Crie tabelas de classificação com o Firestore

1. Introdução

Última atualização: 2023-01-27

O que é preciso para construir uma tabela de classificação?

Em sua essência, as tabelas de classificação são apenas tabelas de pontuações com um fator complicador: ler uma classificação para qualquer pontuação específica requer conhecimento de todas as outras pontuações em algum tipo de ordem. Além disso, se o seu jogo decolar, suas tabelas de classificação crescerão e serão lidas e gravadas com frequência. Para construir uma tabela de classificação bem-sucedida, ela precisa ser capaz de lidar com essa operação de classificação rapidamente.

O que você vai construir

Neste codelab, você implementará vários placares diferentes, cada um adequado para um cenário diferente.

O que você aprenderá

Você aprenderá como implementar quatro tabelas de classificação diferentes:

  • Uma implementação ingênua usando contagem de registros simples para determinar a classificação
  • Uma tabela de classificação barata e atualizada periodicamente
  • Uma tabela de classificação em tempo real com algumas bobagens de árvores
  • Uma tabela de classificação estocástica (probabilística) para classificação aproximada de bases de jogadores muito grandes

O que você precisará

  • Uma versão recente do Chrome (107 ou posterior)
  • Node.js 16 ou superior (execute nvm --version para ver o número da sua versão se estiver usando nvm)
  • Um plano Firebase Blaze pago (opcional)
  • Firebase CLI v11.16.0 ou superior
    Para instalar a CLI, você pode executar npm install -g firebase-tools ou consultar a documentação da CLI para obter mais opções de instalação.
  • Conhecimento de JavaScript, Cloud Firestore, Cloud Functions e Chrome DevTools

2. Preparando-se

Obtenha o código

Colocamos tudo o que você precisa para este projeto em um repositório Git. Para começar, você precisará pegar o código e abri-lo em seu ambiente de desenvolvimento favorito. Para este codelab, usamos o VS Code, mas qualquer editor de texto serve.

e descompacte o arquivo zip baixado.

Ou clone no diretório de sua escolha:

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

Qual é o nosso ponto de partida?

Nosso projeto é atualmente uma lousa em branco com algumas funções vazias:

  • index.html contém alguns scripts de cola que nos permitem invocar funções do console do desenvolvedor e ver suas saídas. Usaremos isso para fazer interface com nosso back-end e ver os resultados de nossas invocações de função. Em um cenário do mundo real, você faria essas chamadas de back-end diretamente do seu jogo. Não estamos usando um jogo neste codelab porque levaria muito tempo para jogar um jogo toda vez que você quisesse adicionar uma pontuação ao placar .
  • functions/index.js contém todas as nossas Cloud Functions. Você verá algumas funções utilitárias, como addScores e deleteScores , bem como as funções que implementaremos neste codelab, que chamam funções auxiliares em outro arquivo.
  • functions/functions-helpers.js contém as funções vazias que implementaremos. Para cada tabela de classificação, implementaremos funções de leitura, criação e atualização, e você verá como nossa escolha de implementação afeta a complexidade de nossa implementação e seu desempenho de dimensionamento.
  • functions/utils.js contém mais funções utilitárias. Não tocaremos nesse arquivo neste codelab.

Criar e configurar um projeto do Firebase

  1. No console do Firebase , clique em Adicionar projeto .
  2. Para criar um novo projeto, digite o nome do projeto desejado.
    Isso também definirá o ID do projeto (exibido abaixo do nome do projeto) para algo baseado no nome do projeto. Opcionalmente, você pode clicar no ícone de edição no ID do projeto para personalizá-lo ainda mais.
  3. Se solicitado, revise e aceite os termos do Firebase .
  4. Clique em Continuar .
  5. Selecione a opção Ativar o Google Analytics para este projeto e clique em Continuar .
  6. Selecione uma conta existente do Google Analytics para usar ou selecione Criar uma nova conta para criar uma nova conta.
  7. Clique em Criar projeto .
  8. Quando o projeto for criado, clique em Continuar .
  9. No menu Build , clique em Functions e, se solicitado, atualize seu projeto para usar o plano de cobrança Blaze.
  10. No menu Construir , clique em Banco de dados do Firestore .
  11. Na caixa de diálogo Criar banco de dados que aparece, selecione Iniciar no modo de teste e clique em Avançar .
  12. Escolha uma região no menu suspenso de localização do Cloud Firestore e clique em Ativar .

