Cree tablas de clasificación con Firestore

1. Introducción

Última actualización: 2023-01-27

¿Qué se necesita para construir una tabla de clasificación?

En esencia, las tablas de clasificación son solo tablas de puntajes con un factor de complicación: leer un rango para cualquier puntaje dado requiere el conocimiento de todos los demás puntajes en algún tipo de orden. Además, si su juego despega, sus tablas de clasificación crecerán y se leerán y escribirán con frecuencia. Para construir una tabla de clasificación exitosa, debe poder manejar esta operación de clasificación rápidamente.

lo que vas a construir

En este laboratorio de código, implementará varias tablas de clasificación diferentes, cada una adecuada para un escenario diferente.

lo que aprenderás

Aprenderá a implementar cuatro tablas de clasificación diferentes:

  • Una implementación ingenua que usa un simple conteo de registros para determinar el rango
  • Una tabla de clasificación económica que se actualiza periódicamente
  • Una tabla de clasificación en tiempo real con algunas tonterías de árboles.
  • Una tabla de clasificación estocástica (probabilística) para la clasificación aproximada de bases de jugadores muy grandes

Lo que necesitarás

  • Una versión reciente de Chrome (107 o posterior)
  • Node.js 16 o superior (ejecute nvm --version para ver su número de versión si está usando nvm)
  • Un plan pago de Firebase Blaze (opcional)
  • Firebase CLI v11.16.0 o superior
    Para instalar la CLI, puede ejecutar npm install -g firebase-tools o consultar la documentación de la CLI para obtener más opciones de instalación.
  • Conocimiento de JavaScript, Cloud Firestore, Cloud Functions y Chrome DevTools

2. Preparación

Obtener el código

Hemos puesto todo lo que necesita para este proyecto en un repositorio de Git. Para comenzar, deberá obtener el código y abrirlo en su entorno de desarrollo favorito. Para este laboratorio de código, usamos VS Code, pero cualquier editor de texto servirá.

y descomprima el archivo zip descargado.

O, clone en su directorio de elección:

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

¿Cuál es nuestro punto de partida?

Nuestro proyecto es actualmente una pizarra en blanco con algunas funciones vacías:

  • index.html contiene algunos scripts adhesivos que nos permiten invocar funciones desde la consola de desarrollo y ver sus resultados. Usaremos esto para interactuar con nuestro backend y ver los resultados de nuestras invocaciones de funciones. En un escenario del mundo real, haría estas llamadas de back-end directamente desde su juego; no estamos usando un juego en este laboratorio de código porque tomaría demasiado tiempo jugar un juego cada vez que desee agregar un puntaje a la tabla de clasificación. .
  • functions/index.js contiene todas nuestras Cloud Functions. Verá algunas funciones de utilidad, como addScores y deleteScores , así como las funciones que implementaremos en este laboratorio de código, que llaman a funciones auxiliares en otro archivo.
  • functions/functions-helpers.js contiene las funciones vacías que implementaremos. Para cada tabla de clasificación, implementaremos funciones de lectura, creación y actualización, y verá cómo nuestra elección de implementación afecta tanto la complejidad de nuestra implementación como su rendimiento de escalado.
  • functions/utils.js contiene más funciones de utilidad. No tocaremos este archivo en este codelab.

Crear y configurar un proyecto de Firebase

  1. En Firebase console , haga clic en Agregar proyecto .
  2. Para crear un nuevo proyecto, ingrese el nombre del proyecto deseado.
    Esto también establecerá el ID del proyecto (que se muestra debajo del nombre del proyecto) en algo basado en el nombre del proyecto. Opcionalmente, puede hacer clic en el icono de edición en el ID del proyecto para personalizarlo aún más.
  3. Si se le solicita, revise y acepte los términos de Firebase .
  4. Haga clic en Continuar .
  5. Seleccione la opción Habilitar Google Analytics para este proyecto y luego haga clic en Continuar .
  6. Seleccione una cuenta de Google Analytics existente para usar o seleccione Crear una cuenta nueva para crear una cuenta nueva.
  7. Haga clic en Crear proyecto .
  8. Cuando se haya creado el proyecto, haga clic en Continuar .
  9. En el menú Generar , haga clic en Funciones y, si se le solicita, actualice su proyecto para usar el plan de facturación de Blaze.
  10. En el menú Generar , haz clic en Base de datos de Firestore .
  11. En el cuadro de diálogo Crear base de datos que aparece, seleccione Iniciar en modo de prueba , luego haga clic en Siguiente .
  12. Elija una región del menú desplegable de ubicación de Cloud Firestore , luego haga clic en Habilitar .

