Costruisci classifiche con Firestore

1. Introduzione

Ultimo aggiornamento: 2023-01-27

Cosa serve per costruire una classifica?

Fondamentalmente, le classifiche sono solo tabelle di punteggi con un fattore complicante: leggere una classifica per un dato punteggio richiede la conoscenza di tutti gli altri punteggi in un qualche ordine. Inoltre, se il tuo gioco decolla, le tue classifiche diventeranno grandi e verranno lette e scritte frequentemente. Per costruire una classifica di successo, è necessario essere in grado di gestire rapidamente questa operazione di classificazione.

Cosa costruirai

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

Cosa imparerai

Imparerai come implementare quattro diverse classifiche:

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

Di cosa avrai bisogno

  • Una versione recente di Chrome (107 o successiva)
  • Node.js 16 o versione successiva (esegui nvm --version per vedere il numero della tua versione se utilizzi nvm)
  • Un piano Firebase Blaze a pagamento (facoltativo)
  • La CLI Firebase v11.16.0 o successiva
    Per installare la CLI, puoi eseguire npm install -g firebase-tools o fare riferimento alla documentazione della CLI per ulteriori opzioni di installazione.
  • Conoscenza di JavaScript, Cloud Firestore, Cloud Functions e Chrome DevTools

2. Preparazione

Ottieni il codice

Abbiamo inserito tutto ciò di cui hai bisogno per questo progetto in un repository Git. Per iniziare, dovrai prendere il codice e aprirlo nel tuo ambiente di sviluppo preferito. Per questo codelab abbiamo utilizzato VS Code, ma qualsiasi editor di testo andrà bene.

e decomprimi il file zip scaricato.

Oppure 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 tabula rasa con alcune funzioni vuote:

  • index.html contiene alcuni script di colla che ci consentono di invocare funzioni dalla console di sviluppo e vedere i loro output. Lo useremo per interfacciarci con il nostro backend e vedere i risultati delle nostre invocazioni di funzioni. In uno scenario reale, effettueresti queste chiamate al backend direttamente dal tuo gioco: non stiamo utilizzando un gioco in questo codelab perché ci vorrebbe troppo tempo per giocare ogni volta che desideri aggiungere un punteggio alla classifica .
  • functions/index.js contiene tutte le nostre funzioni cloud. Vedrai alcune funzioni di utilità, come addScores e deleteScores , nonché le funzioni che implementeremo in questo codelab, che richiamano funzioni di supporto in un altro file.
  • functions/functions-helpers.js contiene le funzioni vuote che implementeremo. Per ogni classifica, implementeremo funzioni di lettura, creazione e aggiornamento e vedrai come la nostra scelta di implementazione influisce sia sulla complessità della nostra implementazione che sulle sue prestazioni di scalabilità.
  • functions/utils.js contiene più funzioni di utilità. Non toccheremo questo file in questo codelab.

Crea e configura un progetto Firebase

  1. Nella console Firebase , fai clic su Aggiungi progetto .
  2. Per creare un nuovo progetto, inserisci il nome del progetto desiderato.
    Ciò imposterà anche l'ID progetto (visualizzato sotto il nome del progetto) su qualcosa basato sul nome del progetto. Facoltativamente, puoi fare clic sull'icona di modifica sull'ID progetto per personalizzarlo ulteriormente.
  3. Se richiesto, esamina e accetta i termini di Firebase .
  4. Fare clic su Continua .
  5. Seleziona l'opzione Abilita Google Analytics per questo progetto , quindi fai clic su Continua .
  6. Seleziona un account Google Analytics esistente da utilizzare o seleziona Crea un nuovo account per creare un nuovo account.
  7. Fare clic su Crea progetto .
  8. Una volta creato il progetto, fare clic su Continua .
  9. Dal menu Crea , fai clic su Funzioni e, se richiesto, aggiorna il tuo progetto per utilizzare il piano di fatturazione Blaze.
  10. Dal menu Crea , fai clic su Database Firestore .
  11. Nella finestra di dialogo Crea database visualizzata, seleziona Avvia in modalità test , quindi fai clic su Avanti .
  12. Scegli una regione dal menu a discesa delle posizioni di Cloud Firestore , quindi fai clic su Abilita .

Configura ed esegui la tua classifica

  1. In un terminale, vai alla root del progetto ed esegui firebase use --add . Scegli il progetto Firebase che hai appena creato.
  2. Nella radice del progetto, esegui firebase emulators:start --only hosting .
  3. Nel tuo 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(); in consolle. Se vedi un messaggio di benvenuto, è tutto a posto! In caso contrario, spegnere l'emulatore ed eseguire nuovamente i passaggi 2-4.

Passiamo alla prima implementazione della classifica.

3. Implementa una semplice classifica

Entro la fine di questa sezione, saremo in grado di aggiungere un punteggio alla classifica e far sì che ci indichi la nostra posizione in classifica.

