Créez des classements avec Firestore

1. Introduction

Dernière mise à jour : 2023-01-27

Que faut-il pour construire un classement ?

À la base, les classements ne sont que des tableaux de scores avec un facteur de complication : la lecture d'un classement pour un score donné nécessite la connaissance de tous les autres scores dans un certain ordre. De plus, si votre jeu décolle, vos classements s'agrandiront et seront fréquemment lus et écrits. Pour construire un classement réussi, il doit être capable de gérer cette opération de classement rapidement.

Ce que vous construirez

Dans cet atelier de programmation, vous allez implémenter différents classements, chacun adapté à un scénario différent.

Ce que vous apprendrez

Vous apprendrez à mettre en œuvre quatre classements différents :

  • Une implémentation naïve utilisant un simple comptage d'enregistrements pour déterminer le classement
  • Un classement bon marché et mis à jour périodiquement
  • Un classement en temps réel avec quelques bêtises sur les arbres
  • Un classement stochastique (probabiliste) pour un classement approximatif de très grandes bases de joueurs

Ce dont vous aurez besoin

  • Une version récente de Chrome (107 ou version ultérieure)
  • Node.js 16 ou supérieur (exécutez nvm --version pour voir votre numéro de version si vous utilisez nvm)
  • Un forfait Firebase Blaze payant (facultatif)
  • La CLI Firebase v11.16.0 ou supérieure
    Pour installer la CLI, vous pouvez exécuter npm install -g firebase-tools ou vous référer à la documentation CLI pour plus d'options d'installation.
  • Connaissance de JavaScript, Cloud Firestore, Cloud Functions et Chrome DevTools

2. Mise en place

Obtenez le code

Nous avons mis tout ce dont vous avez besoin pour ce projet dans un dépôt Git. Pour commencer, vous devrez récupérer le code et l'ouvrir dans votre environnement de développement préféré. Pour cet atelier de programmation, nous avons utilisé VS Code, mais n'importe quel éditeur de texte fera l'affaire.

et décompressez le fichier zip téléchargé.

Ou clonez dans le répertoire de votre choix :

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

Quel est notre point de départ ?

Notre projet est actuellement une page vierge avec quelques fonctions vides :

  • index.html contient des scripts de colle qui nous permettent d'invoquer des fonctions depuis la console de développement et de voir leurs sorties. Nous l'utiliserons pour interfacer avec notre backend et voir les résultats de nos invocations de fonctions. Dans un scénario réel, vous effectueriez ces appels backend directement depuis votre jeu. Nous n'utilisons pas de jeu dans cet atelier de programmation, car cela prendrait trop de temps de jouer à un jeu à chaque fois que vous souhaitez ajouter un score au classement. .
  • functions/index.js contient toutes nos fonctions Cloud. Vous verrez quelques fonctions utilitaires, comme addScores et deleteScores , ainsi que les fonctions que nous allons implémenter dans cet atelier de programmation, qui font appel à des fonctions d'assistance dans un autre fichier.
  • functions/functions-helpers.js contient les fonctions vides que nous allons implémenter. Pour chaque classement, nous implémenterons des fonctions de lecture, de création et de mise à jour, et vous verrez comment notre choix d'implémentation affecte à la fois la complexité de notre implémentation et ses performances de mise à l'échelle.
  • functions/utils.js contient plus de fonctions utilitaires. Nous ne toucherons pas à ce fichier dans cet atelier de programmation.

Créer et configurer un projet Firebase

  1. Dans la console Firebase , cliquez sur Ajouter un projet .
  2. Pour créer un nouveau projet, entrez le nom du projet souhaité.
    Cela définira également l'ID du projet (affiché sous le nom du projet) sur quelque chose basé sur le nom du projet. Vous pouvez éventuellement cliquer sur l'icône d'édition sur l'ID du projet pour le personnaliser davantage.
  3. Si vous y êtes invité, consultez et acceptez les conditions d'utilisation de Firebase .
  4. Cliquez sur Continuer .
  5. Sélectionnez l'option Activer Google Analytics pour ce projet , puis cliquez sur Continuer .
  6. Sélectionnez un compte Google Analytics existant à utiliser ou sélectionnez Créer un nouveau compte pour créer un nouveau compte.
  7. Cliquez sur Créer un projet .
  8. Une fois le projet créé, cliquez sur Continuer .
  9. Dans le menu Créer , cliquez sur Fonctions et si vous y êtes invité, mettez à niveau votre projet pour utiliser le plan de facturation Blaze.
  10. Dans le menu Créer , cliquez sur Base de données Firestore .
  11. Dans la boîte de dialogue Créer une base de données qui apparaît, sélectionnez Démarrer en mode test , puis cliquez sur Suivant .
  12. Choisissez une région dans la liste déroulante Emplacement Cloud Firestore , puis cliquez sur Activer .

