Crea classifiche con Firestore

1. Introduzione

Ultimo aggiornamento: 2023-01-27

Cosa serve per creare una classifica?

Fondamentalmente, le classifiche sono solo tabelle di punteggi con un fattore complicante: per leggere una posizione per un determinato punteggio è necessario conoscere tutti gli altri punteggi in un determinato ordine. Inoltre, se il tuo gioco decolla, le classifiche diventeranno grandi e verranno lette e scritte di frequente. Per creare una classifica efficace, è necessario che sia in grado di gestire rapidamente questa operazione di classificazione.

Cosa creerai

In questo codelab implementerai varie classifiche diverse, ognuna adatta a uno scenario diverso.

Obiettivi didattici

Imparerai a implementare quattro diverse classifiche:

  • Un'implementazione semplice che utilizza il conteggio dei record per determinare il rango
  • Una classifica economica che si aggiorna periodicamente
  • Una classifica in tempo reale con alcune sciocchezze sugli alberi
  • Una classifica stocastica (probabilistica) per la classificazione approssimativa di basi di giocatori molto grandi

Che cosa ti serve

  • Una versione recente di Chrome (107 o successive)
  • Node.js 16 o versioni successive (esegui nvm --version per visualizzare il numero di versione se utilizzi nvm)
  • Un piano Blaze di Firebase a pagamento (facoltativo)
  • Firebase CLI versione 11.16.0 o successive
    Per installare la CLI, puoi eseguire npm install -g firebase-tools o consultare la documentazione della CLI per altre opzioni di installazione.
  • Conoscenza di JavaScript, Cloud Firestore, Cloud Functions e Chrome DevTools

2. Configurazione

Ottieni il codice

Abbiamo inserito tutto ciò che ti serve per questo progetto in un repository Git. Per iniziare, devi recuperare il codice e aprirlo nel tuo ambiente di sviluppo preferito. Per questo codelab abbiamo utilizzato VS Code, ma va bene qualsiasi editor di testo.

e decomprimi il file ZIP scaricato.

In alternativa, clona nella directory che preferisci:

git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git

Qual è il nostro punto di partenza?

Il nostro progetto è attualmente una pagina vuota con alcune funzioni vuote:

  • index.html contiene alcuni script di collegamento che ci consentono di richiamare funzioni dalla console per sviluppatori e visualizzarne gli output. Lo useremo per interagire con il nostro backend e visualizzare i risultati delle chiamate di funzione. In uno scenario reale, queste chiamate di backend vengono effettuate direttamente dal gioco. In questo codelab non utilizziamo un gioco perché ci vorrebbe troppo tempo per giocare ogni volta che vuoi aggiungere un punteggio alla classifica.
  • functions/index.js contiene tutte le nostre funzioni Cloud Functions. Vedrai alcune funzioni di utilità, come addScores e deleteScores, nonché le funzioni che implementeremo in questo codelab, che chiamano funzioni di supporto in un altro file.
  • functions/functions-helpers.js contiene le funzioni vuote che implementeremo. Per ogni classifica, implementeremo le funzioni di lettura, creazione e aggiornamento e vedrai in che modo la nostra scelta di implementazione influisce sia sulla complessità dell'implementazione sia sul rendimento di scalabilità.
  • functions/utils.js contiene più funzioni di utilità. Non modificheremo questo file in questo codelab.

Crea e configura un progetto Firebase

Crea un nuovo progetto Firebase

  1. Accedi alla console Firebase utilizzando il tuo Account Google.
  2. Fai clic sul pulsante per creare un nuovo progetto, quindi inserisci un nome per il progetto (ad esempio Leaderboards Codelab).
  3. Fai clic su Continua.
  4. Se richiesto, leggi e accetta i termini di Firebase, quindi fai clic su Continua.
  5. (Facoltativo) Attiva l'assistenza AI nella console Firebase (denominata "Gemini in Firebase").
  6. Per questo codelab non hai bisogno di Google Analytics, quindi disattiva l'opzione Google Analytics.
  7. Fai clic su Crea progetto, attendi il provisioning del progetto, poi fai clic su Continua.

