1. Введение
Последнее обновление: 27.01.2023
Что нужно для создания таблицы лидеров?
По сути, таблицы лидеров — это просто таблицы с результатами, но есть один осложняющий фактор: для определения рейтинга любого результата требуется знание всех остальных результатов в определённом порядке. Кроме того, по мере роста популярности игры ваши таблицы лидеров будут разрастаться, и их придётся часто читать и вносить изменения. Чтобы создать успешную таблицу лидеров, она должна уметь быстро обрабатывать эту операцию.
Что вы построите
В этой лабораторной работе вы реализуете различные таблицы лидеров, каждая из которых подходит для определенного сценария.
Чему вы научитесь
Вы узнаете, как реализовать четыре различные таблицы лидеров:
- Наивная реализация, использующая простой подсчет записей для определения ранга
- Дешевая, периодически обновляемая таблица лидеров
- Таблица лидеров в реальном времени с какой-то ерундой с деревьями
- Стохастическая (вероятностная) таблица лидеров для приблизительного ранжирования очень больших баз игроков
Что вам понадобится
- Последняя версия Chrome (107 или более поздняя)
- Node.js 16 или выше (запустите
nvm --version
, чтобы увидеть номер версии, если вы используете nvm) - Платный план Firebase Blaze (необязательно)
- Firebase CLI v11.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
- Войдите в консоль Firebase, используя свою учетную запись Google.
- Нажмите кнопку, чтобы создать новый проект, а затем введите название проекта (например,
Leaderboards Codelab
). - Нажмите «Продолжить» .
- При появлении соответствующего запроса ознакомьтесь с условиями Firebase и примите их, а затем нажмите кнопку «Продолжить» .
- (Необязательно) Включите помощь ИИ в консоли Firebase (так называемая «Gemini в Firebase»).
- Для этой лабораторной работы вам не понадобится Google Analytics, поэтому отключите опцию Google Analytics.
- Нажмите «Создать проект» , дождитесь завершения подготовки проекта, а затем нажмите «Продолжить» .
Настройка продуктов Firebase
- В меню «Сборка» выберите «Функции» и при появлении соответствующего запроса обновите свой проект, чтобы использовать тарифный план Blaze.
- В меню «Сборка» выберите пункт «База данных Firestore» .
- В открывшемся диалоговом окне «Создание базы данных» выберите «Запустить в тестовом режиме» , затем нажмите «Далее» .
- Выберите регион из раскрывающегося списка «Расположение Cloud Firestore» , затем нажмите « Включить» .
Настройте и управляйте своей таблицей лидеров
- В терминале перейдите в корень проекта и выполните
firebase use --add
. Выберите только что созданный вами проект Firebase. - В корне проекта запустите
firebase emulators:start --only hosting
. - В браузере перейдите по адресу
localhost:5000
. - Откройте консоль JavaScript в Chrome DevTools и импортируйте
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- Запустите
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.
После завершения записи в консоли должен появиться ответ «Score created». Видите ошибку? Откройте логи функций через консоль 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, нажав на три точки рядом с коллекцией результатов, чтобы подготовиться к следующему разделу.
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,
});
});
});
}
Это, безусловно, сложнее, чем наша предыдущая реализация, которая состояла из одного вызова метода и всего шести строк кода. После реализации этого метода попробуйте добавить несколько оценок в базу данных и понаблюдать за структурой получившегося дерева. В консоли 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. Приложение: Обман
Постойте, вы, наверное, думаете: если я записываю значения в свою кодовую лабу через консоль JS вкладки браузера, не может ли кто-нибудь из моих игроков просто солгать таблице лидеров и сказать, что они получили высокий результат, которого они добились нечестно?
Да, могут. Если вы хотите предотвратить читерство, самый надёжный способ — запретить клиентским командам запись в базу данных с помощью правил безопасности , защитить доступ к облачным функциям, чтобы клиенты не могли вызывать их напрямую, а затем проверять игровые действия на сервере перед отправкой обновлений счёта в таблицу лидеров.
Важно отметить, что эта стратегия не является панацеей от читерства: при достаточно большом стимуле мошенники могут найти способы обойти серверную проверку, и многие крупные и успешные видеоигры постоянно играют в кошки-мышки со своими читерами, чтобы выявлять новые читы и препятствовать их распространению. Неприятным следствием этого явления является то, что серверная проверка для каждой игры изначально индивидуальна. Хотя Firebase предоставляет инструменты для борьбы с мошенничеством, такие как App Check, которые не позволят пользователю скопировать вашу игру через простой скриптовый клиент, Firebase не предоставляет никаких услуг, которые можно было бы считать комплексной античит-системой.
Любое отсутствие проверки на стороне сервера, если игра достаточно популярна или барьер для мошенничества достаточно низок, приведет к созданию таблицы лидеров, где все верхние значения будут принадлежать читерам.
8. Поздравления
Поздравляем! Вы успешно создали четыре разные таблицы лидеров на Firebase! В зависимости от точности и скорости вашей игры, вы сможете выбрать подходящую по разумной цене.
Далее изучите пути обучения с помощью игр.