Crea tablas de clasificación con Firestore

1. Introducción

Última actualización: 27/01/2023

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

En esencia, las tablas de clasificación son solo tablas de puntuaciones con un factor complicado: leer una clasificación para una puntuación determinada requiere conocer todas las demás puntuaciones en algún tipo de orden. Además, si tu juego tiene éxito, las tablas de clasificación aumentarán su tamaño y se leerán y escribirán con frecuencia. Para crear una tabla de clasificación exitosa, es necesario que sea capaz de administrar esta operación de clasificación rápidamente.

Qué compilarás

En este codelab, implementarás varias tablas de clasificación diferentes, cada una adecuada para una situación diferente.

Qué aprenderás

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

  • Una implementación simple que usa un recuento de registros simple para determinar la clasificación
  • Una tabla de clasificación económica que se actualiza periódicamente
  • Una tabla de clasificación en tiempo real con algunos detalles del árbol
  • Una tabla de clasificación estocástica (probabilística) para una clasificación aproximada de bases de jugadores muy grandes.

Requisitos

  • Una versión reciente de Chrome (107 o posterior)
  • Node.js 16 o una versión posterior (ejecuta nvm --version para ver el número de versión si usas nvm)
  • Un plan Blaze pagado de Firebase (opcional)
  • Firebase CLI v11.16.0 o una versión posterior
    Para instalar la CLI, puedes ejecutar npm install -g firebase-tools o consultar la documentación de la CLI para ver más opciones de instalación.
  • Conocimientos de JavaScript, Cloud Firestore, Cloud Functions y Herramientas para desarrolladores de Chrome

2. Preparación

Obtén el código

Todo lo que necesitas para este proyecto en un repositorio de Git Para comenzar, deberás tomar el código y abrirlo en tu entorno de desarrollo favorito. En este codelab, usamos VS Code, pero es útil para cualquier editor de texto.

y descomprime el archivo ZIP descargado.

O bien, clona el directorio que prefieras:

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

¿Cuál es nuestro punto de partida?

Actualmente, nuestro proyecto está en blanco y tiene algunas funciones vacías:

  • index.html contiene algunas secuencias de comandos de unión que nos permiten invocar funciones desde la consola para desarrolladores y ver sus resultados. Lo usaremos para interactuar con nuestro backend y ver los resultados de nuestras invocaciones de funciones. En una situación real, harías estas llamadas de backend directamente desde tu juego. No usamos un juego en este codelab porque tomaría demasiado tiempo jugarlo cada vez que quisieras agregar una puntuación a la tabla de clasificación.
  • functions/index.js contiene todas nuestras funciones de Cloud Functions. Verás algunas funciones de utilidad, como addScores y deleteScores, además de las funciones que implementaremos en este codelab, 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ás cómo nuestra elección de implementación afecta tanto la complejidad de nuestra implementación como su rendimiento de escalamiento.
  • functions/utils.js contiene más funciones de utilidad. No tocaremos este archivo en este codelab.

Crea y configura un proyecto de Firebase

  1. En Firebase console, haz clic en Agregar proyecto.
  2. Para crear un proyecto nuevo, ingresa el nombre que quieras.
    Con esto, también se configurará el ID del proyecto (que se muestra debajo del nombre del proyecto) según ese nombre. De manera opcional, puedes hacer clic en el ícono de edición en el ID del proyecto para personalizarlo aún más.
  3. Si se te solicita, revisa y acepta las Condiciones de Firebase.
  4. Haz clic en Continuar.
  5. Selecciona la opción Habilitar Google Analytics para este proyecto y, luego, haz clic en Continuar.
  6. Selecciona una cuenta de Google Analytics existente para usar o elige Crear una cuenta nueva para crear una nueva.
  7. Haz clic en Crear proyecto.
  8. Cuando se haya creado el proyecto, haz clic en Continuar.
  9. En el menú Compilación, haz clic en Funciones y, si se te solicita, actualiza tu proyecto para usar el plan de facturación Blaze.
  10. En el menú Compilar, haz clic en Base de datos de Firestore.
  11. En el diálogo Crear base de datos que aparece, selecciona Comenzar en modo de prueba y, luego, haz clic en Siguiente.
  12. Elige una región del menú desplegable Ubicación de Cloud Firestore y haz clic en Habilitar.

