Crea 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 puntuaciones con un factor que complica la situación: leer una clasificación para cualquier puntuación determinada requiere conocer todas las demás puntuaciones en algún tipo de orden. Además, si tu juego despega, tus tablas de clasificación crecerán y se leerán y escribirán con frecuencia. Para crear una tabla de clasificación exitosa, es necesario poder manejar esta operación de clasificación rápidamente.

lo que construirás

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

lo que aprenderás

Aprenderá cómo implementar cuatro tablas de clasificación diferentes:

  • Una implementación ingenua que utiliza un simple recuento de registros 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 algunas tonterías sobre los árboles.
  • Una tabla de clasificación estocástica (probabilística) para una 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 Firebase Blaze pago (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, funciones de nube 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ás tomar el código y abrirlo en tu entorno de desarrollo favorito. Para este codelab, utilizamos VS Code, pero cualquier editor de texto servirá.

y descomprima el archivo zip descargado.

O clonar en el directorio de su 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ías estas llamadas de backend desde tu juego directamente; no usamos un juego en este codelab porque tomaría demasiado tiempo jugar un juego cada vez que quieres agregar una puntuación a la tabla de clasificación. .
  • functions/index.js contiene todas nuestras funciones en la nube. Verá algunas funciones de utilidad, como addScores y deleteScores , así como 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á 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 , haz 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 te solicita, revisa y acepta 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ú Crear , haga clic en Funciones y, si se le solicita, actualice su proyecto para usar el plan de facturación Blaze.
  10. En el menú Generar , haga 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 y luego haga clic en Siguiente .
  12. Elija una región en el menú desplegable de ubicación de Cloud Firestore y 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 . Elija el proyecto de Firebase que acaba de crear.
  2. En la raíz del proyecto, ejecute firebase emulators:start --only hosting .
  3. En su navegador, navegue hasta 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. Implemente una tabla de clasificación sencilla

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 esta implementación de tabla de clasificación: todos los jugadores se almacenan en una única colección, y la clasificación de un jugador se obtiene recuperando la colección y contando cuántos jugadores están delante de él. 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 tan sencilla como parece:

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 está actualizando ya exista:

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

¡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, agrega 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 partitura a la mezcla:

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 "Puntuación creada". ¿Ves un error en su lugar? Abra los registros de Functions a través de la consola Firebase para ver qué salió mal.

Y, finalmente, 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 proporciona requisitos lineales de tiempo y memoria indeseables 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 recuperaciones se volverán cada vez más lentas, sino que después de que se agreguen suficientes puntuaciones a la tabla de clasificación, nuestras funciones expirarán o fallarán antes de que puedan devolver un resultado. Claramente, necesitaremos algo mejor si queremos escalar más allá de un puñado de jugadores.

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

Pero espere, puede que esté pensando, si vamos a enumerar todos los documentos de la colección de todos modos, podemos asignar a cada documento un rango y luego, cuando necesitemos recuperarlo, nuestras recuperaciones serán O(1). tiempo y memoria! Esto nos lleva a nuestro siguiente enfoque, la tabla de clasificación que se actualiza periódicamente.

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

La clave de este enfoque es almacenar la clasificación en el documento mismo, de modo que recuperarlo nos proporcione la clasificación 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 todas 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"),
  };
}

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

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

Continúe y elimine las puntuaciones en su base de datos de Firestore haciendo clic en los 3 puntos al lado de 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 árboles en tiempo real.

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

Para empezar, queremos poder distribuir nuestras puntuaciones en grupos más o menos iguales, lo que requerirá cierto conocimiento de los valores de las puntuaciones que registran nuestros usuarios; por ejemplo, si estás creando una tabla de clasificación para la calificación de habilidades en un juego competitivo, las calificaciones de habilidades de tus 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 depósitos de manera uniforme.

En este ejemplo usaremos 3 depósitos para simplificar, pero probablemente encontrará que si usa esta implementación en una aplicación real, más depósitos producirán resultados más rápidos: un árbol menos profundo significa, en promedio, menos recuperaciones de colecciones y menos contención de bloqueos.

