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

1. Введение

Последнее обновление: 27 января 2023 г.

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

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

Что ты построишь

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

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

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

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

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

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

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

  1. В консоли Firebase нажмите «Добавить проект» .
  2. Чтобы создать новый проект, введите желаемое имя проекта.
    При этом для идентификатора проекта (отображаемого под именем проекта) будет установлено значение, основанное на имени проекта. При желании вы можете щелкнуть значок редактирования на идентификаторе проекта, чтобы дополнительно настроить его.
  3. При появлении запроса прочтите и примите условия Firebase .
  4. Нажмите Продолжить .
  5. Выберите параметр «Включить Google Analytics для этого проекта» и нажмите «Продолжить» .
  6. Выберите существующую учетную запись Google Analytics для использования или выберите Создать новую учетную запись , чтобы создать новую учетную запись.
  7. Нажмите Создать проект .
  8. Когда проект будет создан, нажмите «Продолжить» .
  9. В меню «Сборка» нажмите «Функции » и при появлении запроса обновите проект, чтобы использовать план выставления счетов Blaze.
  10. В меню «Создать» выберите «База данных Firestore» .
  11. В появившемся диалоговом окне «Создать базу данных» выберите «Начать в тестовом режиме» , затем нажмите «Далее» .
  12. Выберите регион в раскрывающемся списке «Местоположение Cloud Firestore» , затем нажмите «Включить» .

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

  1. В терминале перейдите в корень проекта и запустите firebase use --add . Выберите проект Firebase, который вы только что создали.
  2. В корне проекта запустите firebase emulators:start --only hosting .
  3. В браузере перейдите по адресу localhost:5000 .
  4. Откройте консоль JavaScript Chrome DevTools и импортируйте 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

А затем в JS-консоли 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, нажав на 3 точки рядом с коллекцией результатов, чтобы подготовиться к следующему разделу.

Firestore scores document page with\nDelete Collection activated

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

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

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

В этом примере для простоты мы будем использовать 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,
      });
    });
  });
}

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

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

Обновления оставлены в качестве дополнительного упражнения. Попробуйте добавить и получить результаты в консоли JS с помощью методов 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. Приложение: Обман

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

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

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

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

8. Поздравления

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

Далее рассмотрим способы обучения играм.