Configurez et exécutez votre classement

  1. Dans un terminal, accédez à la racine du projet et exécutez firebase use --add . Choisissez le projet Firebase que vous venez de créer.
  2. À la racine du projet, exécutez firebase emulators:start --only hosting .
  3. Dans votre navigateur, accédez à localhost:5000 .
  4. Ouvrez la console JavaScript de Chrome DevTools et importez leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Exécutez leaderboard.codelab(); en console. Si vous voyez un message de bienvenue, vous êtes prêt ! Sinon, arrêtez l'émulateur et réexécutez les étapes 2 à 4.

Passons à la première implémentation du classement.

3. Mettre en œuvre un classement simple

À la fin de cette section, nous pourrons ajouter un score au classement et lui demander de nous indiquer notre classement.

Avant de commencer, expliquons comment fonctionne cette implémentation du classement : tous les joueurs sont stockés dans une seule collection, et la récupération du classement d'un joueur se fait en récupérant la collection et en comptant le nombre de joueurs qui le précèdent. Cela facilite l'insertion et la mise à jour d'une partition. Pour insérer une nouvelle partition, nous l'ajoutons simplement à la collection, et pour la mettre à jour, nous filtrons pour notre utilisateur actuel puis mettons à jour le document résultant. Voyons à quoi cela ressemble dans le code.

Dans functions/functions-helper.js , implémentez la fonction createScore , qui est à peu près aussi simple que possible :

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

Pour mettre à jour les scores, il suffit d'ajouter un contrôle d'erreur pour s'assurer que le score en cours de mise à jour existe déjà :

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

Et enfin, notre fonction de classement simple mais moins évolutive :

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

Mettons-le à l'épreuve ! Déployez vos fonctions en exécutant ce qui suit dans le terminal :

firebase deploy --only functions

Et puis, dans la console JS de Chrome, ajoutez d'autres scores afin que nous puissions voir notre classement parmi les autres joueurs.

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

Nous pouvons maintenant ajouter notre propre partition au mélange :

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

Une fois l'écriture terminée, vous devriez voir une réponse dans la console indiquant « Score créé ». Vous voyez une erreur à la place ? Ouvrez les journaux de fonctions via la console Firebase pour voir ce qui n'a pas fonctionné.

Et enfin, nous pouvons récupérer et mettre à jour notre score.

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

Cependant, cette implémentation nous impose des besoins linéaires en termes de temps et de mémoire pour récupérer le rang d'un score donné. Étant donné que le temps d'exécution des fonctions et la mémoire sont tous deux limités, cela signifie non seulement que nos récupérations deviendront de plus en plus lentes, mais qu'une fois suffisamment de scores ajoutés au classement, nos fonctions expireront ou planteront avant de pouvoir renvoyer un résultat. De toute évidence, nous aurons besoin de quelque chose de mieux si nous voulons dépasser une poignée de joueurs.

Si vous êtes un passionné de Firestore, vous connaissez peut-être COUNT requêtes d'agrégation , ce qui rendrait ce classement beaucoup plus performant. Et vous auriez raison ! Avec COUNT requêtes, cela évolue bien en dessous d'un million d'utilisateurs environ, bien que ses performances restent linéaires.

Mais attendez, vous pensez peut-être que si nous voulons quand même énumérer tous les documents de la collection, nous pouvons attribuer un rang à chaque document et lorsque nous aurons besoin de le récupérer, nos récupérations seront O(1) du temps et de la mémoire ! Cela nous amène à notre prochaine approche, le classement mis à jour périodiquement.

4. Mettre en œuvre un classement mis à jour périodiquement

La clé de cette approche est de stocker le rang dans le document lui-même, donc le récupérer nous donne le rang sans travail supplémentaire. Pour y parvenir, nous aurons besoin d’un nouveau type de fonction.

Dans index.js , ajoutez ce qui suit :

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

Désormais, nos opérations de lecture, de mise à jour et d’écriture sont toutes simples et agréables. L'écriture et la mise à jour sont toutes deux inchangées, mais la lecture devient (dans 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"),
  };
}

Malheureusement, vous ne pourrez pas déployer et tester cela sans ajouter un compte de facturation à votre projet. Si vous disposez d'un compte de facturation, raccourcissez l'intervalle de la fonction programmée et regardez votre fonction attribuer comme par magie des classements à vos scores du classement.

Sinon, supprimez la fonction planifiée et passez à l’implémentation suivante.