Configura i prodotti Firebase

  1. Nel menu Build (Crea), fai clic su Functions (Funzioni) e, se richiesto, esegui l'upgrade del progetto per utilizzare il piano tariffario Blaze.
  2. Nel menu Build, fai clic su Database Firestore.
  3. Nella finestra di dialogo Crea database che viene visualizzata, seleziona Avvia in modalità di test, quindi fai clic su Avanti.
  4. Scegli una regione dal menu a discesa Località Cloud Firestore, poi fai clic su Attiva.

Configurare ed eseguire la classifica

  1. In un terminale, vai alla radice del progetto ed esegui firebase use --add. Scegli il progetto Firebase appena creato.
  2. Nella radice del progetto, esegui firebase emulators:start --only hosting.
  3. Nel browser, vai a localhost:5000.
  4. Apri la console JavaScript di Chrome DevTools e importa leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Esegui leaderboard.codelab(); nella console. Se visualizzi un messaggio di benvenuto, è tutto pronto. In caso contrario, chiudi l'emulatore e ripeti i passaggi 2-4.

Passiamo alla prima implementazione della classifica.

3. Implementare una classifica semplice

Al termine di questa sezione, saremo in grado di aggiungere un punteggio alla classifica e visualizzare la nostra posizione.

Prima di iniziare, spieghiamo come funziona l'implementazione di questa classifica: tutti i giocatori sono memorizzati in una singola raccolta e il recupero della posizione di un giocatore viene eseguito recuperando la raccolta e contando quanti giocatori si trovano davanti a lui. In questo modo, inserire e aggiornare un punteggio è facile. Per inserire un nuovo punteggio, lo aggiungiamo alla raccolta e, per aggiornarlo, filtriamo l'utente corrente e poi aggiorniamo il documento risultante. Vediamo come si traduce in codice.

In functions/functions-helper.js, implementa la funzione createScore, che è molto semplice:

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

Per l'aggiornamento dei punteggi, dobbiamo solo aggiungere un controllo degli errori per assicurarci che il punteggio da aggiornare esista già:

async function updateScore(playerID, newScore, firestore) {
  const playerSnapshot = await firestore.collection("scores")
      .where("user", "==", playerID).get();
  if (playerSnapshot.size !== 1) {
    throw Error(`User not found in leaderboard: ${playerID}`);
  }
  const player = playerSnapshot.docs[0];
  const doc = firestore.doc(player.id);
  return doc.update({
    score: newScore,
  });
}

Infine, la nostra funzione di classificazione semplice, ma meno scalabile:

async function readRank(playerID, firestore) {
  const scores = await firestore.collection("scores")
      .orderBy("score", "desc").get();
  const player = `${playerID}`;
  let rank = 1;
  for (const doc of scores.docs) {
    const user = `${doc.get("user")}`;
    if (user === player) {
      return {
        user: player,
        rank: rank,
        score: doc.get("score"),
      };
    }
    rank++;
  }
  // No user found
  throw Error(`User not found in leaderboard: ${playerID}`);
}

Mettiamolo alla prova. Esegui il deployment delle funzioni eseguendo questo comando nel terminale:

firebase deploy --only functions

Poi, nella console JS di Chrome, aggiungi altri punteggi per visualizzare la tua posizione in classifica rispetto ad altri giocatori.

leaderboard.addScores(); // Results may take some time to appear.

Ora possiamo aggiungere il nostro punteggio:

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

Al termine della scrittura, nella console dovrebbe essere visualizzato il messaggio "Punteggio creato". Visualizzi invece un errore? Apri i log di Functions tramite la console Firebase per vedere cosa è andato storto.

Infine, possiamo recuperare e aggiornare il nostro punteggio.

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

Tuttavia, questa implementazione ci offre requisiti di tempo e memoria lineari indesiderabili per il recupero della posizione di un determinato punteggio. Poiché il tempo di esecuzione e la memoria delle funzioni sono entrambi limitati, non solo i recuperi diventeranno sempre più lenti, ma dopo che saranno stati aggiunti un numero sufficiente di punteggi alla classifica, le funzioni scadranno o si arresteranno in modo anomalo prima di poter restituire un risultato. Chiaramente, avremo bisogno di qualcosa di meglio se vogliamo andare oltre una manciata di giocatori.