El rango de un jugador viene dado por la suma del número de jugadores con puntuaciones más altas, más uno del propio jugador. Cada colección bajo scores almacenará tres documentos, cada uno con un rango, la cantidad de documentos bajo cada rango y luego tres subcolecciones correspondientes. Para leer una clasificación, recorreremos este árbol buscando una puntuación y realizando un seguimiento 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 realizar todas nuestras escrituras dentro de una transacción para evitar inconsistencias de datos cuando ocurren múltiples escrituras o lecturas al mismo tiempo. También necesitaremos mantener todas las condiciones que describimos anteriormente mientras recorremos el árbol para escribir nuestros nuevos documentos. Y, finalmente, dado que tenemos toda la complejidad de este nuevo enfoque combinada con la necesidad de almacenar todos nuestros documentos originales, nuestro costo de almacenamiento aumentará ligeramente (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,
      });
    });
  });
}

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

leaderboard.addScores();

La estructura de la base de datos resultante debería verse así, con la estructura de árbol claramente visible y las hojas del árbol representando 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 hemos superado 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 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 Firebase console.

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 tabla de clasificación puede generar problemas de contención de bloqueo, ya que las transacciones requieren bloqueos de lectura y escritura en documentos para garantizar que se mantengan coherentes.
  • En segundo lugar, Firestore impone un límite de profundidad de subcolección de 100 , lo que significa que deberás evitar la creación de subárboles después de 100 puntuaciones empatadas, algo que esta implementación no hace.
  • Y, por último, esta tabla de clasificación escala logarítmicamente solo en el caso ideal en el que el árbol está equilibrado; si está desequilibrado, el peor rendimiento de esta tabla de clasificación vuelve a ser lineal.

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

6. Implementar una tabla de clasificación estocástica (probabilí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 solucionar esto que no exploraremos en este codelab, pero si no necesita una clasificación exacta, puede eliminar toda la complejidad del enfoque anterior y optar por algo más simple y rápido. Echemos un vistazo a cómo podríamos 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 de los cuales representa aproximadamente el uno por ciento 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 aproximadamente uniforme de las puntuaciones en todo el segmento, pero lograremos una mayor precisión en nuestras aproximaciones si sabemos cómo se distribuirán nuestras puntuaciones. .

Nuestro enfoque es el siguiente: como antes, cada depósito almacena el recuento del número de puntuaciones dentro y el rango de las puntuaciones. Al insertar una nueva puntuación, buscaremos el depósito de la puntuación e incrementaremos su recuento. Al buscar una clasificación, simplemente sumaremos los depósitos que se encuentran delante de él y luego aproximaremos dentro de nuestro depósito en lugar de buscar más. Esto nos brinda búsquedas e inserciones en 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ás que este código de inserción tiene cierta lógica para inicializar el estado de tu base de datos en la parte superior con una advertencia para no hacer algo como esto en producción. El código de 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 generar un montón de depósitos duplicados.

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

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

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

leaderboard.addScores();

Y ahora, para 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 hicimos que la función addScores genere una distribución uniforme de puntuaciones y usamos interpolación lineal dentro de los grupos, obtendremos resultados muy precisos, el rendimiento de nuestra tabla de clasificación no se degradará a medida que aumentamos el número de usuarios. y no tenemos que preocuparnos (tanto) por la contención de bloqueos cuando se actualizan los recuentos.

7. Anexo: hacer trampa

Espera, podrías estar pensando, si estoy escribiendo valores en mi codelab 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 una puntuación alta 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 mediante reglas de seguridad , proteger el acceso a sus funciones en la nube para que los clientes no puedan llamarlos directamente y luego validar las acciones del juego en su servidor antes. enviar actualizaciones de puntuación a la tabla de clasificación.

Es importante señalar 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 están constantemente jugando 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 anti-abuso como App Check que evitarán 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-trampas integral.

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 más altos 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 tu juego, podrás elegir uno que funcione para ti a un costo razonable.

A continuación, consulte las rutas de aprendizaje de los juegos.