Configura y ejecuta tu tabla de clasificación

  1. En una terminal, navegue hasta la raíz del proyecto y ejecute firebase use --add . Elige el proyecto de Firebase que acabas de crear.
  2. En la raíz del proyecto, ejecuta firebase emulators:start --only hosting .
  3. En su navegador, vaya a localhost:5000 .
  4. Abra la consola JavaScript de Chrome DevTools e importe leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Ejecute leaderboard.codelab(); en consola Si ve un mensaje de bienvenida, ¡ya está todo listo! De lo contrario, apague el emulador y vuelva a ejecutar los pasos 2 a 4.

Pasemos a la primera implementación de la tabla de clasificación.

3. Implementa una tabla de clasificación simple

Al final de esta sección, podremos agregar un puntaje a la tabla de clasificación y hacer que nos diga nuestro rango.

Antes de comenzar, expliquemos cómo funciona esta implementación de la tabla de clasificación: todos los jugadores se almacenan en una sola colección, y la clasificación de un jugador se obtiene recuperando la colección y contando cuántos jugadores están delante de ellos. Esto facilita la inserción y actualización de una partitura. Para insertar una nueva partitura, simplemente la agregamos a la colección y, para actualizarla, filtramos por nuestro usuario actual y luego actualizamos el documento resultante. Veamos cómo se ve eso en el código.

En functions/functions-helper.js , implemente la función createScore , que es lo más sencillo posible:

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

Para actualizar puntajes, solo necesitamos agregar una verificación de error para asegurarnos de que el puntaje que se está actualizando ya existe:

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

Y finalmente, nuestra función de rango simple pero menos escalable:

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

¡Pongámoslo a prueba! Implemente sus funciones ejecutando lo siguiente en la terminal:

firebase deploy --only functions

Y luego, en la consola JS de Chrome, agregue algunos otros puntajes para que podamos ver nuestra clasificación entre otros jugadores.

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

Ahora podemos agregar nuestra propia partitura a la mezcla:

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

Cuando se complete la escritura, debería ver una respuesta en la consola que dice "Puntuación creada". ¿Ves un error en su lugar? Abra los registros de funciones a través de la consola Firebase para ver qué salió mal.

Y, por último, podemos buscar y actualizar nuestra puntuación.

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

Sin embargo, esta implementación nos brinda requisitos de memoria y tiempo lineal no deseados para obtener el rango de una puntuación determinada. Dado que el tiempo de ejecución de la función y la memoria son limitados, esto no solo significará que nuestras búsquedas se volverán cada vez más lentas, sino que después de agregar suficientes puntajes a la tabla de clasificación, nuestras funciones expirarán o se bloquearán antes de que puedan arrojar un resultado. Claramente, necesitaremos algo mejor si vamos a escalar más allá de un puñado de jugadores.

Si es un aficionado a Firestore, es posible que conozca COUNT consultas de agregación , lo que haría que esta tabla de clasificación fuera mucho más eficaz. ¡Y tendrías razón! Con COUNT consultas, esto escala muy bien por debajo de un millón de usuarios, aunque su rendimiento sigue siendo lineal.

Pero espera, puedes estar pensando, si vamos a enumerar todos los documentos en la colección de todos modos, podemos asignar a cada documento un rango y luego, cuando necesitemos buscarlo, nuestras búsquedas serán O (1) tiempo y memoria! Esto nos lleva a nuestro próximo enfoque, la tabla de clasificación que se actualiza periódicamente.

4. Implemente una tabla de clasificación que se actualice periódicamente

La clave de este enfoque es almacenar el rango en el propio documento, por lo que obtenerlo nos da el rango sin trabajo adicional. Para lograr esto, necesitaremos un nuevo tipo de función.

En index.js , agregue lo siguiente:

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

Ahora nuestras operaciones de lectura, actualización y escritura son agradables y simples. La escritura y la actualización no cambian, pero la lectura se convierte (en 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"),
  };
}

Desafortunadamente, no podrá implementar y probar esto sin agregar una cuenta de facturación a su proyecto. Si tiene una cuenta de facturación, acorte el intervalo en la función programada y observe cómo su función asigna mágicamente rangos a los puntajes de su tabla de clasificación.

De lo contrario, elimine la función programada y avance a la siguiente implementación.

Continúe y elimine las puntuaciones en su base de datos de Firestore haciendo clic en los 3 puntos junto a la colección de puntuaciones para prepararse para la siguiente sección.