Se sei un appassionato di Firestore, potresti conoscere le query di aggregazione COUNT, che renderebbero questa classifica molto più efficiente. E avresti ragione. Con le query COUNT, questa operazione viene scalata in modo ottimale al di sotto di circa un milione di utenti, anche se le prestazioni rimangono lineari.

Ma aspetta, potresti pensare che se dobbiamo enumerare tutti i documenti della raccolta, possiamo assegnare a ogni documento un rango e poi, quando dobbiamo recuperarlo, i recuperi avranno una complessità temporale e spaziale O(1). Questo ci porta al nostro approccio successivo: la classifica con aggiornamenti periodici.

4. Implementare una classifica che si aggiorna periodicamente

La chiave di questo approccio è memorizzare il ranking nel documento stesso, in modo che il recupero fornisca il ranking senza lavoro aggiuntivo. Per raggiungere questo obiettivo, avremo bisogno di un nuovo tipo di funzione.

In index.js, aggiungi quanto segue:

// Also add this to the top of your file
const admin = require("firebase-admin");

exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
    // Schedule this when most of your users are offline to avoid
    // database spikiness.
    .timeZone("America/Los_Angeles")
    .onRun((context) => {
      const scores = admin.firestore().collection("scores");
      scores.orderBy("score", "desc").get().then((snapshot) => {
        let rank = 1;
        const writes = [];
        for (const docSnapshot of snapshot.docs) {
          const docReference = scores.doc(docSnapshot.id);
          writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
          rank++;
        }
        Promise.all(writes).then((result) => {
          console.log(`Writes completed with results: ${result}`);
        });
      });
      return null;
    });

Ora le nostre operazioni di lettura, aggiornamento e scrittura sono tutte semplici e chiare. Scrittura e aggiornamento rimangono invariati, ma lettura diventa (in functions-helpers.js):

async function readRank(playerID, firestore) {
  const scores = firestore.collection("scores");
  const playerSnapshot = await scores
      .where("user", "==", playerID).get();
  if (playerSnapshot.size === 0) {
    throw Error(`User not found in leaderboard: ${playerID}`);
  }

  const player = playerSnapshot.docs[0];
  if (player.get("rank") === undefined) {
    // This score was added before our scheduled function could run,
    // but this shouldn't be treated as an error
    return {
    user: playerID,
    rank: null,
    score: player.get("score"),
  };
  }

  return {
    user: playerID,
    rank: player.get("rank"),
    score: player.get("score"),
  };
}

Purtroppo, non potrai eseguire il deployment e il test senza aggiungere un account di fatturazione al tuo progetto. Se hai un account di fatturazione, riduci l'intervallo della funzione pianificata e osserva come la funzione assegna magicamente i ranking ai punteggi della classifica.

In caso contrario, elimina la funzione pianificata e vai all'implementazione successiva.

Elimina i punteggi nel database Firestore facendo clic sui tre puntini accanto alla raccolta dei punteggi per prepararti per la sezione successiva.

Pagina del documento dei punteggi Firestore con\nElimina raccolta attivata

5. Implementare una classifica in tempo reale degli alberi

Questo approccio funziona memorizzando i dati di ricerca nella raccolta del database stessa. Invece di avere una raccolta uniforme, il nostro obiettivo è archiviare tutto in una struttura ad albero che possiamo attraversare spostandoci tra i documenti. Ciò consente di eseguire una ricerca binaria (o n-aria) per il rango di un determinato punteggio. Come potrebbe essere?

Per iniziare, vogliamo essere in grado di distribuire i nostri punteggi in bucket più o meno uniformi, il che richiederà una certa conoscenza dei valori dei punteggi registrati dai nostri utenti. Ad esempio, se stai creando una classifica per la valutazione delle abilità in un gioco competitivo, le valutazioni delle abilità dei tuoi utenti finiranno quasi sempre per essere distribuite normalmente. La nostra funzione di generazione di punteggi casuali utilizza Math.random() di JavaScript, che produce una distribuzione approssimativamente uniforme, quindi divideremo i bucket in modo uniforme.