Configurar e executar sua tabela de classificação

  1. Em um terminal, navegue até a raiz do projeto e execute firebase use --add . Escolha o projeto do Firebase que você acabou de criar.
  2. Na raiz do projeto, execute firebase emulators:start --only hosting .
  3. Em seu navegador, navegue até localhost:5000 .
  4. Abra o console JavaScript do Chrome DevTools e importe leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Execute leaderboard.codelab(); no console. Se você vir uma mensagem de boas-vindas, está tudo pronto! Caso contrário, desligue o emulador e execute novamente as etapas 2 a 4.

Vamos pular para a primeira implementação da tabela de classificação.

3. Implemente uma tabela de classificação simples

No final desta seção, poderemos adicionar uma pontuação à tabela de classificação e fazer com que ela nos informe nossa classificação.

Antes de começarmos, vamos explicar como funciona a implementação dessa tabela de classificação: todos os jogadores são armazenados em uma única coleção, e a obtenção da classificação de um jogador é feita recuperando a coleção e contando quantos jogadores estão à frente deles. Isso facilita a inserção e atualização de uma partitura. Para inserir uma nova partitura, basta anexá-la à coleção e, para atualizá-la, filtramos nosso usuário atual e, em seguida, atualizamos o documento resultante. Vamos ver como isso fica no código.

Em functions/functions-helper.js , implemente a função createScore , que é a mais simples possível:

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

Para atualizar as pontuações, precisamos apenas adicionar uma verificação de erro para garantir que a pontuação que está sendo atualizada já 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,
  });
}

E, finalmente, nossa função de classificação simples, mas menos escalável:

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 colocá-lo à prova! Implante suas funções executando o seguinte no terminal:

firebase deploy --only functions

E então, no console JS do Chrome, adicione algumas outras pontuações para que possamos ver nossa classificação entre outros jogadores.

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

Agora podemos adicionar nossa própria pontuação à mistura:

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

Quando a gravação for concluída, você deverá ver uma resposta no console dizendo "Pontuação criada". Vendo um erro em vez disso? Abra os logs do Functions por meio do console do Firebase para ver o que deu errado.

E, finalmente, podemos buscar e atualizar nossa pontuação.

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

No entanto, essa implementação nos fornece requisitos de tempo e memória lineares indesejáveis ​​para buscar a classificação de uma determinada pontuação. Como o tempo de execução da função e a memória são limitados, isso não apenas significa que nossas buscas se tornarão cada vez mais lentas, mas depois que pontuações suficientes forem adicionadas à tabela de classificação, nossas funções expirarão ou travarão antes que possam retornar um resultado. Claramente, precisaremos de algo melhor se formos escalar além de um punhado de jogadores.

Se você é um aficionado do Firestore, pode estar ciente das COUNT consultas de agregação , o que tornaria esse placar muito mais eficiente. E você estaria certo! Com COUNT consultas, isso escala bem abaixo de um milhão de usuários, embora seu desempenho ainda seja linear.

Mas espere, você pode estar pensando consigo mesmo, se vamos enumerar todos os documentos na coleção de qualquer maneira, podemos atribuir a cada documento uma classificação e, quando precisarmos buscá-lo, nossas buscas serão O(1) tempo e memória! Isso nos leva à nossa próxima abordagem, a tabela de classificação com atualização periódica.

4. Implemente uma tabela de classificação com atualização periódica

A chave para essa abordagem é armazenar a classificação no próprio documento, portanto, buscá-la nos dá a classificação sem nenhum trabalho adicional. Para conseguir isso, precisaremos de um novo tipo de função.

Em index.js , adicione o seguinte:

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

Agora nossas operações de leitura, atualização e gravação são simples e agradáveis. Write e update permanecem inalterados, mas read se torna (em 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"),
  };
}

Infelizmente, você não poderá implantar e testar isso sem adicionar uma conta de cobrança ao seu projeto. Se você tiver uma conta de cobrança, encurte o intervalo na função agendada e observe sua função atribuir classificações magicamente às pontuações da tabela de classificação.

Caso contrário, exclua a função agendada e passe para a próxima implementação.

Vá em frente e exclua as pontuações em seu banco de dados do Firestore clicando nos 3 pontos ao lado da coleção de pontuações para se preparar para a próxima seção.