Prima di entrare nel merito, spieghiamo come funziona l'implementazione della classifica: tutti i giocatori vengono archiviati in un'unica raccolta e il recupero del grado di un giocatore viene effettuato recuperando la raccolta e contando quanti giocatori sono davanti a lui. Ciò semplifica l'inserimento e l'aggiornamento di una partitura. Per inserire un nuovo punteggio, lo aggiungiamo semplicemente alla raccolta e, per aggiornarlo, filtriamo per il nostro utente corrente e quindi aggiorniamo il documento risultante. Vediamo come appare nel codice.

functions/functions-helper.js , implementa la funzione createScore , che è tanto semplice quanto sembra:

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

Per aggiornare i 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,
  });
}

E infine, la nostra funzione di rango 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! Distribuisci le tue funzioni eseguendo quanto segue nel terminale:

firebase deploy --only functions

E poi, nella console JS di Chrome, aggiungi altri punteggi in modo che possiamo vedere la nostra classifica tra gli altri giocatori.

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

Ora possiamo aggiungere la nostra partitura al mix:

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

Al termine della scrittura, dovresti vedere una risposta nella console che dice "Punteggio creato". Visualizzi invece un errore? Apri i log delle funzioni tramite la console Firebase per vedere cosa è andato storto.

E, 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 fornisce tempi lineari e requisiti di memoria indesiderati per recuperare il rango di un determinato punteggio. Poiché il tempo di esecuzione e la memoria delle funzioni sono entrambi limitati, non solo ciò significherà che i nostri recuperi diventeranno sempre più lenti, ma dopo che saranno stati aggiunti abbastanza punteggi alla classifica, le nostre funzioni andranno in timeout o si bloccheranno prima di poter restituire un risultato. Chiaramente, avremo bisogno di qualcosa di meglio se vogliamo espanderci oltre una manciata di giocatori.

Se sei un appassionato di Firestore, potresti essere a conoscenza di COUNT query di aggregazione , che renderebbero questa classifica molto più performante. E avresti ragione! Con COUNT query, questo scala ben al di sotto di un milione di utenti circa, sebbene le sue prestazioni siano ancora lineari.

Ma aspetta, potresti pensare a te stesso, se enumereremo comunque tutti i documenti nella raccolta, possiamo assegnare a ogni documento un rango e quindi quando dobbiamo recuperarlo, i nostri recuperi saranno O(1) tempo e memoria! Questo ci porta al nostro approccio successivo, la classifica che viene aggiornata periodicamente.

4. Implementare una classifica aggiornata periodicamente

La chiave di questo approccio è memorizzare il rango nel documento stesso, quindi recuperarlo ci fornisce il rango 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 piacevoli. Scrittura e aggiornamento rimangono entrambi 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"),
  };
}

Sfortunatamente, non potrai distribuirlo e testarlo senza aggiungere un account di fatturazione al tuo progetto. Se disponi di un account di fatturazione, accorcia l'intervallo della funzione pianificata e osserva la tua funzione assegnare magicamente i ranghi ai punteggi della classifica.

In caso contrario, eliminare la funzione pianificata e passare all'implementazione successiva.

Vai avanti ed elimina i punteggi nel tuo database Firestore facendo clic sui 3 punti accanto alla raccolta dei punteggi per prepararti alla sezione successiva.

Firestore scores document page with\nDelete Collection activated

5. Implementa una classifica ad albero in tempo reale

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

Per iniziare, vorremo essere in grado di distribuire i nostri punteggi in intervalli 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 del punteggio casuale utilizza Math.random() di JavaScript, che si traduce in una distribuzione approssimativamente uniforme, quindi divideremo i nostri bucket equamente.

In questo esempio utilizzeremo 3 bucket per semplicità, ma probabilmente scoprirai che se utilizzi questa implementazione in un'app reale più bucket produrranno risultati più rapidi: un albero meno profondo significa in media meno recuperi di raccolte e meno conflitti di blocco.

Il grado di un giocatore è dato dalla somma del numero dei giocatori con il punteggio più alto, più uno per il giocatore stesso. Ciascuna raccolta sotto scores memorizzerà tre documenti, ciascuno con un intervallo, il numero di documenti sotto ciascun intervallo e quindi tre sottoraccolte corrispondenti. Per leggere una classifica percorreremo questo albero alla ricerca di un punteggio e tenendo traccia della somma dei punteggi maggiori. Quando troveremo il nostro punteggio, avremo anche la somma corretta.

Scrivere è decisamente più complicato. Innanzitutto, dovremo eseguire tutte le scritture all'interno di una transazione per evitare incoerenze nei dati quando si verificano più scritture o letture contemporaneamente. Dovremo inoltre mantenere tutte le condizioni che abbiamo descritto sopra mentre attraversiamo l'albero per scrivere i nostri nuovi documenti. E, infine, poiché abbiamo tutta la complessità di questo nuovo approccio combinata con la necessità di archiviare tutti i nostri documenti originali, i nostri costi di archiviazione aumenteranno leggermente (ma sono comunque lineari).

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,
      });
    });
  });
}

Questo è certamente più complicato della nostra ultima implementazione, che prevedeva una singola chiamata al metodo e solo sei righe di codice. Una volta implementato questo metodo, prova ad aggiungere alcuni punteggi al database e ad osservare la struttura dell'albero risultante. Nella tua console JS:

leaderboard.addScores();

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

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

Ora che abbiamo risolto la parte 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 tua console JS con i metodi leaderboard.addScore(id, score) e leaderboard.getRank(id) e osserva come cambia la tua classifica nella console Firebase.

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

  • Innanzitutto, questa implementazione della classifica può incorrere in problemi di conflitto di blocco, poiché le transazioni richiedono il blocco delle letture e delle scritture sui documenti per assicurarsi che rimangano coerenti.
  • In secondo luogo, Firestore impone un limite di profondità della sottoraccolta pari a 100 , il che significa che dovrai evitare di creare sottoalberi dopo 100 punteggi pari, cosa che questa implementazione non fa.
  • Infine, questa classifica si ridimensiona logaritmicamente solo nel caso ideale in cui l'albero è bilanciato: se è sbilanciato, la prestazione nel caso peggiore di questa classifica è ancora una volta lineare.

Una volta terminato, elimina i scores e le raccolte players tramite la console Firebase e passeremo alla nostra 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 tue funzioni inizieranno a fallire con un messaggio di errore relativo al conflitto sul blocco della transazione. Esistono modi per aggirare questo problema che non esploreremo in questo codelab, ma se non hai bisogno di una classificazione esatta, puoi eliminare tutta la complessità dell'approccio precedente per qualcosa di più semplice e veloce. Diamo un'occhiata a come potremmo restituire una classifica stimata per i punteggi dei nostri giocatori invece di una classifica esatta, e come ciò cambia la logica del nostro database.

Per questo approccio, divideremo la nostra classifica in 100 segmenti, ciascuno dei quali rappresenta circa l'1% dei punteggi che prevediamo di ricevere. Questo approccio funziona anche senza conoscere la nostra distribuzione dei punteggi, nel qual caso non abbiamo modo di garantire una distribuzione più o meno uniforme dei punteggi in tutto il 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 all'interno e l'intervallo dei punteggi. Quando inseriamo un nuovo punteggio, troveremo il secchio per il punteggio e ne incrementeremo il conteggio. Quando recuperiamo una classifica, sommamo semplicemente i gruppi precedenti e poi ci approssimiamo all'interno del nostro gruppo invece di cercare ulteriormente. Questo ci offre ricerche e inserimenti costanti nel tempo molto piacevoli e richiede 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 tuo database in alto con un avviso di non fare qualcosa di simile in produzione. Il codice per l'inizializzazione non è affatto protetto dalle condizioni di competizione, quindi se lo facessi, più scritture simultanee danneggerebbero il tuo database dandoti un mucchio di bucket duplicati.

Vai avanti e distribuisci le tue funzioni, quindi esegui un inserimento per inizializzare tutti i bucket con un conteggio pari a zero. Restituirà un errore, che puoi tranquillamente ignorare.

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

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

leaderboard.addScores();

E ora leggiamo 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,
  };
}

Dato che 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 man mano che aumentiamo il numero di utenti, e non dobbiamo preoccuparci (tanto) del conflitto di lock quando l'aggiornamento conta.

7. Addendum: imbroglio

Aspetta, potresti pensare, se scrivo valori nel mio codelab tramite la console JS di una scheda del browser, nessuno dei miei giocatori potrebbe semplicemente mentire alla classifica e dire di aver ottenuto un punteggio elevato che non hanno raggiungere in modo equo?

Si Loro possono. Se vuoi prevenire gli imbrogli, il modo più efficace per farlo è disabilitare le scritture dei client sul tuo database tramite regole di sicurezza , proteggere l'accesso alle tue funzioni cloud in modo che i client non possano chiamarle direttamente e quindi convalidare le azioni di gioco sul tuo server prima invio di aggiornamenti del punteggio alla classifica.

È importante notare che questa strategia non è una panacea contro gli imbrogli: con un incentivo sufficientemente ampio, gli imbroglioni possono trovare modi per aggirare le convalide lato server e molti videogiochi di grandi dimensioni e di successo giocano costantemente al gatto e al topo con i loro imbroglioni per identificare nuovi trucchi e impedirne la proliferazione. Una difficile conseguenza di questo fenomeno è che la convalida lato server per ogni gioco è intrinsecamente personalizzata; sebbene Firebase fornisca strumenti antiabuso come App Check che impediranno a un utente di copiare il tuo gioco tramite un semplice client con script, Firebase non fornisce alcun servizio che equivalga a un anti-cheat olistico.

Qualsiasi cosa al di fuori della convalida lato server, per un gioco abbastanza popolare o una barriera sufficientemente bassa contro gli imbrogli, si tradurrà in una classifica in cui i valori migliori sono tutti gli imbroglioni.

8. Congratulazioni

Congratulazioni, hai creato con successo quattro diverse classifiche su Firebase! A seconda delle esigenze di precisione e velocità del tuo gioco, potrai sceglierne uno che funzioni per te a un costo ragionevole.

Successivamente, controlla i percorsi di apprendimento per i giochi.