In questo esempio utilizzeremo tre bucket per semplicità, ma probabilmente scoprirai che se utilizzi questa implementazione in un'app reale, un numero maggiore di bucket produrrà risultati più rapidi: un albero meno profondo significa in media meno recuperi di raccolte e meno contese di blocchi.

Il rango di un giocatore è dato dalla somma del numero di giocatori con punteggi più alti, più uno per il giocatore stesso. Ogni raccolta in scores memorizzerà tre documenti, ognuno con un intervallo, il numero di documenti in ogni intervallo e tre sottoraccolte corrispondenti. Per leggere una posizione, attraverseremo questo albero alla ricerca di un punteggio e tenendo traccia della somma dei punteggi più alti. Quando troviamo il nostro punteggio, avremo anche la somma corretta.

La scrittura è molto più complicata. Innanzitutto, dobbiamo eseguire tutte le scritture all'interno di una transazione per evitare incoerenze dei dati quando si verificano più scritture o letture contemporaneamente. Inoltre, dovremo mantenere tutte le condizioni descritte sopra mentre attraversiamo l'albero per scrivere i nuovi documenti. Infine, poiché abbiamo tutta la complessità dell'albero di questo nuovo approccio combinata con la necessità di archiviare tutti i nostri documenti originali, il costo di archiviazione aumenterà leggermente (ma rimane lineare).

In functions-helpers.js:

async function createScore(playerID, score, firestore) {
  /**
   * This function assumes a minimum score of 0 and that value
   * is between min and max.
   * Returns the expected size of a bucket for a given score
   * so that bucket sizes stay constant, to avoid expensive
   * re-bucketing.
   * @param {number} value The new score.
   * @param {number} min The min of the previous range.
   * @param {number} max The max of the previous range. Must be greater than
   *     min.
   * @return {Object<string, number>} Returns an object containing the new min
   *     and max.
   */
  function bucket(value, min, max) {
    const bucketSize = (max - min) / 3;
    const bucketMin = Math.floor(value / bucketSize) * bucketSize;
    const bucketMax = bucketMin + bucketSize;
    return {min: bucketMin, max: bucketMax};
  }

  /**
   * A function used to store pending writes until all reads within a
   * transaction have completed.
   *
   * @callback PendingWrite
   * @param {admin.firestore.Transaction} transaction The transaction
   *     to be used for writes.
   * @return {void}
   */

  /**
   * Recursively searches for the node to write the score to,
   * then writes the score and updates any counters along the way.
   * @param {number} id The user associated with the score.
   * @param {number} value The new score.
   * @param {admin.firestore.CollectionReference} coll The collection this
   *     value should be written to.
   * @param {Object<string, number>} range An object with properties min and
   *     max defining the range this score should be in. Ranges cannot overlap
   *     without causing problems. Use the bucket function above to determine a
   *     root range from constant values to ensure consistency.
   * @param {admin.firestore.Transaction} transaction The transaction used to
   *     ensure consistency during tree updates.
   * @param {Array<PendingWrite>} pendingWrites A series of writes that should
   *     occur once all reads within a transaction have completed.
   * @return {void} Write error/success is handled via the transaction object.
   */
  async function writeScoreToCollection(
      id, value, coll, range, transaction, pendingWrites) {
    const snapshot = await transaction.get(coll);
    if (snapshot.empty) {
      // This is the first score to be inserted into this node.
      for (const write of pendingWrites) {
        write(transaction);
      }
      const docRef = coll.doc();
      transaction.create(docRef, {exact: {score: value, user: id}});
      return;
    }

    const min = range.min;
    const max = range.max;

    for (const node of snapshot.docs) {
      const data = node.data();
      if (data.exact !== undefined) {
        // This node held an exact score.
        const newRange = bucket(value, min, max);
        const tempRange = bucket(data.exact.score, min, max);

        if (newRange.min === tempRange.min &&
          newRange.max === tempRange.max) {
          // The scores belong in the same range, so we need to "demote" both
          // to a lower level of the tree and convert this node to a range.
          const rangeData = {
            range: newRange,
            count: 2,
          };
          for (const write of pendingWrites) {
            write(transaction);
          }
          const docReference = node.ref;
          transaction.set(docReference, rangeData);
          transaction.create(docReference.collection("scores").doc(), data);
          transaction.create(
              docReference.collection("scores").doc(),
              {exact: {score: value, user: id}},
          );
          return;
        } else {
          // The scores are in different ranges. Continue and try to find a
          // range that fits this score.
          continue;
        }
      }

      if (data.range.min <= value && data.range.max > value) {
        // The score belongs to this range that may have subvalues.
        // Increment the range's count in pendingWrites, since
        // subsequent recursion may incur more reads.
        const docReference = node.ref;
        const newCount = node.get("count") + 1;
        pendingWrites.push((t) => {
          t.update(docReference, {count: newCount});
        });
        const newRange = bucket(value, min, max);
        return writeScoreToCollection(
            id,
            value,
            docReference.collection("scores"),
            newRange,
            transaction,
            pendingWrites,
        );
      }
    }

    // No appropriate range was found, create an `exact` value.
    transaction.create(coll.doc(), {exact: {score: value, user: id}});
  }

  const scores = firestore.collection("scores");
  const players = firestore.collection("players");
  return firestore.runTransaction((transaction) => {
    return writeScoreToCollection(
        playerID, score, scores, {min: 0, max: 1000}, transaction, [],
    ).then(() => {
      transaction.create(players.doc(), {
        user: playerID,
        score: score,
      });
    });
  });
}