Firestore scores document page with\nDelete Collection activated

5. Implemente uma tabela de classificação em árvore em tempo real

Essa abordagem funciona armazenando dados de pesquisa na própria coleção de banco de dados. Em vez de ter uma coleção uniforme, nosso objetivo é armazenar tudo em uma árvore que podemos percorrer movendo-nos pelos documentos. Isso nos permite realizar uma pesquisa binária (ou n-ária) para a classificação de uma determinada pontuação. O que isso pode parecer?

Para começar, queremos poder distribuir nossas pontuações em intervalos mais ou menos uniformes, o que exigirá algum conhecimento dos valores das pontuações que nossos usuários estão registrando; por exemplo, se você estiver construindo uma tabela de classificação para classificação de habilidade em um jogo competitivo, as classificações de habilidade de seus usuários quase sempre terminarão normalmente distribuídas. Nossa função de geração de pontuação aleatória usa Math.random() do JavaScript, que resulta em uma distribuição aproximadamente uniforme, portanto, dividiremos nossos baldes uniformemente.

Neste exemplo, usaremos 3 baldes para simplificar, mas você provavelmente descobrirá que, se usar essa implementação em um aplicativo real, mais baldes produzirão resultados mais rápidos – uma árvore mais rasa significa, em média, menos buscas de coleta e menos contenção de bloqueio.

A classificação de um jogador é dada pela soma do número de jogadores com pontuações mais altas, mais um para o próprio jogador. Cada coleção sob scores armazenará três documentos, cada um com um intervalo, o número de documentos em cada intervalo e, em seguida, três subcoleções correspondentes. Para ler uma classificação vamos percorrer esta árvore procurando por uma pontuação e acompanhando a soma das maiores pontuações. Quando encontrarmos nossa pontuação, também teremos a soma correta.

Escrever é significativamente mais complicado. Primeiro, precisaremos fazer todas as nossas gravações em uma transação para evitar inconsistências de dados quando várias gravações ou leituras ocorrerem ao mesmo tempo. Também precisaremos manter todas as condições descritas acima conforme percorremos a árvore para escrever nossos novos documentos. E, finalmente, como temos toda a complexidade da árvore dessa nova abordagem combinada com a necessidade de armazenar todos os nossos documentos originais, nosso custo de armazenamento aumentará um pouco (mas ainda é linear).

Em functions-helpers.js :