Configura y ejecuta tu tabla de clasificación

  1. En una terminal, navega hasta la raíz del proyecto y ejecuta firebase use --add. Selecciona el proyecto de Firebase que acabas de crear.
  2. En la raíz del proyecto, ejecuta firebase emulators:start --only hosting.
  3. En tu navegador, ve a localhost:5000.
  4. Abre la consola de JavaScript de Herramientas para desarrolladores de Chrome e importa leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Ejecuta leaderboard.codelab(); en la consola. Si ves un mensaje de bienvenida, no necesitas hacer nada más. De lo contrario, apaga el emulador y vuelve 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 una puntuación a la tabla de clasificación y hacer que nos indique nuestra clasificación.

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

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

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

Para actualizar las puntuaciones, solo necesitamos agregar una verificación de errores para asegurarnos de que la puntuación que se actualiza 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, por último, nuestra función de clasificación 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}`);
}

¡Vamos a probarlo! Para implementar tus funciones, ejecuta el siguiente comando en la terminal:

firebase deploy --only functions

Y luego, en la consola de JS de Chrome, agregar algunas otras puntuaciones para que podamos ver nuestra clasificación entre otros jugadores.

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

Ahora, podemos agregar nuestra propia puntuación a la combinación:

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

Cuando se complete la escritura, deberías ver una respuesta en la consola que diga “Score created”. ¿Ves un error en su lugar? Abre los registros de Functions a través de Firebase console para ver qué salió mal.

Por último, podemos recuperar 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 tiempo y memoria lineal no deseados para recuperar la clasificación de una puntuación determinada. Dado que el tiempo de ejecución y la memoria de la función son limitados, no solo significa que las recuperaciones serán cada vez más lentas, sino que, después de agregar suficientes puntuaciones a la tabla de clasificación, se agotará el tiempo de espera de las funciones o fallarán antes de que se muestre un resultado. Sin duda, necesitaremos algo mejor si vamos a escalar más allá de un puñado de jugadores.

Si eres un aficionado de Firestore, es posible que conozcas las COUNT búsquedas de agregación, que mejorarían mucho el rendimiento de esta tabla de clasificación. ¡Y tienes razón! Con las consultas COUNT, esto se escala bastante por debajo del millón de usuarios, aproximadamente, a pesar de que su rendimiento sigue siendo lineal.

Pero espera, es posible que pienses que, si vamos a enumerar todos los documentos de la colección de todas formas, podemos asignar una clasificación a cada documento y, luego, cuando necesitemos recuperarla, nuestras búsquedas serán el tiempo O(1) y la memoria. Esto nos lleva a nuestro próximo enfoque: la tabla de clasificación que se actualiza periódicamente.

4. Cómo implementar una tabla de clasificación que se actualiza periódicamente

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

En index.js, agrega 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 se modifican, pero la lectura se convierte en (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"),
  };
}

Lamentablemente, no podrás implementar ni probar esto sin agregar una cuenta de facturación a tu proyecto. Si tienes una cuenta de facturación, acorta el intervalo en la función programada y observa cómo la función asigna automáticamente clasificaciones a las puntuaciones de la tabla de clasificación.

De lo contrario, borra la función programada y continúa con la siguiente implementación.

Borra las puntuaciones de tu base de datos de Firestore. Para ello, haz clic en los 3 puntos junto a la colección de puntuaciones y prepárate para la siguiente sección.

Página del documento de puntuaciones de Firestore con\nactivar la opción Borrar colección

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

Este enfoque funciona almacenando los datos de búsqueda en la 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 atravesar cuando nos desplazamos a través de los documentos. Esto nos permite realizar una búsqueda binaria (o n-aria) para la clasificación de una puntuación determinada. ¿Cómo podría ser?

Para empezar, deseamos poder distribuir nuestras puntuaciones en segmentos más o menos parejas, lo cual requerirá cierto conocimiento de los valores de las puntuaciones que nuestros usuarios registran; por ejemplo, si estás creando una tabla de clasificación para las calificaciones de habilidades en un juego competitivo, las calificaciones de habilidades de tus usuarios casi siempre se distribuirán de forma normal. Nuestra función para generar puntuaciones aleatorias usa Math.random() de JavaScript, lo que da como resultado una distribución aproximadamente uniforme, por lo que dividiremos nuestros buckets de manera uniforme.

En este ejemplo, usaremos 3 buckets para que sea más simple, pero probablemente descubras que si usas esta implementación en una app real, más buckets producirán resultados más rápidos. Un árbol más superficial significa, en promedio, menos recuperaciones de colecciones y menos contención de bloqueos.

La clasificación de un jugador se da por la suma del número de jugadores con puntuaciones más altas, más uno para el propio jugador. Cada colección de scores almacenará tres documentos, cada uno con un rango, la cantidad de documentos en cada rango y, luego, tres subcolecciones correspondientes. Para leer una clasificación, atravesaremos este árbol buscando una puntuación y haciendo un seguimiento de la suma de las puntuaciones mayores. Cuando encontremos nuestra puntuación, también tendremos la suma correcta.

Escribir es mucho más complicado. Primero, tendremos que realizar todas las operaciones de escritura dentro de una transacción para evitar inconsistencias de datos cuando se produzcan varias operaciones de escritura o lectura al mismo tiempo. También tendremos que mantener todas las condiciones que describimos anteriormente a medida que desplacémos el árbol para escribir nuestros nuevos documentos. Y, por último, dado que tenemos toda la complejidad de árbol de este nuevo enfoque combinada con la necesidad de almacenar todos los documentos originales, nuestro costo de almacenamiento aumentará levemente (pero sigue 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.
   * @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,
      });
    });
  });
}

Esto es sin duda más complicado que nuestra última implementación, que consistía en una única llamada de método y solo seis líneas de código. Una vez que hayas implementado este método, intenta agregar algunas puntuaciones a la base de datos y observa la estructura del árbol resultante. En tu consola de JS:

leaderboard.addScores();

La estructura de la base de datos resultante debería ser similar a la siguiente, con la estructura de árbol claramente visible y las hojas del árbol que representen las puntuaciones 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 terminamos con la parte difícil, podemos recorrer el árbol como se describió antes para leer las puntuaciones.

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 adicional. Intenta agregar y recuperar puntuaciones en tu consola de JS con los métodos leaderboard.addScore(id, score) y leaderboard.getRank(id), y observa cómo cambia la tabla de clasificación en Firebase console.

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

  • En primer lugar, esta implementación de tabla de clasificación puede tener problemas de contención de bloqueo, ya que las transacciones requieren bloquear las lecturas y escrituras en los documentos para garantizar que sean coherentes.
  • En segundo lugar, Firestore impone un límite de profundidad de la subcolección de 100, lo que significa que deberás evitar crear subárboles después de 100 puntuaciones empatadas, algo que esta implementación no tiene.
  • Por último, esta tabla de clasificación escala de forma logarítmica solo en el caso ideal en el que el árbol está equilibrado; si no lo está, el peor de los casos de rendimiento de esta tabla de clasificación es lineal.

Una vez que hayas terminado, borra las colecciones scores y players a través de Firebase console y pasaremos a la implementación de la tabla de clasificación más reciente.

6. Implementa una tabla de clasificación estocástica (probabilística)

Cuando ejecutes el código de inserción, es posible que notes que, si lo ejecutas demasiadas veces en paralelo, tus funciones comenzarán a fallar y se mostrará un mensaje de error relacionado con la contención de bloqueo de transacciones. Hay formas de solucionar esto que no exploraremos en este codelab, pero si no necesitas una clasificación exacta, puedes descartar toda la complejidad del enfoque anterior para obtener algo más simple y más rápido. Veamos cómo podríamos mostrar una clasificación estimada para las puntuaciones de los jugadores en lugar de una clasificación exacta, y cómo esto cambia la lógica de nuestra base de datos.

Para este enfoque, dividiremos nuestra tabla de clasificación en 100 segmentos, cada uno de los cuales representa aproximadamente un porcentaje de las puntuaciones que esperamos recibir. Este enfoque funciona incluso sin conocer nuestra distribución de puntuaciones, en cuyo caso no tenemos forma de garantizar una distribución bastante uniforme de las puntuaciones en todo el bucket, pero lograremos una mayor precisión en nuestras aproximaciones si sabemos cómo se distribuirán las puntuaciones.

Nuestro enfoque es el siguiente: al igual que antes, cada bucket almacena el recuento de la cantidad de puntuaciones dentro y el rango de estas. Cuando insertemos una puntuación nueva, buscaremos el bucket para la puntuación y aumentaremos su recuento. Cuando recuperemos una clasificación, solo sumaremos los buckets que se encuentran antes de ella y, luego, nos aproximaremos dentro de nuestro bucket en lugar de buscar más. Esto nos proporciona inserciones y búsquedas de tiempo constantes muy atractivas y requiere mucho menos código.

En primer lugar, la 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ás que este código de inserción tiene cierta lógica para inicializar el estado de la base de datos en la parte superior con una advertencia de que no debes 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 hicieras esto, múltiples escrituras simultáneas corrompían tu base de datos, dándote un montón de buckets duplicados.

Implementa las funciones y, luego, ejecuta una inserción para inicializar todos los buckets con un recuento de cero. Se mostrará un error, que puedes ignorar sin problemas.

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

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

leaderboard.addScores();

Y, ahora, para leer las puntuaciones:

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

Como hicimos que la función addScores genere una distribución uniforme de las puntuaciones y usamos interpolación lineal dentro de los buckets, 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 tendremos que preocuparnos tanto por la contención de bloqueo cuando actualizamos los recuentos.

7. Anexo: Trampas

Espera. Es posible que pienses que, si escribo valores en mi codelab a través de la consola de JS de una pestaña del navegador, ¿ninguno de mis jugadores puede mentir sobre la tabla de clasificación y decir que obtuvieron una puntuación alta que no lograron de manera justa?

Sí, es posible. Si quieres evitar trampas, la forma más sólida de hacerlo es inhabilitar las operaciones de escritura de los clientes en tu base de datos a través de reglas de seguridad, el acceso seguro a tus Cloud Functions para que los clientes no puedan llamarlas directamente y, luego, validar las acciones en el juego en tu servidor antes de 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, quienes hacen trampa pueden encontrar formas de evadir las validaciones del servidor, y muchos videojuegos exitosos y grandes juegan al gato y el ratón constantemente con sus trampas para identificar nuevas trampas y evitar que proliferen. Una consecuencia difícil de este fenómeno es que la validación del servidor para cada juego es inherentemente personalizada. Si bien Firebase proporciona herramientas antiabuso, como la Verificación de aplicaciones, que evitan que un usuario copie tu juego a través de un cliente con secuencia de comandos simple, Firebase no proporciona ningún servicio que constituya una solución integral contra los trampas.

Si la falta de validación del servidor es un juego lo suficientemente popular o un obstáculo lo suficientemente bajo como para hacer trampa, generará una tabla de clasificación en la que los valores principales son trampa.

8. ¡Felicitaciones!

¡Felicitaciones! Creaste con éxito cuatro tablas de clasificación diferentes en Firebase. Podrás elegir el que funcione mejor para ti a un costo razonable según las necesidades de exactitud y velocidad de tu juego.

A continuación, consulta las rutas de aprendizaje sobre juegos.