Allez-y et supprimez les scores de votre base de données Firestore en cliquant sur les 3 points à côté de la collection de scores pour préparer la section suivante.

Firestore scores document page with\nDelete Collection activated

5. Mettre en œuvre un classement des arbres en temps réel

Cette approche fonctionne en stockant les données de recherche dans la collection de bases de données elle-même. Au lieu d'avoir une collection uniforme, notre objectif est de tout stocker dans une arborescence que nous pouvons parcourir en parcourant les documents. Cela nous permet d'effectuer une recherche binaire (ou n-aire) pour le classement d'un score donné. À quoi cela peut-il ressembler?

Pour commencer, nous voudrons pouvoir répartir nos scores dans des tranches à peu près égales, ce qui nécessitera une certaine connaissance des valeurs des scores enregistrés par nos utilisateurs ; par exemple, si vous créez un classement pour l'évaluation des compétences dans un jeu compétitif, les évaluations des compétences de vos utilisateurs finiront presque toujours par être distribuées normalement. Notre fonction de génération de scores aléatoires utilise Math.random() de JavaScript, ce qui donne une distribution à peu près égale, nous diviserons donc nos compartiments de manière égale.

Dans cet exemple, nous utiliserons 3 compartiments pour plus de simplicité, mais vous constaterez probablement que si vous utilisez cette implémentation dans une application réelle, davantage de compartiments produiront des résultats plus rapides : une arborescence moins profonde signifie en moyenne moins de récupérations de collection et moins de conflits de verrouillage.

Le rang d'un joueur est donné par la somme du nombre de joueurs ayant les scores les plus élevés, plus un pour le joueur lui-même. Chaque collection sous scores stockera trois documents, chacun avec une plage, le nombre de documents sous chaque plage, puis trois sous-collections correspondantes. Pour lire un classement, nous allons parcourir cet arbre à la recherche d'un score et en gardant une trace de la somme des scores les plus élevés. Lorsque nous trouverons notre score, nous aurons également la somme correcte.

L’écriture est nettement plus compliquée. Tout d'abord, nous devrons effectuer toutes nos écritures au sein d'une transaction pour éviter les incohérences de données lorsque plusieurs écritures ou lectures se produisent en même temps. Nous devrons également maintenir toutes les conditions que nous avons décrites ci-dessus lorsque nous parcourons l'arborescence pour écrire nos nouveaux documents. Et enfin, comme nous avons toute l'arborescence de cette nouvelle approche combinée à la nécessité de stocker tous nos documents originaux, notre coût de stockage va légèrement augmenter (mais il reste linéaire).

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

C'est certainement plus compliqué que notre dernière implémentation, qui consistait en un seul appel de méthode et seulement six lignes de code. Une fois que vous avez implémenté cette méthode, essayez d'ajouter quelques scores à la base de données et d'observer la structure de l'arborescence résultante. Dans votre console JS :

leaderboard.addScores();

La structure de la base de données résultante devrait ressembler à ceci, avec la structure arborescente clairement visible et les feuilles de l'arbre représentant les scores individuels.

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

Maintenant que nous avons réglé la partie la plus difficile, nous pouvons lire les scores en parcourant l’arbre comme décrit précédemment.

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

Les mises à jour sont laissées comme un exercice supplémentaire. Essayez d'ajouter et de récupérer des scores dans votre console JS avec les méthodes leaderboard.addScore(id, score) et leaderboard.getRank(id) et voyez comment votre classement change dans la console Firebase.

Cependant, avec cette implémentation, la complexité que nous avons ajoutée pour obtenir des performances logarithmiques a un coût.

  • Premièrement, cette implémentation de classement peut rencontrer des problèmes de conflit de verrouillage, car les transactions nécessitent un verrouillage des lectures et des écritures dans les documents pour garantir leur cohérence.
  • Deuxièmement, Firestore impose une limite de profondeur de sous-collection de 100 , ce qui signifie que vous devrez éviter de créer des sous-arbres après 100 scores liés, ce que cette implémentation ne fait pas.
  • Et enfin, ce classement évolue de manière logarithmique uniquement dans le cas idéal où l'arbre est équilibré : s'il est déséquilibré, la pire performance de ce classement est à nouveau linéaire.

Une fois que vous avez terminé, supprimez les collections scores et players via la console Firebase et nous passerons à notre dernière implémentation du classement.

6. Mettre en œuvre un classement stochastique (probabiliste)