async function createScore(playerID, score, firestore) {
  /**
   * This function assumes a minimum score of 0 and that value
   * is between min and max.
   * Returns the expected size of a bucket for a given score
   * so that bucket sizes stay constant, to avoid expensive
   * re-bucketing.
   * @param {number} value The new score.
   * @param {number} min The min of the previous range.
   * @param {number} max The max of the previous range. Must be greater than
   *     min.
   * @return {Object<string, number>} Returns an object containing the new min
   *     and max.
   */
  function bucket(value, min, max) {
    const bucketSize = (max - min) / 3;
    const bucketMin = Math.floor(value / bucketSize) * bucketSize;
    const bucketMax = bucketMin + bucketSize;
    return {min: bucketMin, max: bucketMax};
  }

  /**
   * A function used to store pending writes until all reads within a
   * transaction have completed.
   *
   * @callback PendingWrite
   * @param {admin.firestore.Transaction} transaction The transaction
   *     to be used for writes.
   * @returns {void}
   */

  /**
   * Recursively searches for the node to write the score to,
   * then writes the score and updates any counters along the way.
   * @param {number} id The user associated with the score.
   * @param {number} value The new score.
   * @param {admin.firestore.CollectionReference} coll The collection this
   *     value should be written to.
   * @param {Object<string, number>} range An object with properties min and
   *     max defining the range this score should be in. Ranges cannot overlap
   *     without causing problems. Use the bucket function above to determine a
   *     root range from constant values to ensure consistency.
   * @param {admin.firestore.Transaction} transaction The transaction used to
   *     ensure consistency during tree updates.
   * @param {Array<PendingWrite>} pendingWrites A series of writes that should
   *     occur once all reads within a transaction have completed.
   * @return {void} Write error/success is handled via the transaction object.
   */
  async function writeScoreToCollection(
      id, value, coll, range, transaction, pendingWrites) {
    const snapshot = await transaction.get(coll);
    if (snapshot.empty) {
      // This is the first score to be inserted into this node.
      for (const write of pendingWrites) {
        write(transaction);
      }
      const docRef = coll.doc();
      transaction.create(docRef, {exact: {score: value, user: id}});
      return;
    }

    const min = range.min;
    const max = range.max;

    for (const node of snapshot.docs) {
      const data = node.data();
      if (data.exact !== undefined) {
        // This node held an exact score.
        const newRange = bucket(value, min, max);
        const tempRange = bucket(data.exact.score, min, max);

        if (newRange.min === tempRange.min &&
          newRange.max === tempRange.max) {
          // The scores belong in the same range, so we need to "demote" both
          // to a lower level of the tree and convert this node to a range.
          const rangeData = {
            range: newRange,
            count: 2,
          };
          for (const write of pendingWrites) {
            write(transaction);
          }
          const docReference = node.ref;
          transaction.set(docReference, rangeData);
          transaction.create(docReference.collection("scores").doc(), data);
          transaction.create(
              docReference.collection("scores").doc(),
              {exact: {score: value, user: id}},
          );
          return;
        } else {
          // The scores are in different ranges. Continue and try to find a
          // range that fits this score.
          continue;
        }
      }

      if (data.range.min <= value && data.range.max > value) {
        // The score belongs to this range that may have subvalues.
        // Increment the range's count in pendingWrites, since
        // subsequent recursion may incur more reads.
        const docReference = node.ref;
        const newCount = node.get("count") + 1;
        pendingWrites.push((t) => {
          t.update(docReference, {count: newCount});
        });
        const newRange = bucket(value, min, max);
        return writeScoreToCollection(
            id,
            value,
            docReference.collection("scores"),
            newRange,
            transaction,
            pendingWrites,
        );
      }
    }

    // No appropriate range was found, create an `exact` value.
    transaction.create(coll.doc(), {exact: {score: value, user: id}});
  }

  const scores = firestore.collection("scores");
  const players = firestore.collection("players");
  return firestore.runTransaction((transaction) => {
    return writeScoreToCollection(
        playerID, score, scores, {min: 0, max: 1000}, transaction, [],
    ).then(() => {
      transaction.create(players.doc(), {
        user: playerID,
        score: score,
      });
    });
  });
}

Isso certamente é mais complicado do que nossa última implementação, que era uma única chamada de método e apenas seis linhas de código. Depois de implementar esse método, tente adicionar algumas pontuações ao banco de dados e observar a estrutura da árvore resultante. No seu console JS:

leaderboard.addScores();

A estrutura de banco de dados resultante deve ser semelhante a esta, com a estrutura da árvore claramente visível e as folhas da árvore representando as pontuações individuais.

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

Agora que resolvemos a parte difícil, podemos ler as partituras percorrendo a árvore conforme descrito 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,
  };
}

As atualizações são deixadas como um exercício extra. Tente adicionar e buscar pontuações em seu console JS com os métodos leaderboard.addScore(id, score) e leaderboard.getRank(id) e veja como seu placar muda no console Firebase.

Com essa implementação, no entanto, a complexidade que adicionamos para obter desempenho logarítmico tem um custo.

  • Primeiro, essa implementação de tabela de classificação pode apresentar problemas de contenção de bloqueio, pois as transações exigem leituras e gravações de bloqueio em documentos para garantir que permaneçam consistentes.
  • Em segundo lugar, o Firestore impõe um limite de profundidade de subcoleção de 100 , o que significa que você precisará evitar a criação de subárvores após 100 pontuações empatadas, o que esta implementação não faz.
  • E, finalmente, esse placar escala logaritmicamente apenas no caso ideal em que a árvore está balanceada – se estiver desbalanceada, o desempenho do pior caso desse placar é novamente linear.

Quando terminar, exclua as scores e as coleções players por meio do console do Firebase e passaremos para nossa última implementação de placar.

6. Implemente uma tabela de classificação estocástica (probabilística)

Ao executar o código de inserção, você pode perceber que, se executá-lo muitas vezes em paralelo, suas funções começarão a falhar com uma mensagem de erro relacionada à contenção de bloqueio de transação. Existem maneiras de contornar isso que não exploraremos neste codelab, mas se você não precisar de uma classificação exata, poderá descartar toda a complexidade da abordagem anterior para algo mais simples e rápido. Vamos dar uma olhada em como podemos retornar uma classificação estimada para as pontuações de nossos jogadores em vez de uma classificação exata e como isso altera nossa lógica de banco de dados.

