Создавайте списки лидеров с помощью Firestore

1. Введение

Последнее обновление: 27.01.2023

Что нужно для создания таблицы лидеров?

По своей сути, таблицы лидеров — это просто таблицы результатов, но с одним усложняющим фактором: для определения рейтинга по любому результату необходимо знать все остальные результаты в определенном порядке. Кроме того, если ваша игра станет популярной, ваши таблицы лидеров разрастутся, и данные из них будут часто считываться и записываться. Для создания успешной таблицы лидеров она должна быстро обрабатывать эту операцию ранжирования.

Что вы построите

В этом практическом занятии вы реализуете различные таблицы лидеров, каждая из которых подходит для разных сценариев.

Что вы узнаете

Вы узнаете, как реализовать четыре различных таблицы лидеров:

  • Простая реализация, использующая подсчет записей для определения ранга.
  • Недорогая, периодически обновляемая таблица лидеров.
  • Таблица лидеров в реальном времени с какой-то древовидной бессмыслицей.
  • Стохастическая (вероятностная) таблица лидеров для приблизительного ранжирования очень больших баз игроков.

Что вам понадобится

  • Последняя версия Chrome (107 или более поздняя)
  • Node.js 16 или выше (если вы используете nvm, запустите nvm --version , чтобы узнать номер вашей версии).
  • Платный тарифный план Firebase Blaze (по желанию)
  • Firebase CLI версии 11.16.0 или выше.
    Для установки CLI можно выполнить команду npm install -g firebase-tools или обратиться к документации CLI для получения дополнительных параметров установки.
  • Знание JavaScript, Cloud Firestore, Cloud Functions и Chrome DevTools.

2. Настройка

Получите код

Все необходимое для этого проекта собрано в репозитории Git. Для начала вам нужно будет скачать код и открыть его в вашей любимой среде разработки. Для этого практического занятия мы использовали VS Code, но подойдет любой текстовый редактор.

и распакуйте загруженный zip-файл.

Или клонируйте репозиторий в выбранную вами директорию:

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

С чего мы начнём?

Наш проект в настоящее время представляет собой чистый лист с некоторыми пустыми функциями:

  • index.html содержит несколько вспомогательных скриптов, позволяющих вызывать функции из консоли разработчика и видеть их результат. Мы будем использовать это для взаимодействия с нашим бэкендом и просмотра результатов вызовов функций. В реальной ситуации вы бы делали эти вызовы к бэкенду непосредственно из игры — в этом практическом занятии мы не используем игру, потому что играть в игру каждый раз, когда нужно добавить результат в таблицу лидеров, заняло бы слишком много времени.
  • functions/index.js содержит все наши облачные функции. Вы увидите некоторые вспомогательные функции, такие как addScores и deleteScores , а также функции, которые мы реализуем в этом практическом занятии, и которые вызывают вспомогательные функции из другого файла.
  • functions/functions-helpers.js содержит пустые функции, которые мы будем реализовывать. Для каждой таблицы лидеров мы реализуем функции чтения, создания и обновления, и вы увидите, как наш выбор реализации влияет как на сложность нашей реализации, так и на ее масштабируемость.
  • functions/utils.js содержит дополнительные вспомогательные функции. В этом практическом занятии мы не будем его трогать.

Создайте и настройте проект Firebase.

Создайте новый проект Firebase.

  1. Войдите в консоль Firebase, используя свою учетную запись Google.
  2. Нажмите кнопку, чтобы создать новый проект, а затем введите название проекта (например, Leaderboards Codelab ).
  3. Нажмите «Продолжить» .
  4. Если появится запрос, ознакомьтесь с условиями использования Firebase и примите их, после чего нажмите «Продолжить» .
  5. (Необязательно) Включите помощь ИИ в консоли Firebase (в Firebase она называется "Gemini").
  6. Для этого практического занятия вам не понадобится Google Analytics, поэтому отключите эту опцию.
  7. Нажмите «Создать проект» , дождитесь завершения подготовки проекта, а затем нажмите «Продолжить» .

Настройка продуктов Firebase

  1. В меню «Сборка» выберите «Функции» , и если появится запрос, обновите свой проект, чтобы использовать тарифный план Blaze.
  2. В меню «Сборка» выберите «База данных Firestore» .
  3. В появившемся диалоговом окне «Создать базу данных» выберите «Запустить в тестовом режиме» , затем нажмите «Далее» .
  4. Выберите регион из раскрывающегося списка «Расположение Cloud Firestore» , затем нажмите «Включить» .