Firestore scores document page with\nDelete Collection activated

5. Implemente una tabla de clasificación de árbol en tiempo real

Este enfoque funciona almacenando los datos de búsqueda en la propia colección de la base de datos. En lugar de tener una colección uniforme, nuestro objetivo es almacenar todo en un árbol que podamos recorrer al movernos por los documentos. Esto nos permite realizar una búsqueda binaria (o n-aria) para el rango de un puntaje dado. ¿A que podría parecerse?

Para empezar, querremos poder distribuir nuestros puntajes en grupos aproximadamente iguales, lo que requerirá cierto conocimiento de los valores de los puntajes que registran nuestros usuarios; por ejemplo, si está creando una tabla de clasificación para la calificación de habilidades en un juego competitivo, las calificaciones de habilidades de sus usuarios casi siempre terminarán distribuidas normalmente. Nuestra función de generación de puntuación aleatoria utiliza Math.random() de JavaScript, lo que da como resultado una distribución aproximadamente uniforme, por lo que dividiremos nuestros cubos de manera uniforme.

En este ejemplo, usaremos 3 cubos para simplificar, pero es probable que descubra que si usa esta implementación en una aplicación real, más cubos producirán resultados más rápidos: un árbol menos profundo significa, en promedio, menos capturas de colección y menos contención de bloqueo.

El rango de un jugador viene dado por la suma del número de jugadores con puntajes más altos, más uno para el propio jugador. Cada colección bajo scores almacenará tres documentos, cada uno con un rango, el número de documentos en cada rango y luego tres subcolecciones correspondientes. Para leer una clasificación recorreremos este árbol buscando una puntuación y llevando la cuenta de la suma de las puntuaciones mayores. Cuando encontremos nuestra puntuación, también tendremos la suma correcta.

Escribir es significativamente más complicado. Primero, necesitaremos hacer todas nuestras escrituras dentro de una transacción para evitar inconsistencias en los datos cuando ocurren varias escrituras o lecturas al mismo tiempo. También necesitaremos mantener todas las condiciones que hemos descrito anteriormente mientras recorremos el árbol para escribir nuestros nuevos documentos. Y, finalmente, dado que tenemos toda la complejidad del árbol de este nuevo enfoque combinada con la necesidad de almacenar todos nuestros documentos originales, nuestro costo de almacenamiento aumentará ligeramente (pero seguirá siendo lineal).

En 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.
   * @returns {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,
      });
    });
  });
}

Sin duda, esto es más complicado que nuestra última implementación, que consistía en una sola llamada de método y solo seis líneas de código. Una vez que haya implementado este método, intente agregar algunos puntajes a la base de datos y observe la estructura del árbol resultante. En su consola JS:

leaderboard.addScores();

La estructura de la base de datos resultante debería parecerse a esto, con la estructura de árbol claramente visible y las hojas del árbol representando puntajes individuales.

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

Ahora que ya no tenemos la parte difícil, podemos leer las partituras recorriendo el árbol como se describió anteriormente.

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

Las actualizaciones se dejan como un ejercicio extra. Intente agregar y obtener puntuaciones en su consola JS con los métodos leaderboard.addScore(id, score) y leaderboard.getRank(id) y vea cómo cambia su tabla de clasificación en la consola de Firebase.

Sin embargo, con esta implementación, la complejidad que hemos agregado para lograr un rendimiento logarítmico tiene un costo.

  • En primer lugar, esta implementación de la tabla de clasificación puede encontrarse con problemas de contención de bloqueos, ya que las transacciones requieren bloqueos de lecturas y escrituras en los documentos para asegurarse de que se mantengan coherentes.
  • En segundo lugar, Firestore impone un límite de profundidad de subcolección de 100 , lo que significa que deberá evitar crear subárboles después de 100 puntajes empatados, lo que esta implementación no hace.
  • Y, por último, esta tabla de clasificación se escala logarítmicamente solo en el caso ideal en el que el árbol está equilibrado; si no está equilibrado, el rendimiento en el peor de los casos de esta tabla de clasificación vuelve a ser lineal.

Una vez que haya terminado, elimine las scores y las colecciones players a través de la consola Firebase y pasaremos a nuestra última implementación de la tabla de clasificación.

6. Implementar una tabla de clasificación estocástica (probalística)