Para essa abordagem, dividiremos nossa tabela de classificação em 100 grupos, cada um representando aproximadamente um por cento das pontuações que esperamos receber. Essa abordagem funciona mesmo sem o conhecimento de nossa distribuição de pontuação, caso em que não temos como garantir uma distribuição uniforme de pontuações em todo o balde, mas obteremos maior precisão em nossas aproximações se soubermos como nossas pontuações serão distribuídas .

Nossa abordagem é a seguinte: como antes, cada balde armazena a contagem do número de pontuações dentro e o intervalo das pontuações. Ao inserir uma nova pontuação, encontraremos o balde para a pontuação e incrementaremos sua contagem. Ao buscar uma classificação, apenas somaremos os baldes à frente dela e, em seguida, aproximaremos dentro de nosso balde, em vez de pesquisar mais. Isso nos dá pesquisas e inserções de tempo constante muito boas e requer muito menos código.

Primeiro, inserção:

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

Você notará que esse código de inserção tem alguma lógica para inicializar o estado do seu banco de dados na parte superior com um aviso para não fazer algo assim na produção. O código para inicialização não é protegido de forma alguma contra condições de corrida, portanto, se você fizer isso, várias gravações simultâneas corromperão seu banco de dados, fornecendo a você um monte de depósitos duplicados.

Vá em frente e implante suas funções e, em seguida, execute uma inserção para inicializar todos os depósitos com uma contagem de zero. Ele retornará um erro, que você pode ignorar com segurança.

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

Agora que o banco de dados foi inicializado corretamente, podemos executar addScores e ver a estrutura de nossos dados no Firebase console. A estrutura resultante é muito mais plana do que nossa última implementação, embora sejam superficialmente semelhantes.

leaderboard.addScores();

E, agora, para ler as 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,
  };
}

Como fizemos a função addScores gerar uma distribuição uniforme de pontuações e estamos usando interpolação linear dentro dos intervalos, obteremos resultados muito precisos, o desempenho de nossa tabela de classificação não diminuirá à medida que aumentarmos o número de usuários, e não precisamos nos preocupar com contenção de bloqueio (tanto) ao atualizar as contagens.

7. Adendo: Trapaça

Espere, você deve estar pensando, se estou escrevendo valores para meu codelab por meio do console JS de uma guia do navegador, nenhum dos meus jogadores pode simplesmente mentir para a tabela de classificação e dizer que obteve uma pontuação alta que não obteve alcançar de forma justa?

Sim eles podem. Se você deseja evitar trapaças, a maneira mais robusta de fazer isso é desabilitar as gravações do cliente em seu banco de dados por meio de regras de segurança , proteger o acesso às funções do Cloud para que os clientes não possam chamá-los diretamente e, em seguida, validar as ações do jogo em seu servidor antes enviando atualizações de pontuação para a tabela de classificação.

É importante observar que esta estratégia não é uma panacéia contra a trapaça - com um incentivo grande o suficiente, os trapaceiros podem encontrar maneiras de contornar as validações do lado do servidor, e muitos videogames grandes e bem-sucedidos estão constantemente jogando gato e rato com seus trapaceiros para identificar novas fraudes e impedir que elas se proliferem. Uma consequência difícil desse fenômeno é que a validação do lado do servidor para cada jogo é inerentemente feita sob medida; embora o Firebase forneça ferramentas antiabuso, como o App Check, que impedirá que um usuário copie seu jogo por meio de um cliente com script simples, o Firebase não fornece nenhum serviço que seja um antitrapaça holístico.

Qualquer coisa que não seja validação do lado do servidor, para um jogo popular o suficiente ou uma barreira baixa o suficiente para trapacear, resultará em uma tabela de classificação onde os valores principais são todos trapaceiros.

8. Parabéns

Parabéns, você construiu com sucesso quatro tabelas de classificação diferentes no Firebase! Dependendo das necessidades de precisão e velocidade do seu jogo, você poderá escolher um que funcione para você a um custo razoável.

Em seguida, confira os caminhos de aprendizado para jogos.