Si tratta di un'implementazione più complessa rispetto all'ultima, che prevedeva una sola chiamata al metodo e sei righe di codice. Una volta implementato questo metodo, prova ad aggiungere alcuni punteggi al database e a osservare la struttura dell'albero risultante. Nella console JS:

leaderboard.addScores();

La struttura del database risultante dovrebbe essere simile a questa, con la struttura ad albero chiaramente visibile e le foglie dell'albero che rappresentano i singoli punteggi.

scores
  - document
    range: 0-333.33
    count: 2
    scores:
      - document
        exact:
          score: 18
          user: 1
      - document
        exact:
          score: 22
          user: 2

Ora che abbiamo superato la parte più difficile, possiamo leggere i punteggi attraversando l'albero come descritto in precedenza.

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = firestore.collection("scores");

  /**
   * Recursively finds a player score in a collection.
   * @param {string} id The player's ID, since some players may be tied.
   * @param {number} value The player's score.
   * @param {admin.firestore.CollectionReference} coll The collection to
   *     search.
   * @param {number} currentCount The current count of players ahead of the
   *     player.
   * @return {Promise<number>} The rank of the player (the number of players
   *     ahead of them plus one).
   */
  async function findPlayerScoreInCollection(id, value, coll, currentCount) {
    const snapshot = await coll.get();
    for (const doc of snapshot.docs) {
      if (doc.get("exact") !== undefined) {
        // This is an exact score. If it matches the score we're looking
        // for, return. Otherwise, check if it should be counted.
        const exact = doc.data().exact;
        if (exact.score === value) {
          if (exact.user === id) {
            // Score found.
            return currentCount + 1;
          } else {
            // The player is tied with another. In this case, don't increment
            // the count.
            continue;
          }
        } else if (exact.score > value) {
          // Increment count
          currentCount++;
          continue;
        } else {
          // Do nothing
          continue;
        }
      } else {
        // This is a range. If it matches the score we're looking for,
        // search the range recursively, otherwise, check if it should be
        // counted.
        const range = doc.data().range;
        const count = doc.get("count");
        if (range.min > value) {
          // The range is greater than the score, so add it to the rank
          // count.
          currentCount += count;
          continue;
        } else if (range.max <= value) {
          // do nothing
          continue;
        } else {
          const subcollection = doc.ref.collection("scores");
          return findPlayerScoreInCollection(
              id,
              value,
              subcollection,
              currentCount,
          );
        }
      }
    }
    // There was no range containing the score.
    throw Error(`Range not found for score: ${value}`);
  }

  const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
  return {
    user: playerID,
    rank: rank,
    score: score,
  };
}