Lors de l'exécution du code d'insertion, vous remarquerez peut-être que si vous l'exécutez trop de fois en parallèle, vos fonctions commenceront à échouer avec un message d'erreur lié au conflit de verrouillage de transaction. Il existe des moyens de contourner ce problème que nous n'explorerons pas dans cet atelier de programmation, mais si vous n'avez pas besoin d'un classement exact, vous pouvez abandonner toute la complexité de l'approche précédente pour quelque chose à la fois plus simple et plus rapide. Voyons comment nous pourrions renvoyer un classement estimé pour les scores de nos joueurs au lieu d'un classement exact, et comment cela modifie la logique de notre base de données.

Pour cette approche, nous diviserons notre classement en 100 catégories, chacune représentant environ un pour cent des scores que nous espérons recevoir. Cette approche fonctionne même sans connaissance de la distribution de nos scores, auquel cas nous n'avons aucun moyen de garantir une répartition à peu près uniforme des scores dans l'ensemble de la tranche, mais nous obtiendrons une plus grande précision dans nos approximations si nous savons comment nos scores seront distribués. .

Notre approche est la suivante : comme auparavant, chaque compartiment stocke le nombre de scores à l'intérieur et la plage des scores. Lors de l'insertion d'un nouveau score, nous trouverons le compartiment du score et incrémenterons son décompte. Lors de la récupération d'un classement, nous additionnerons simplement les compartiments qui le précèdent, puis nous nous rapprocherons de notre compartiment au lieu de chercher plus loin. Cela nous donne de très belles recherches et insertions en temps constant, et nécessite beaucoup moins de code.

Tout d'abord, l'insertion :

// 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();
    }
  }
}

Vous remarquerez que ce code d'insertion a une certaine logique pour initialiser l'état de votre base de données en haut avec un avertissement de ne pas faire quelque chose comme ça en production. Le code d'initialisation n'est pas du tout protégé contre les conditions de concurrence, donc si vous deviez faire cela, plusieurs écritures simultanées corromptraient votre base de données en vous donnant un tas de compartiments en double.

Allez-y et déployez vos fonctions, puis exécutez une insertion pour initialiser tous les compartiments avec un nombre de zéro. Cela renverra une erreur que vous pourrez ignorer en toute sécurité.

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

Maintenant que la base de données est correctement initialisée, nous pouvons exécuter addScores et voir la structure de nos données dans la console Firebase. La structure résultante est beaucoup plus plate que notre dernière implémentation, même si elles sont superficiellement similaires.

leaderboard.addScores();

Et maintenant, pour lire les partitions :

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

Puisque nous avons fait en sorte que la fonction addScores génère une distribution uniforme des scores et que nous utilisons une interpolation linéaire dans les compartiments, nous obtiendrons des résultats très précis, les performances de notre classement ne se dégraderont pas à mesure que nous augmenterons le nombre d'utilisateurs. et nous n'avons pas à nous soucier (autant) des conflits de verrouillage lors de la mise à jour des décomptes.

7. Addendum : Tricherie

Attendez, vous pensez peut-être que si j'écris des valeurs dans mon atelier de programmation via la console JS d'un onglet de navigateur, aucun de mes joueurs ne peut-il simplement mentir au classement et dire qu'il a obtenu un score élevé alors qu'il ne l'a pas fait. réaliser équitablement ?

Oui, ils peuvent. Si vous souhaitez éviter la triche, le moyen le plus efficace consiste à désactiver les écritures des clients dans votre base de données via des règles de sécurité , à sécuriser l'accès à vos fonctions Cloud afin que les clients ne puissent pas les appeler directement, puis à valider les actions en jeu sur votre serveur avant. envoyer des mises à jour de score au classement.

Il est important de noter que cette stratégie n'est pas une panacée contre la triche : avec une incitation suffisamment importante, les tricheurs peuvent trouver des moyens de contourner les validations côté serveur, et de nombreux grands jeux vidéo à succès jouent constamment au chat et à la souris avec leurs tricheurs pour les identifier. de nouveaux tricheurs et les empêcher de proliférer. Une conséquence difficile de ce phénomène est que la validation côté serveur pour chaque jeu est intrinsèquement personnalisée ; bien que Firebase fournisse des outils anti-abus comme App Check qui empêcheront un utilisateur de copier votre jeu via un simple client scripté, Firebase ne fournit aucun service équivalant à un anti-triche holistique.

Tout ce qui ne correspond pas à une validation côté serveur entraînera, pour un jeu suffisamment populaire ou une barrière à la triche suffisamment faible, un classement dans lequel les principales valeurs sont toutes des tricheurs.

8. Félicitations

Félicitations, vous avez créé avec succès quatre classements différents sur Firebase ! En fonction des besoins de précision et de rapidité de votre jeu, vous pourrez en choisir un qui vous convient à un coût raisonnable.

Ensuite, consultez les parcours d’apprentissage des jeux.