1. Pengantar
Terakhir Diperbarui: 27-01-2023
Apa yang diperlukan untuk membuat papan peringkat?
Pada dasarnya, papan peringkat hanyalah tabel skor dengan satu faktor yang menyulitkan: membaca peringkat untuk skor tertentu memerlukan pengetahuan tentang semua skor lainnya dalam urutan tertentu. Selain itu, jika game Anda populer, papan peringkat Anda akan menjadi besar dan sering dibaca serta ditulis. Untuk membuat papan peringkat yang sukses, papan peringkat harus dapat menangani operasi peringkat ini dengan cepat.
Yang akan Anda build
Dalam codelab ini, Anda akan menerapkan berbagai papan peringkat yang berbeda, yang masing-masing cocok untuk skenario yang berbeda.
Yang akan Anda pelajari
Anda akan mempelajari cara menerapkan empat papan peringkat yang berbeda:
- Implementasi sederhana menggunakan penghitungan rekaman sederhana untuk menentukan peringkat
- Papan peringkat murah yang diperbarui secara berkala
- Papan peringkat real-time dengan beberapa omong kosong tentang pohon
- Papan peringkat stokastik (probabilistik) untuk perkiraan peringkat basis pemain yang sangat besar
Yang Anda butuhkan
- Chrome versi terbaru (107 atau yang lebih baru)
- Node.js 16 atau yang lebih baru (jalankan
nvm --version
untuk melihat nomor versi Anda jika Anda menggunakan nvm) - Paket Firebase Blaze berbayar (opsional)
- Firebase CLI v11.16.0 atau yang lebih tinggi
Untuk menginstal CLI, Anda dapat menjalankannpm install -g firebase-tools
atau melihat dokumentasi CLI untuk opsi penginstalan lainnya. - Pengetahuan tentang JavaScript, Cloud Firestore, Cloud Functions, dan Chrome DevTools
2. Mempersiapkan
Mendapatkan kode
Kami telah memasukkan semua yang Anda perlukan untuk project ini ke dalam repo Git. Untuk memulai, Anda harus mengambil kode dan membukanya di lingkungan pengembangan favorit Anda. Untuk codelab ini, kita menggunakan VS Code, tetapi editor teks apa pun bisa digunakan.
dan ekstrak file zip yang didownload.
Atau, clone ke direktori pilihan Anda:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
Apa titik awalnya?
Project kita saat ini adalah kanvas kosong dengan beberapa fungsi kosong:
index.html
berisi beberapa skrip penggabungan yang memungkinkan kita memanggil fungsi dari konsol dev dan melihat outputnya. Kita akan menggunakannya untuk berinteraksi dengan backend dan melihat hasil pemanggilan fungsi. Dalam skenario dunia nyata, Anda akan melakukan panggilan backend ini langsung dari game—kami tidak menggunakan game dalam codelab ini karena akan terlalu lama untuk memainkan game setiap kali Anda ingin menambahkan skor ke papan peringkat.functions/index.js
berisi semua Cloud Functions kita. Anda akan melihat beberapa fungsi utilitas, sepertiaddScores
dandeleteScores
, serta fungsi yang akan kita implementasikan dalam codelab ini, yang memanggil fungsi helper di file lain.functions/functions-helpers.js
berisi fungsi kosong yang akan kita implementasikan. Untuk setiap papan peringkat, kita akan menerapkan fungsi baca, buat, dan perbarui, dan Anda akan melihat bagaimana pilihan implementasi kita memengaruhi kompleksitas implementasi dan performa penskalaannya.functions/utils.js
berisi lebih banyak fungsi utilitas. Kita tidak akan menyentuh file ini dalam codelab ini.
Membuat dan mengonfigurasi project Firebase
- Di Firebase console, klik Add project.
- Untuk membuat project baru, masukkan nama project yang diinginkan.
Tindakan ini juga akan menetapkan project ID (yang ditampilkan di bawah nama project) ke sesuatu berdasarkan nama project. Anda dapat mengklik ikon edit pada project ID untuk menyesuaikannya lebih lanjut. - Jika diminta, tinjau dan setujui persyaratan Firebase.
- Klik Lanjutkan.
- Pilih opsi Enable Google Analytics for this project, lalu klik Continue.
- Pilih akun Google Analytics yang ada untuk digunakan atau pilih Buat akun baru untuk membuat akun baru.
- Klik Create project.
- Setelah project dibuat, klik Lanjutkan.
- Dari menu Build, klik Functions, dan jika diminta, upgrade project Anda untuk menggunakan paket penagihan Blaze.
- Dari menu Build, klik Firestore database.
- Pada dialog Create database yang muncul, pilih Start in test mode, lalu klik Next.
- Pilih region dari drop-down Lokasi Cloud Firestore, lalu klik Aktifkan.
Mengonfigurasi dan menjalankan papan peringkat
- Di terminal, buka root project dan jalankan
firebase use --add
. Pilih project Firebase yang baru saja Anda buat. - Di root project, jalankan
firebase emulators:start --only hosting
. - Di browser Anda, buka
localhost:5000
. - Buka konsol JavaScript Chrome DevTools dan impor
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- Jalankan
leaderboard.codelab();
di konsol. Jika Anda melihat pesan selamat datang, berarti Anda sudah siap. Jika tidak, tutup emulator dan jalankan kembali langkah 2-4.
Mari kita mulai implementasi papan peringkat pertama.
3. Menerapkan papan peringkat sederhana
Di akhir bagian ini, kita akan dapat menambahkan skor ke papan peringkat dan mengetahui peringkat kita.
Sebelum kita mulai, mari kita jelaskan cara kerja penerapan papan peringkat ini: Semua pemain disimpan dalam satu koleksi, dan pengambilan peringkat pemain dilakukan dengan mengambil koleksi dan menghitung jumlah pemain yang berada di atasnya. Hal ini memudahkan penyisipan dan pembaruan skor. Untuk menyisipkan skor baru, kita cukup menambahkannya ke koleksi, dan untuk memperbaruinya, kita memfilter pengguna saat ini, lalu memperbarui dokumen yang dihasilkan. Mari kita lihat tampilannya dalam kode.
Di functions/functions-helper.js
, terapkan fungsi createScore
, yang sangat mudah:
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
Untuk memperbarui skor, kita hanya perlu menambahkan pemeriksaan error untuk memastikan skor yang diperbarui sudah ada:
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,
});
}
Terakhir, fungsi peringkat kami yang sederhana tetapi kurang dapat diskalakan:
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}`);
}
Mari kita uji! Deploy fungsi Anda dengan menjalankan perintah berikut di terminal:
firebase deploy --only functions
Kemudian, di konsol JS Chrome, tambahkan beberapa skor lain sehingga kita dapat melihat peringkat kita di antara pemain lain.
leaderboard.addScores(); // Results may take some time to appear.
Sekarang kita dapat menambahkan skor kita sendiri:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
Setelah penulisan selesai, Anda akan melihat respons di konsol yang bertuliskan "Skor dibuat". Melihat error? Buka log Functions melalui Firebase console untuk melihat apa yang salah.
Terakhir, kita dapat mengambil dan memperbarui skor.
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
Namun, implementasi ini memberi kita persyaratan waktu dan memori linier yang tidak diinginkan untuk mengambil peringkat skor tertentu. Karena waktu dan memori eksekusi fungsi dibatasi, tidak hanya pengambilan data kita akan semakin lambat, tetapi setelah cukup banyak skor ditambahkan ke papan peringkat, fungsi kita akan kehabisan waktu atau error sebelum dapat menampilkan hasil. Jelas, kita akan memerlukan sesuatu yang lebih baik jika ingin menskalakan lebih dari segelintir pemain.
Jika Anda penggemar Firestore, Anda mungkin mengetahui kueri agregasi COUNT, yang akan membuat papan peringkat ini berperforma jauh lebih baik. Dan Anda benar! Dengan kueri COUNT, hal ini dapat diskalakan dengan baik di bawah sekitar satu juta pengguna, meskipun performanya masih linear.
Namun, Anda mungkin berpikir, jika kita akan menghitung semua dokumen dalam koleksi, kita dapat menetapkan peringkat setiap dokumen dan kemudian saat kita perlu mengambilnya, pengambilan kita akan menjadi O(1) waktu dan memori. Hal ini mengarahkan kita ke pendekatan berikutnya, papan peringkat yang diperbarui secara berkala.
4. Menerapkan papan peringkat yang diperbarui secara berkala
Kunci pendekatan ini adalah menyimpan peringkat dalam dokumen itu sendiri, sehingga pengambilan peringkat tidak memerlukan pekerjaan tambahan. Untuk mencapainya, kita memerlukan jenis fungsi baru.
Di index.js
, tambahkan kode berikut:
// 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;
});
Sekarang, operasi baca, update, dan tulis kita sudah bagus dan sederhana. Penulisan dan pembaruan tidak berubah, tetapi pembacaan menjadi (dalam 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"),
};
}
Sayangnya, Anda tidak akan dapat men-deploy dan menguji ini tanpa menambahkan akun penagihan ke project Anda. Jika Anda memiliki akun penagihan, perpendek interval pada fungsi terjadwal dan lihat fungsi Anda secara ajaib menetapkan peringkat ke skor papan peringkat Anda.
Jika tidak, hapus fungsi terjadwal dan lanjutkan ke penerapan berikutnya.
Lanjutkan dan hapus skor di database Firestore Anda dengan mengklik 3 titik di samping koleksi skor untuk bersiap ke bagian berikutnya.
5. Menerapkan papan peringkat pohon real-time
Pendekatan ini berfungsi dengan menyimpan data penelusuran di koleksi database itu sendiri. Daripada memiliki koleksi seragam, tujuan kita adalah menyimpan semuanya dalam hierarki yang dapat kita telusuri dengan berpindah melalui dokumen. Hal ini memungkinkan kita melakukan penelusuran biner (atau n-ary) untuk peringkat skor tertentu. Seperti apa bentuknya?
Untuk memulai, kita harus dapat mendistribusikan skor ke dalam bucket yang hampir sama, yang akan memerlukan pengetahuan tentang nilai skor yang dicatat pengguna; misalnya, jika Anda membuat papan peringkat untuk rating keterampilan dalam game kompetitif, rating keterampilan pengguna hampir selalu akan terdistribusi secara normal. Fungsi pembuatan skor acak kita menggunakan Math.random()
JavaScript, yang menghasilkan distribusi yang hampir merata, jadi kita akan membagi bucket secara merata.
Dalam contoh ini, kita akan menggunakan 3 bucket agar lebih sederhana, tetapi Anda mungkin akan mendapati bahwa jika Anda menggunakan implementasi ini di aplikasi sebenarnya, lebih banyak bucket akan menghasilkan hasil yang lebih cepat–pohon yang lebih dangkal berarti rata-rata lebih sedikit pengambilan koleksi dan lebih sedikit pertentangan kunci.
Peringkat pemain diberikan oleh jumlah pemain dengan skor yang lebih tinggi, ditambah satu untuk pemain itu sendiri. Setiap koleksi di scores
akan menyimpan tiga dokumen, yang masing-masing memiliki rentang, jumlah dokumen dalam setiap rentang, lalu tiga subkoleksi yang sesuai. Untuk membaca peringkat, kita akan menelusuri pohon ini untuk mencari skor dan melacak jumlah skor yang lebih besar. Saat menemukan skor, kita juga akan mendapatkan jumlah yang benar.
Penulisan jauh lebih rumit. Pertama, kita harus melakukan semua penulisan dalam transaksi untuk mencegah inkonsistensi data saat beberapa penulisan atau pembacaan terjadi secara bersamaan. Kita juga perlu mempertahankan semua kondisi yang telah dijelaskan di atas saat menjelajahi hierarki untuk menulis dokumen baru. Terakhir, karena kita memiliki semua kompleksitas struktur hierarki pendekatan baru ini yang dikombinasikan dengan kebutuhan untuk menyimpan semua dokumen asli, biaya penyimpanan kita akan meningkat sedikit (tetapi masih linear).
Di 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,
});
});
});
}
Hal ini tentu lebih rumit daripada penerapan terakhir kita, yang merupakan panggilan metode tunggal dan hanya enam baris kode. Setelah menerapkan metode ini, coba tambahkan beberapa skor ke database dan amati struktur pohon yang dihasilkan. Di konsol JS Anda:
leaderboard.addScores();
Struktur database yang dihasilkan akan terlihat seperti ini, dengan struktur hierarki yang terlihat jelas dan daun hierarki yang merepresentasikan skor individual.
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
Setelah bagian yang sulit selesai, kita dapat membaca skor dengan menjelajahi hierarki seperti yang dijelaskan sebelumnya.
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,
};
}
Pembaruan dibiarkan sebagai latihan tambahan. Coba tambahkan dan ambil skor di konsol JS Anda dengan metode leaderboard.addScore(id, score)
dan leaderboard.getRank(id)
, lalu lihat perubahan papan peringkat Anda di Firebase console.
Namun, dengan penerapan ini, kompleksitas yang telah kita tambahkan untuk mencapai performa logaritmik memiliki biaya.
- Pertama, penerapan papan peringkat ini dapat mengalami masalah persaingan kunci, karena transaksi memerlukan penguncian baca dan tulis ke dokumen untuk memastikan konsistensinya.
- Kedua, Firestore menerapkan batas kedalaman subkoleksi 100, yang berarti Anda harus menghindari pembuatan subpohon setelah 100 skor terikat, yang tidak dilakukan oleh implementasi ini.
- Terakhir, papan peringkat ini diskalakan secara logaritmik hanya dalam kasus ideal saat pohon seimbang–jika tidak seimbang, performa terburuk papan peringkat ini sekali lagi bersifat linear.
Setelah selesai, hapus koleksi scores
dan players
melalui Firebase console, lalu kita akan melanjutkan ke penerapan papan peringkat terakhir.
6. Menerapkan papan peringkat stokastik (probabilistik)
Saat menjalankan kode penyisipan, Anda mungkin melihat bahwa jika Anda menjalankannya terlalu banyak kali secara paralel, fungsi Anda akan mulai gagal dengan pesan error yang terkait dengan pertentangan penguncian transaksi. Ada cara untuk mengatasi hal ini yang tidak akan kita bahas dalam codelab ini, tetapi jika Anda tidak memerlukan peringkat yang tepat, Anda dapat menghilangkan semua kompleksitas pendekatan sebelumnya untuk sesuatu yang lebih sederhana dan lebih cepat. Mari kita lihat bagaimana cara menampilkan perkiraan peringkat untuk skor pemain, bukan peringkat yang tepat, dan bagaimana hal itu mengubah logika database kita.
Untuk pendekatan ini, kita akan membagi papan peringkat menjadi 100 kelompok, yang masing-masing mewakili sekitar satu persen skor yang akan kita terima. Pendekatan ini berfungsi bahkan tanpa mengetahui distribusi skor kita. Dalam hal ini, kita tidak dapat menjamin distribusi skor yang cukup merata di seluruh bucket, tetapi kita akan mencapai presisi yang lebih besar dalam perkiraan jika kita mengetahui cara skor kita akan didistribusikan.
Pendekatan kami adalah sebagai berikut: seperti sebelumnya, setiap bucket menyimpan jumlah skor di dalamnya dan rentang skor. Saat memasukkan skor baru, kita akan menemukan bucket untuk skor tersebut dan menambah jumlahnya. Saat mengambil peringkat, kami hanya akan menjumlahkan bucket di depannya, lalu memperkirakan dalam bucket kami, bukan menelusuri lebih lanjut. Hal ini memberi kita pencarian dan penyisipan waktu konstan yang sangat bagus, dan memerlukan lebih sedikit kode.
Pertama, penyisipan:
// 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();
}
}
}
Anda akan melihat bahwa kode penyisipan ini memiliki beberapa logika untuk menginisialisasi status database di bagian atas dengan peringatan untuk tidak melakukan hal seperti ini dalam produksi. Kode untuk inisialisasi tidak dilindungi sama sekali terhadap kondisi persaingan, jadi jika Anda melakukan ini, beberapa penulisan serentak akan merusak database Anda dengan memberi Anda banyak bucket duplikat.
Lanjutkan dengan men-deploy fungsi Anda, lalu jalankan penyisipan untuk menginisialisasi semua bucket dengan jumlah nol. Tindakan ini akan menampilkan error, yang dapat Anda abaikan dengan aman.
leaderboard.addScore(999, 0); // The params aren't important here.
Setelah database diinisialisasi dengan benar, kita dapat menjalankan addScores
dan melihat struktur data di Firebase console. Struktur yang dihasilkan jauh lebih datar daripada implementasi terakhir kita, meskipun secara dangkal serupa.
leaderboard.addScores();
Sekarang, untuk membaca skor:
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,
};
}
Karena kita telah membuat fungsi addScores
menghasilkan distribusi skor yang seragam dan kita menggunakan interpolasi linear dalam bucket, kita akan mendapatkan hasil yang sangat akurat, performa papan peringkat tidak akan menurun saat kita meningkatkan jumlah pengguna, dan kita tidak perlu khawatir tentang persaingan kunci (sebanyak itu) saat memperbarui jumlah.
7. Tambahan: Kecurangan
Tunggu dulu, Anda mungkin berpikir, jika saya menulis nilai ke codelab melalui konsol JS tab browser, bukankah pemain saya dapat berbohong kepada papan peringkat dan mengatakan bahwa mereka mendapatkan skor tinggi yang tidak mereka raih secara adil?
Ya, tentu saja. Jika Anda ingin mencegah kecurangan, cara paling efektif untuk melakukannya adalah dengan menonaktifkan penulisan klien ke database Anda melalui aturan keamanan, mengamankan akses ke Cloud Functions Anda sehingga klien tidak dapat memanggilnya secara langsung, lalu memvalidasi tindakan dalam game di server Anda sebelum mengirimkan pembaruan skor ke papan peringkat.
Penting untuk diperhatikan bahwa strategi ini bukanlah obat mujarab untuk mengatasi kecurangan. Dengan insentif yang cukup besar, pelaku kecurangan dapat menemukan cara untuk menghindari validasi sisi server, dan banyak video game besar yang sukses terus-menerus bermain kucing-kucingan dengan pelaku kecurangan untuk mengidentifikasi kecurangan baru dan menghentikan penyebarannya. Konsekuensi sulit dari fenomena ini adalah validasi sisi server untuk setiap game pada dasarnya dibuat khusus; meskipun Firebase menyediakan alat anti-penyalahgunaan seperti App Check yang akan mencegah pengguna menyalin game Anda melalui klien yang discript sederhana, Firebase tidak menyediakan layanan apa pun yang setara dengan anti-cheat holistik.
Jika tidak ada validasi sisi server, untuk game yang cukup populer atau penghalang kecurangan yang cukup rendah, papan peringkat akan dipenuhi oleh pemain curang.
8. Selamat
Selamat, Anda telah berhasil membuat empat papan peringkat yang berbeda di Firebase. Bergantung pada kebutuhan game Anda akan ketepatan dan kecepatan, Anda dapat memilih salah satu yang sesuai dengan biaya yang wajar.
Selanjutnya, lihat jalur pembelajaran untuk game.