Gli aggiornamenti vengono lasciati come esercizio aggiuntivo. Prova ad aggiungere e recuperare i punteggi nella console JS con i metodi leaderboard.addScore(id, score) e leaderboard.getRank(id) e osserva come cambia la classifica nella console Firebase.

Con questa implementazione, tuttavia, la complessità che abbiamo aggiunto per ottenere prestazioni logaritmiche ha un costo.

  • Innanzitutto, questa implementazione della classifica può riscontrare problemi di contesa dei blocchi, poiché le transazioni richiedono la lettura e la scrittura di blocchi nei documenti per garantire la coerenza.
  • In secondo luogo, Firestore impone un limite di profondità delle raccolte secondarie pari a 100, il che significa che dovrai evitare di creare sottoalberi dopo 100 pareggi, cosa che questa implementazione non fa.
  • Infine, questa classifica viene scalata in modo logaritmico solo nel caso ideale in cui l'albero è bilanciato. Se non lo è, il rendimento peggiore di questa classifica è di nuovo lineare.

Al termine, elimina le raccolte scores e players tramite la console Firebase e passeremo all'ultima implementazione della classifica.

6. Implementare una classifica stocastica (probabilistica)

Quando esegui il codice di inserimento, potresti notare che se lo esegui troppe volte in parallelo, le funzioni inizieranno a non funzionare con un messaggio di errore relativo alla contesa del blocco delle transazioni. Esistono modi per aggirare questo problema che non esploreremo in questo codelab, ma se non hai bisogno di una classificazione esatta, puoi abbandonare tutta la complessità dell'approccio precedente per qualcosa di più semplice e veloce. Vediamo come potremmo restituire un ranking stimato per i punteggi dei nostri giocatori anziché un ranking esatto e come ciò cambia la logica del nostro database.

Per questo approccio, divideremo la nostra classifica in 100 bucket, ognuno dei quali rappresenta circa l'1% dei punteggi che prevediamo di ricevere. Questo approccio funziona anche senza conoscere la distribuzione dei nostri punteggi, nel qual caso non abbiamo modo di garantire una distribuzione più o meno uniforme dei punteggi nel bucket, ma otterremo una maggiore precisione nelle nostre approssimazioni se sappiamo come verranno distribuiti i nostri punteggi.

Il nostro approccio è il seguente: come prima, ogni bucket memorizza il conteggio del numero di punteggi al suo interno e l'intervallo dei punteggi. Quando inseriamo un nuovo punteggio, troviamo il bucket per il punteggio e incrementiamo il relativo conteggio. Quando recuperiamo un ranking, sommiamo i bucket precedenti e poi approssimiamo all'interno del nostro bucket anziché eseguire ulteriori ricerche. In questo modo, otteniamo ricerche e inserimenti a tempo costante molto efficienti e richiediamo molto meno codice.

Innanzitutto, l'inserimento:

// Add this line to the top of your file.
const admin = require("firebase-admin");

// Implement this method (again).
async function createScore(playerID, score, firestore) {
  const scores = await firestore.collection("scores").get();
  if (scores.empty) {
    // Create the buckets since they don't exist yet.
    // In a real app, don't do this in your write function. Do it once
    // manually and then keep the buckets in your database forever.
    for (let i = 0; i < 10; i++) {
      const min = i * 100;
      const max = (i + 1) * 100;
      const data = {
        range: {
          min: min,
          max: max,
        },
        count: 0,
      };
      await firestore.collection("scores").doc().create(data);
    }
    throw Error("Database not initialized");
  }

  const buckets = await firestore.collection("scores")
      .where("range.min", "<=", score).get();
  for (const bucket of buckets.docs) {
    const range = bucket.get("range");
    if (score < range.max) {
      const writeBatch = firestore.batch();
      const playerDoc = firestore.collection("players").doc();
      writeBatch.create(playerDoc, {
        user: playerID,
        score: score,
      });
      writeBatch.update(
          bucket.ref,
          {count: admin.firestore.FieldValue.increment(1)},
      );
      const scoreDoc = bucket.ref.collection("scores").doc();
      writeBatch.create(scoreDoc, {
        user: playerID,
        score: score,
      });
      return writeBatch.commit();
    }
  }
}

