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 se populariza, las 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 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 ejecutarnpm install -g firebase-tools
o consultar la documentación de la CLI para obtener más opciones de instalación. - Conocimientos de JavaScript, Cloud Firestore, Cloud Functions y Herramientas para desarrolladores de Chrome
2. Cómo prepararte
Obtén el código
Todo lo que necesitas para este proyecto se encuentra en un repositorio de Git. Para comenzar, deberás obtener el código y abrirlo en tu entorno de desarrollo favorito. En este codelab, usamos VS Code, pero puedes usar 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 es una pizarra en blanco con algunas funciones vacías:
index.html
contiene algunas secuencias de comandos de vinculación que nos permiten invocar funciones desde la consola de desarrollador 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 al 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 Cloud Functions. Verás algunas funciones de utilidad, comoaddScores
ydeleteScores
, 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á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
- En Firebase console, haz clic en Agregar proyecto.
- 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 editar en el ID del proyecto para personalizarlo aún más. - Si se te solicita, revisa y acepta las Condiciones de Firebase.
- Haz clic en Continuar.
- Selecciona la opción Habilitar Google Analytics para este proyecto y, luego, haz clic en Continuar.
- Selecciona una cuenta de Google Analytics existente para usarla o selecciona Crear una cuenta nueva para crear una nueva.
- Haz clic en Crear proyecto.
- Cuando se haya creado el proyecto, haz clic en Continuar.
- 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.
- En el menú Build, haz clic en Base de datos de Firestore.
- En el cuadro de diálogo Create database que aparece, selecciona Start in test mode y, luego, haz clic en Next.
- Elige una región del menú desplegable Ubicación de Cloud Firestore y, luego, haz clic en Habilitar.
Configura y ejecuta tu tabla de clasificación
- En una terminal, navega hasta la raíz del proyecto y ejecuta
firebase use --add
. Elige el proyecto de Firebase que acabas de crear. - En la raíz del proyecto, ejecuta
firebase emulators:start --only hosting
. - En tu navegador, ve a
localhost:5000
. - 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");
- Ejecuta
leaderboard.codelab();
en la consola. Si ves un mensaje de bienvenida, significa que todo está listo. De lo contrario, apaga el emulador y vuelve a ejecutar los pasos 2 a 4.
Comencemos con la primera implementación de tablas 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 que nos indique nuestra clasificación.
Antes de comenzar, explicaremos cómo funciona esta implementación de tablas de clasificación: todos los jugadores se almacenan en una sola colección, y para recuperar la clasificación de un jugador, se recupera la colección y se cuenta cuántos jugadores están por delante de él. Esto facilita la inserción y actualización de una puntuación. Para insertar una puntuación nueva, solo la adjuntamos 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
, implementa la función createScore
, que es lo más directa posible:
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
Para actualizar las puntuaciones, solo debemos agregar una verificación de errores para asegurarnos de que la puntuación que se actualiza 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, 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! Ejecuta el siguiente comando en la terminal para implementar tus funciones:
firebase deploy --only functions
Luego, en la consola de JS de Chrome, agrega 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 dice “Score created”. ¿En cambio, ves un error? 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 lineales no deseados para recuperar la clasificación 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 agregar suficientes puntuaciones a la tabla de clasificación, nuestras funciones se agotarán o fallarán antes de que puedan mostrar un resultado. Sin duda, necesitaremos algo mejor si vamos a escalar más allá de un puñado de jugadores.
Si eres aficionado a Firestore, es posible que conozcas las consultas de agregación COUNT, 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. Implementa una tabla de clasificación que se actualice 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 lograrlo, 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 para prepararte para la siguiente sección.
5. Implementa 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 atravesar cuando nos desplazamos a través de los documentos. Esto nos permite realizar una búsqueda binaria (o n-aria) de la clasificación de una puntuación determinada. ¿Cómo se vería?
Para empezar, desearemos poder distribuir las puntuaciones en segmentos similares, 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, la capacidad las calificaciones de habilidades casi siempre terminan
distribuidas normalmente. Nuestra función para generar puntuaciones aleatorias usa Math.random()
de JavaScript, 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.
La escritura es mucho más complicada. 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 deberemos mantener todas las condiciones que describimos anteriormente a medida que recorremos el árbol para escribir nuestros documentos nuevos. Por último, como 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 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 la consola de JS, haz lo siguiente:
leaderboard.addScores();
La estructura de la base de datos resultante debería verse de la siguiente manera, con la estructura del árbol claramente visible y las hojas del árbol que representan 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 resolvimos la parte difícil, podemos leer las puntuaciones si recorremos 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 adicional. Intenta agregar y recuperar puntuaciones en la 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 tablas de clasificación puede tener problemas de contención de bloqueos, ya que las transacciones requieren bloqueos de operaciones 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 crear subárboles después de 100 puntuaciones empatadas, lo que no hace esta implementación.
- Por último, esta tabla de clasificación se escala de forma logarítmica solo en el caso ideal en el que el árbol está equilibrado. Si no lo está, el rendimiento en el peor de los casos de esta tabla de clasificación vuelve a ser 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 de forma paralela, 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. Echemos un vistazo a cómo podríamos devolver una clasificación estimada para las posiciones en lugar de una clasificación exacta, y cómo 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 y el rango de las puntuaciones. Cuando insertemos una puntuación nueva, buscaremos el bucket para la puntuación y aumentaremos su recuento. Cuando recuperemos una clasificación, simplemente sumaremos los buckets anteriores y, luego, los 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.
Primero, 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.
Continúa e implementa tus 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 nuestra última implementación, aunque son similares en la superficie.
leaderboard.addScores();
Ahora, para leer las puntuaciones, sigue estos pasos:
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 las puntuaciones y usamos la 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 por la contención de bloqueos (tanto) cuando actualicemos 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, proteger el acceso a tus funciones de 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 el uso de trampas. Con un incentivo lo suficientemente grande, los tramposos pueden encontrar formas de eludir las validaciones del servidor, y muchos videojuegos grandes y exitosos juegan constantemente al gato y al ratón con sus tramposos para identificar trampas nuevas 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 contra el abuso, como la Verificación de aplicaciones, que evitarán que un usuario copie tu juego a través de un cliente de secuencias de comandos simple, no proporciona ningún servicio que sea una solución integral contra el uso de 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.