Al ejecutar el código de inserción, puede notar que si lo ejecuta demasiadas veces en paralelo, sus funciones comenzarán a fallar con un mensaje de error relacionado con la contención de bloqueo de transacciones. Hay formas de evitar esto que no exploraremos en este laboratorio de código, pero si no necesita una clasificación exacta, puede eliminar toda la complejidad del enfoque anterior para obtener algo más simple y rápido. Echemos un vistazo a cómo podemos devolver una clasificación estimada para las puntuaciones de nuestros jugadores en lugar de una clasificación exacta, y cómo eso cambia la lógica de nuestra base de datos.

Para este enfoque, dividiremos nuestra tabla de clasificación en 100 grupos, cada uno representando aproximadamente el uno por ciento de los puntajes que esperamos recibir. Este enfoque funciona incluso sin conocer nuestra distribución de puntajes, en cuyo caso no tenemos forma de garantizar una distribución más o menos uniforme de los puntajes en todo el grupo, pero lograremos una mayor precisión en nuestras aproximaciones si sabemos cómo se distribuirán nuestros puntajes. .

Nuestro enfoque es el siguiente: como antes, cada depósito almacena el recuento del número de puntajes dentro y el rango de los puntajes. Al insertar una nueva partitura, buscaremos el depósito para la partitura e incrementaremos su recuento. Al obtener un rango, solo sumaremos los cubos que se encuentran delante de él y luego los aproximaremos dentro de nuestro cubo en lugar de buscar más. Esto nos brinda búsquedas e inserciones de tiempo constante muy agradables, y requiere mucho menos código.

Primero, inserción:

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

Notará que este código de inserción tiene cierta lógica para inicializar el estado de su base de datos en la parte superior con una advertencia para no hacer algo como esto en producción. El código para la inicialización no está protegido en absoluto contra las condiciones de carrera, por lo que si hiciera esto, varias escrituras simultáneas dañarían su base de datos al generarle un montón de cubos duplicados.

Continúe e implemente sus funciones y luego ejecute una inserción para inicializar todos los cubos con un conteo de cero. Devolverá un error, que puede ignorar con seguridad.

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

Ahora que la base de datos se ha inicializado correctamente, podemos ejecutar addScores y ver la estructura de nuestros datos en la consola de Firebase. La estructura resultante es mucho más plana que nuestra última implementación, aunque superficialmente son similares.

leaderboard.addScores();

Y, ahora, a leer partituras:

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

Dado que hemos hecho que la función addScores genere una distribución uniforme de puntajes y estamos usando la interpolación lineal dentro de los cubos, obtendremos resultados muy precisos, el rendimiento de nuestra tabla de clasificación no se degradará a medida que aumentemos la cantidad de usuarios, y no tenemos que preocuparnos por la contención de bloqueo (tanto) al actualizar los recuentos.

7. Anexo: Hacer trampa

Espera, podrías estar pensando, si estoy escribiendo valores en mi laboratorio de código a través de la consola JS de una pestaña del navegador, ¿no puede ninguno de mis jugadores simplemente mentirle a la tabla de clasificación y decir que obtuvieron un puntaje alto que no obtuvieron? lograr de manera justa?

Sí pueden. Si desea evitar las trampas, la forma más sólida de hacerlo es deshabilitar las escrituras de los clientes en su base de datos a través de reglas de seguridad , asegurar el acceso a sus Cloud Functions para que los clientes no puedan llamarlos directamente y luego validar las acciones en el juego en su servidor antes. enviar actualizaciones de puntuación a la tabla de clasificación.

Es importante tener en cuenta que esta estrategia no es una panacea contra las trampas: con un incentivo lo suficientemente grande, los tramposos pueden encontrar formas de eludir las validaciones del lado del servidor, y muchos videojuegos grandes y exitosos juegan constantemente al gato y al ratón con sus tramposos para identificarlos. nuevos trucos y evitar que proliferen. Una consecuencia difícil de este fenómeno es que la validación del lado del servidor para cada juego es inherentemente personalizada; aunque Firebase proporciona herramientas contra el abuso como App Check que evitará que un usuario copie su juego a través de un cliente con script simple, Firebase no proporciona ningún servicio que equivalga a un anti-trampa holístico.

Cualquier cosa que no sea la validación del lado del servidor, para un juego lo suficientemente popular o una barrera lo suficientemente baja para hacer trampa, dará como resultado una tabla de clasificación donde los valores principales son todos los tramposos.

8. Felicitaciones

Felicitaciones, ha creado con éxito cuatro tablas de clasificación diferentes en Firebase. Dependiendo de las necesidades de precisión y velocidad de su juego, podrá elegir uno que funcione para usted a un costo razonable.

A continuación, echa un vistazo a las vías de aprendizaje de los juegos.