Noterai che questo codice di inserimento ha una logica per inizializzare lo stato del database nella parte superiore con un avviso di non fare una cosa del genere in produzione. Il codice di inizializzazione non è protetto dalle condizioni di competizione, quindi se lo facessi, più scritture simultanee corromperebbero il database creando una serie di bucket duplicati.

Procedi con il deployment delle funzioni e poi esegui un inserimento per inizializzare tutti i bucket con un conteggio pari a zero. Restituirà un errore, che puoi ignorare tranquillamente.

leaderboard.addScore(999, 0); // The params aren't important here.

Ora che il database è inizializzato correttamente, possiamo eseguire addScores e visualizzare la struttura dei nostri dati nella console Firebase. La struttura risultante è molto più piatta rispetto alla nostra ultima implementazione, anche se sono simili superficialmente.

leaderboard.addScores();

E ora, per leggere i punteggi:

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = await firestore.collection("scores").get();
  let currentCount = 1; // Player is rank 1 if there's 0 better players.
  let interp = -1;
  for (const bucket of scores.docs) {
    const range = bucket.get("range");
    const count = bucket.get("count");
    if (score < range.min) {
      currentCount += count;
    } else if (score >= range.max) {
      // do nothing
    } else {
      // interpolate where the user is in this bucket based on their score.
      const relativePosition = (score - range.min) / (range.max - range.min);
      interp = Math.round(count - (count * relativePosition));
    }
  }

  if (interp === -1) {
    // Didn't find a correct bucket
    throw Error(`Score out of bounds: ${score}`);
  }

  return {
    user: playerID,
    rank: currentCount + interp,
    score: score,
  };
}

Poiché abbiamo fatto in modo che la funzione addScores generi una distribuzione uniforme dei punteggi e utilizziamo l'interpolazione lineare all'interno dei bucket, otterremo risultati molto accurati, le prestazioni della nostra classifica non peggioreranno all'aumentare del numero di utenti e non dovremo preoccuparci (tanto) della contesa di blocco durante l'aggiornamento dei conteggi.

7. Appendice: Imbrogli

Un attimo, potresti pensare che se scrivo valori nel mio codelab tramite la console JS di una scheda del browser, uno qualsiasi dei miei giocatori non può semplicemente mentire alla classifica e dire di aver ottenuto un punteggio elevato che non ha raggiunto in modo corretto?

Sì, possono farlo. Se vuoi impedire i comportamenti fraudolenti, il modo più efficace è disattivare le scritture dei client nel database tramite le regole di sicurezza, l'accesso sicuro alle tue Cloud Functions in modo che i client non possano chiamarle direttamente e poi convalidare le azioni di gioco sul server prima di inviare gli aggiornamenti del punteggio alla classifica.

È importante notare che questa strategia non è una panacea contro i cheat: con un incentivo sufficientemente grande, i cheater possono trovare modi per aggirare le convalide lato server e molti videogiochi di successo di grandi dimensioni giocano costantemente a guardie e ladri con i cheater per identificare nuovi cheat e impedirne la proliferazione. Una conseguenza difficile di questo fenomeno è che la convalida lato server per ogni gioco è intrinsecamente personalizzata; sebbene Firebase fornisca strumenti anti-abuso come App Check che impediscono a un utente di copiare il tuo gioco tramite un semplice client basato su script, Firebase non fornisce alcun servizio che equivalga a un sistema anti-cheat olistico.

Qualsiasi convalida diversa da quella lato server, per un gioco sufficientemente popolare o una barriera al cheating sufficientemente bassa, comporterà una classifica in cui i valori più alti sono tutti di cheater.

8. Complimenti

Congratulazioni, hai creato correttamente quattro diverse classifiche su Firebase. A seconda delle esigenze del tuo gioco in termini di precisione e velocità, potrai scegliere quella più adatta a te a un costo ragionevole.

A seguire, scopri i percorsi di apprendimento per i giochi.