Настройте и запустите свою таблицу лидеров.

  1. В терминале перейдите в корневую папку проекта и выполните firebase use --add . Выберите только что созданный проект Firebase.
  2. В корне проекта выполните firebase emulators:start --only hosting .`.
  3. В браузере перейдите по адресу localhost:5000 .
  4. Откройте консоль JavaScript в инструментах разработчика Chrome и импортируйте leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Запустите leaderboard.codelab(); в консоли. Если вы увидите приветственное сообщение, значит, все готово! В противном случае закройте эмулятор и повторите шаги 2-4.

Давайте перейдём к первой реализации таблицы лидеров.

3. Реализуйте простую таблицу лидеров.

К концу этого раздела мы сможем добавить результат в таблицу лидеров и получить от неё информацию о нашем месте в рейтинге.

Прежде чем мы начнём, давайте объясним, как работает эта реализация таблицы лидеров: все игроки хранятся в одной коллекции, а получение рейтинга игрока осуществляется путём получения коллекции и подсчёта количества игроков, опережающих его. Это упрощает добавление и обновление результатов. Чтобы добавить новый результат, мы просто добавляем его в коллекцию, а чтобы обновить его, мы фильтруем по текущему пользователю, а затем обновляем полученный документ. Давайте посмотрим, как это выглядит в коде.

В functions/functions-helper.js реализуйте функцию createScore , что максимально просто:

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

Для обновления результатов нам достаточно добавить проверку на ошибку, чтобы убедиться, что обновляемый результат уже существует:

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

И наконец, наша простая, но менее масштабируемая функция ранжирования:

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

Давайте проверим! Разверните свои функции, выполнив в терминале следующую команду:

firebase deploy --only functions

А затем в консоли JavaScript Chrome добавьте еще несколько оценок, чтобы мы могли увидеть свой рейтинг среди других игроков.

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

Теперь мы можем добавить к этому свой собственный результат:

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

После завершения записи в консоли должно отобразиться сообщение «Оценка создана». Если вместо этого вы видите ошибку, откройте журналы функций через консоль Firebase, чтобы узнать, что пошло не так.

И, наконец, мы можем получить и обновить наш результат.

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

Однако такая реализация приводит к нежелательным линейным временным и пространственным затратам на получение ранга для заданного результата. Поскольку время выполнения функций и объем памяти ограничены, это не только приведет к замедлению процесса получения данных, но и к тому, что после добавления достаточного количества результатов в таблицу лидеров наши функции будут завершаться с ошибкой или прерываться до того, как смогут вернуть результат. Очевидно, нам потребуется что-то лучшее, если мы хотим масштабироваться за пределы небольшого количества игроков.

Если вы являетесь поклонником Firestore, вы, возможно, знакомы с запросами агрегации COUNT , которые значительно повышают производительность этой таблицы лидеров. И вы будете правы! С запросами COUNT производительность хорошо масштабируется до количества пользователей менее миллиона, хотя она по-прежнему остается линейной.

Но подождите, вы, возможно, подумаете: если мы всё равно собираемся перечислить все документы в коллекции, мы можем присвоить каждому документу ранг, и тогда, когда нам понадобится его получить, наши операции получения займут время и память O(1)! Это приводит нас к следующему подходу — периодически обновляемой таблице лидеров.

4. Внедрить периодически обновляемую таблицу лидеров.

Ключ к этому подходу заключается в хранении ранга непосредственно в документе, поэтому его получение позволяет получить ранг без дополнительных усилий. Для этого нам понадобится новая функция.

В index.js добавьте следующее:

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

Теперь операции чтения, обновления и записи стали простыми и удобными. Операции записи и обновления остались без изменений, а операция чтения (в 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"),
  };
}

К сожалению, вы не сможете развернуть и протестировать это без добавления платежного аккаунта в ваш проект. Если у вас есть платежный аккаунт, сократите интервал выполнения запланированной функции, и вы увидите, как ваша функция волшебным образом присвоит ранги результатам в таблице лидеров.

В противном случае удалите запланированную функцию и перейдите к следующей реализации.

Для подготовки к следующему разделу удалите оценки из базы данных Firestore, нажав на три точки рядом с коллекцией оценок.

Firestore scores document page with\nDelete Collection activated

5. Реализовать таблицу лидеров в виде дерева результатов в реальном времени.

Этот подход работает за счет хранения поисковых данных в самой коллекции базы данных. Вместо использования однородной коллекции, наша цель — хранить все данные в древовидной структуре, по которой можно перемещаться, перебирая документы. Это позволяет нам выполнять бинарный (или n-арный) поиск по рангу заданного показателя. Как это может выглядеть?

Для начала нам нужно будет распределить наши результаты примерно поровну, что потребует знания значений результатов, которые вводят наши пользователи; например, если вы создаёте таблицу лидеров по рейтингу мастерства в соревновательной игре, рейтинги мастерства ваших пользователей почти всегда будут распределены по нормальному закону. Наша функция генерации случайных результатов использует метод Math.random() из JavaScript, что приводит к приблизительно равномерному распределению, поэтому мы разделим наши группы поровну.

В этом примере для простоты мы будем использовать 3 корзины, но, скорее всего, вы обнаружите, что при использовании этой реализации в реальном приложении большее количество корзин приведет к более быстрым результатам — более неглубокое дерево означает в среднем меньшее количество запросов к сборщику мусора и меньше конфликтов блокировок.

Ранг игрока определяется суммой количества игроков с более высокими баллами плюс единица, полученная самим игроком. Каждая коллекция в разделе « scores будет хранить три документа, каждый из которых содержит диапазон, количество документов в каждом диапазоне, а затем три соответствующие подколлекции. Чтобы прочитать ранг, мы будем обходить это дерево в поисках балла и отслеживать сумму более высоких баллов. Когда мы найдем свой балл, мы также получим правильную сумму.

Процесс записи значительно усложняется. Во-первых, нам потребуется выполнять все операции записи в рамках одной транзакции, чтобы предотвратить несогласованность данных при одновременном выполнении нескольких операций записи или чтения. Также нам необходимо будет соблюдать все описанные выше условия при обходе дерева для записи новых документов. И, наконец, поскольку вся сложность этого нового подхода, связанная с деревом, сочетается с необходимостью хранения всех исходных документов, стоимость хранения немного возрастет (но она по-прежнему будет линейной).

В файле 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,
      });
    });
  });
}

Это, безусловно, сложнее, чем наша предыдущая реализация, которая состояла всего из одного вызова метода и шести строк кода. После реализации этого метода попробуйте добавить несколько оценок в базу данных и понаблюдайте за структурой получившегося дерева. В консоли JavaScript:

leaderboard.addScores();

В результате структура базы данных должна выглядеть примерно так: древовидная структура должна быть четко видна, а листья дерева должны представлять индивидуальные оценки.

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

Теперь, когда самая сложная часть работы выполнена, мы можем считывать результаты, обходя дерево, как описано ранее.

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

Обновления оставлены в качестве дополнительного упражнения. Попробуйте добавить и получить результаты в консоли JavaScript с помощью методов leaderboard.addScore(id, score) и leaderboard.getRank(id) и посмотрите, как изменится ваша таблица лидеров в консоли Firebase.

Однако при такой реализации добавленная нами сложность для достижения логарифмической производительности имеет свою цену.

  • Во-первых, такая реализация таблицы лидеров может столкнуться с проблемами конкуренции за блокировки, поскольку транзакции требуют блокировки операций чтения и записи документов для обеспечения их согласованности.
  • Во-вторых, Firestore устанавливает ограничение на глубину подколлекций в 100 элементов , что означает, что вам нужно избегать создания поддеревьев после 100 одинаковых значений, чего в данной реализации не предусмотрено.
  • И наконец, эта таблица лидеров масштабируется логарифмически только в идеальном случае, когда дерево сбалансировано — если оно несбалансировано, то в худшем случае производительность этой таблицы лидеров снова будет линейной.

После завершения удалите коллекции scores и players через консоль Firebase, и мы перейдем к последней реализации таблицы лидеров.

6. Реализуйте стохастическую (вероятностную) таблицу лидеров.

При выполнении кода вставки вы можете заметить, что если запускать его слишком много раз параллельно, ваши функции начнут выдавать ошибку, связанную с конфликтом блокировок транзакций. Существуют способы обойти это, которые мы не будем рассматривать в этом практическом занятии, но если вам не нужен точный рейтинг, вы можете отказаться от всех сложностей предыдущего подхода в пользу более простого и быстрого решения. Давайте посмотрим, как мы можем возвращать приблизительный рейтинг для результатов наших игроков вместо точного рейтинга, и как это меняет логику работы с базой данных.

В этом подходе мы разделим нашу таблицу лидеров на 100 групп, каждая из которых будет представлять примерно один процент от ожидаемых нами результатов. Этот подход работает даже без знания распределения результатов, в этом случае мы не можем гарантировать приблизительно равномерное распределение результатов по группам, но мы добьемся большей точности в наших приближениях, если будем знать, как будут распределены наши результаты.

Наш подход следующий: как и прежде, каждый сегмент хранит количество оценок внутри него и диапазон оценок. При добавлении новой оценки мы находим сегмент для этой оценки и увеличиваем его счетчик. При получении ранга мы просто суммируем значения сегментов, находящихся выше него, а затем приблизительно вычисляем значения внутри нашего сегмента, вместо того чтобы искать дальше. Это обеспечивает очень удобный поиск и вставку за постоянное время и требует значительно меньше кода.

Во-первых, вставка:

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

Обратите внимание, что в этом коде вставки вверху содержится логика инициализации состояния базы данных с предупреждением о том, что подобные действия недопустимы в производственной среде. Код инициализации совершенно не защищен от состояний гонки, поэтому, если вы попытаетесь это сделать, множественные одновременные операции записи приведут к повреждению базы данных из-за появления множества дубликатов сегментов.

Разверните свои функции, а затем выполните вставку, чтобы инициализировать все сегменты нулевым значением. В результате вы получите ошибку, которую можно смело игнорировать.

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

Теперь, когда база данных корректно инициализирована, мы можем запустить addScores и увидеть структуру наших данных в консоли Firebase. Полученная структура гораздо более плоская, чем в нашей предыдущей реализации, хотя внешне они похожи.

leaderboard.addScores();

А теперь перейдём к чтению результатов:

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

Поскольку мы настроили функцию addScores на создание равномерного распределения оценок и используем линейную интерполяцию внутри интервалов, мы получим очень точные результаты, производительность нашей таблицы лидеров не будет снижаться с увеличением числа пользователей, и нам не придется (в значительной степени) беспокоиться о конфликтах блокировок при обновлении подсчетов.

7. Дополнение: Мошенничество

Подождите, вы, возможно, подумаете: если я записываю значения в свою практическую работу через консоль JavaScript во вкладке браузера, разве кто-нибудь из моих игроков не может просто солгать таблице лидеров и сказать, что он получил высокий балл, которого добился нечестным путем?

Да, могут. Если вы хотите предотвратить мошенничество, наиболее надежный способ сделать это — отключить запись данных клиентом в вашу базу данных с помощью правил безопасности , защитить доступ к вашим облачным функциям, чтобы клиенты не могли вызывать их напрямую, а затем проверять внутриигровые действия на вашем сервере, прежде чем отправлять обновления результатов в таблицу лидеров.

Важно отметить, что эта стратегия не является панацеей от мошенничества — при достаточно сильном стимуле мошенники могут найти способы обойти серверные проверки, и многие крупные и успешные видеоигры постоянно играют в кошки-мышки со своими мошенниками, выявляя новые способы мошенничества и предотвращая их распространение. Сложным следствием этого явления является то, что серверная проверка для каждой игры по своей сути является индивидуальной; хотя Firebase предоставляет инструменты защиты от злоупотреблений, такие как App Check, которые предотвратят копирование вашей игры пользователем с помощью простого скриптового клиента, Firebase не предоставляет никакой услуги, которая представляла бы собой целостную систему защиты от мошенничества.

Любая проверка, кроме серверной, в достаточно популярной игре или при достаточно низком пороге мошенничества приведет к тому, что в таблице лидеров окажутся только читеры.

8. Поздравляем!

Поздравляем, вы успешно создали четыре разные таблицы лидеров на Firebase! В зависимости от требований вашей игры к точности и скорости, вы сможете выбрать ту, которая вам подходит, по разумной цене.

Далее, ознакомьтесь с программами обучения для разработчиков игр.