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 rumit: membaca peringkat untuk skor tertentu memerlukan pengetahuan tentang semua skor lainnya dalam suatu urutan. Selain itu, jika game Anda lepas landas, papan peringkat akan bertambah besar dan sering dibaca dan ditulisi. Agar berhasil membuat papan peringkat, papan peringkat harus mampu menangani operasi peringkat ini dengan cepat.
Yang akan Anda build
Dalam codelab ini, Anda akan menerapkan berbagai papan peringkat, 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 data sederhana untuk menentukan peringkat
- Papan peringkat yang murah dan diperbarui secara berkala
- Papan peringkat real-time dengan beberapa nonsense 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 tinggi (jalankan
nvm --version
untuk melihat nomor versi jika Anda menggunakan nvm) - Paket Firebase Blaze berbayar (opsional)
- Firebase CLI v11.16.0 atau yang lebih baru
Untuk menginstal CLI, Anda dapat menjalankannpm install -g firebase-tools
atau membaca dokumentasi CLI untuk mengetahui 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 dapat menggunakannya.
dan ekstrak file zip yang didownload.
Atau, clone ke direktori pilihan Anda:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
Apa yang menjadi titik awal?
Saat ini, project kita masih kosong dengan beberapa fungsi kosong:
index.html
berisi beberapa skrip glue yang memungkinkan kita memanggil fungsi dari konsol developer 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 dari game Anda secara langsung—kita tidak menggunakan game dalam codelab ini karena akan memakan waktu 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 bantuan 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 pengaruh pilihan implementasi terhadap 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. Jika ingin, Anda dapat mengklik ikon edit pada project ID untuk menyesuaikannya lebih lanjut. - Jika diminta, tinjau dan setujui persyaratan Firebase.
- Klik Lanjutkan.
- Pilih opsi Aktifkan Google Analytics untuk project ini, lalu klik Lanjutkan.
- Pilih akun Google Analytics yang ada untuk digunakan atau pilih Buat akun baru untuk membuat akun baru.
- Klik Create project.
- Setelah project dibuat, klik Continue.
- 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 Anda
- 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 di Chrome DevTools dan impor
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- Jalankan
leaderboard.codelab();
di konsol. Jika melihat pesan selamat datang, artinya Anda sudah siap! Jika tidak, matikan emulator dan jalankan kembali langkah 2-4.
Mari kita langsung ke implementasi papan peringkat pertama.
3. Mengimplementasikan papan peringkat sederhana
Di akhir bagian ini, kita akan dapat menambahkan skor ke papan peringkat dan memintanya untuk memberitahukan peringkat kita.
Sebelum memulai, 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 skalabel:
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 coba! Deploy fungsi Anda dengan menjalankan perintah berikut di terminal:
firebase deploy --only functions
Kemudian, di konsol JS Chrome, tambahkan beberapa skor lain agar 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 ke dalam campuran:
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". Apakah Anda melihat error? Buka log Functions melalui Firebase console untuk melihat masalahnya.
Dan, akhirnya, 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 linear dan memori yang tidak diinginkan untuk mengambil peringkat skor tertentu. Karena waktu eksekusi fungsi dan memori terbatas, hal ini tidak hanya akan membuat pengambilan menjadi semakin lambat, tetapi setelah skor yang cukup ditambahkan ke papan peringkat, fungsi kita akan habis waktu tunggunya atau mengalami error sebelum dapat menampilkan hasil. Jelas, kita akan membutuhkan sesuatu yang lebih baik jika kita ingin meningkatkan skala pemain dengan jumlah yang lebih dari segelintir.
Jika Anda adalah penggemar Firestore, Anda mungkin mengetahui kueri agregasi COUNT, yang akan membuat papan peringkat ini jauh lebih berperforma tinggi. Dan Anda benar! Dengan kueri COUNT, skalanya jauh di bawah satu juta pengguna, meskipun performanya masih linear.
Tapi tunggu dulu, Anda mungkin berpikir, jika kita akan menghitung semua dokumen dalam koleksi, kita bisa menetapkan peringkat pada setiap dokumen. Kemudian ketika kita perlu mengambilnya, pengambilan kita akan menjadi O(1) waktu dan memori! Hal ini akan membawa kita ke pendekatan berikutnya, yaitu papan peringkat yang diperbarui secara berkala.
4. Mengimplementasikan papan peringkat yang diperbarui secara berkala
Kunci dari pendekatan ini adalah menyimpan peringkat dalam dokumen itu sendiri, jadi mengambilnya akan memberi kita peringkat tanpa pekerjaan tambahan. Untuk mencapai ini, kita memerlukan fungsi jenis 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 semuanya bagus dan sederhana. Tulis dan perbarui tidak berubah, tetapi baca menjadi (di 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 dapat men-deploy dan mengujinya tanpa menambahkan akun penagihan ke project Anda. Jika Anda memiliki akun penagihan, perpendek interval pada fungsi yang dijadwalkan 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 kumpulan skor untuk mempersiapkan bagian berikutnya.
5. Mengimplementasikan papan peringkat pohon real-time
Pendekatan ini bekerja dengan menyimpan data pencarian di dalam koleksi {i>database<i} itu sendiri. Alih-alih memiliki koleksi yang seragam, tujuan kami adalah menyimpan semuanya di pohon yang dapat kami jelajahi dengan berpindah di antara dokumen. Hal ini memungkinkan kita melakukan penelusuran biner (atau n-ary) untuk peringkat skor tertentu. Seperti apa tampilannya?
Untuk memulainya, kita perlu mendistribusikan skor ke dalam kategori yang hampir sama, yang akan memerlukan pengetahuan tentang nilai skor yang dicatat oleh pengguna kita; misalnya, jika Anda membuat papan peringkat untuk peringkat keterampilan dalam game kompetitif, peringkat keterampilan hampir selalu
terdistribusi secara normal. Fungsi penghasil skor acak kita menggunakan Math.random()
JavaScript, yang menghasilkan distribusi yang kurang lebih merata, sehingga kita akan membagi bucket secara merata.
Dalam contoh ini, kita akan menggunakan 3 bucket agar lebih praktis, tetapi Anda mungkin akan mendapati bahwa jika implementasi ini digunakan dalam aplikasi sebenarnya, lebih banyak bucket akan memberikan hasil yang lebih cepat–pohon dangkal berarti rata-rata lebih sedikit pengambilan koleksi dan pertentangan kunci akan lebih sedikit.
Peringkat pemain ditentukan oleh jumlah pemain dengan skor lebih tinggi, ditambah satu untuk pemain itu sendiri. Setiap koleksi di bagian scores
akan menyimpan tiga dokumen, masing-masing dengan rentang, jumlah dokumen dalam setiap rentang, lalu tiga subkoleksi yang sesuai. Untuk membaca peringkat, kita akan melewati pohon ini untuk mencari skor dan melacak jumlah skor yang lebih besar. Saat kita menemukan skor, kita juga akan memiliki jumlah yang benar.
Menulis jauh lebih rumit. Pertama, kita harus membuat semua operasi tulis dalam transaksi untuk mencegah inkonsistensi data saat beberapa operasi tulis atau baca terjadi secara bersamaan. Kita juga harus mempertahankan semua kondisi yang telah dijelaskan di atas saat menjelajahi hierarki untuk menulis dokumen baru. Terakhir, karena kita memiliki semua kompleksitas hierarki dari pendekatan baru ini yang dikombinasikan dengan kebutuhan untuk menyimpan semua dokumen asli, biaya penyimpanan kita akan sedikit meningkat (tetapi masih bersifat 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,
});
});
});
}
Ini tentu lebih rumit daripada penerapan terakhir kita, yang merupakan satu panggilan metode dan hanya enam baris kode. Setelah Anda 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 pohon terlihat jelas dan daun pohon mewakili skor individual.
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
Setelah menyelesaikan bagian yang sulit, kita dapat membaca skor dengan menelusuri 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 dengan metode leaderboard.addScore(id, score)
dan leaderboard.getRank(id)
, lalu lihat perubahan papan peringkat Anda di Firebase console.
Namun, dengan implementasi ini, kerumitan yang telah kita tambahkan untuk mencapai performa logaritma akan menimbulkan biaya.
- Pertama, penerapan papan peringkat ini dapat mengalami masalah pertentangan kunci, karena transaksi memerlukan penguncian pembacaan dan penulisan ke dokumen untuk memastikannya tetap konsisten.
- Kedua, Firestore memberlakukan batas kedalaman subkoleksi sebesar 100, yang berarti Anda harus menghindari pembuatan sub-pohon setelah 100 skor yang sama, yang tidak dilakukan oleh implementasi ini.
- Dan terakhir, papan peringkat ini diskalakan secara logis hanya dalam kasus ideal ketika pohon itu seimbang–jika tidak seimbang, performa kasus terburuk dari papan peringkat ini sekali lagi bersifat linier.
Setelah selesai, hapus koleksi scores
dan players
melalui Firebase console dan 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 sering secara paralel, fungsi Anda akan mulai gagal dengan pesan error yang terkait dengan pertentangan kunci transaksi. Ada beberapa cara seputar hal ini yang tidak akan kita pelajari dalam codelab ini, tetapi jika Anda tidak memerlukan peringkat yang tepat, Anda dapat menghilangkan semua kerumitan pendekatan sebelumnya untuk sesuatu yang lebih sederhana dan lebih cepat. Mari kita lihat bagaimana kita bisa mengembalikan perkiraan peringkat untuk skor alih-alih peringkat yang tepat, dan bagaimana hal itu mengubah logika {i>database<i} kita.
Untuk pendekatan ini, kita akan membagi papan peringkat menjadi 100 bucket, yang masing-masing mewakili sekitar satu persen skor yang diperkirakan akan kita terima. Pendekatan ini berfungsi bahkan tanpa mengetahui distribusi skor, sehingga tidak ada cara untuk menjamin distribusi skor yang merata di seluruh bucket, tetapi kami akan mencapai presisi yang lebih tinggi dalam perkiraan jika mengetahui bagaimana skor akan didistribusikan.
Pendekatan kita adalah sebagai berikut: seperti sebelumnya, setiap kotak menyimpan jumlah jumlah skor di dalamnya dan rentang skornya. Saat menyisipkan skor baru, kita akan menemukan bucket untuk skor dan menambah jumlahnya. Saat mengambil peringkat, kita hanya akan menjumlahkan bucket di depannya, lalu memperkirakan dalam bucket, bukan menelusuri lebih lanjut. Ini memberi kita pencarian dan penyisipan waktu konstan yang sangat bagus, dan membutuhkan kode yang jauh lebih sedikit.
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 kode penyisipan ini memiliki beberapa logika untuk melakukan inisialisasi status database di bagian atas dengan peringatan untuk tidak melakukan hal seperti ini dalam produksi. Kode untuk inisialisasi tidak terlindungi sama sekali dari kondisi race, jadi jika Anda melakukannya, beberapa penulisan serentak akan merusak database dengan memberi Anda banyak bucket duplikat.
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 mirip.
leaderboard.addScores();
Dan, 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 menggunakan interpolasi linier dalam bucket, kita akan mendapatkan hasil yang sangat akurat, performa papan peringkat tidak akan menurun saat kita meningkatkan jumlah pengguna, dan kita tidak perlu mengkhawatirkan pertentangan kunci (terlalu khawatir) saat memperbarui jumlah.
7. Adendum: Kecurangan
Tunggu, Anda mungkin berpikir, jika saya menulis nilai ke codelab melalui konsol JS di tab browser, tidak bisakah pemain saya berbohong pada papan peringkat dan mengatakan bahwa mereka mendapat skor tinggi yang tidak dicapai dengan adil?
Ya, tentu saja. Jika Anda ingin mencegah kecurangan, cara paling ampuh untuk melakukannya adalah dengan menonaktifkan penulisan klien ke database Anda melalui aturan keamanan dan memberikan akses aman ke Cloud Functions Anda agar 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 melakukan kecurangan–dengan insentif yang cukup besar, pelaku curang dapat menemukan cara untuk mengakali validasi sisi server, dan banyak video game besar yang sukses terus bermain kucing dan tikus dengan penipu untuk mengidentifikasi penipuan baru dan mencegahnya berkembang biak. Konsekuensi yang sulit dari fenomena ini adalah validasi sisi server untuk setiap game pada dasarnya bersifat khusus; meskipun Firebase menyediakan alat anti-penyalahgunaan seperti App Check yang akan mencegah pengguna menyalin game Anda melalui klien dengan skrip sederhana, Firebase tidak menyediakan layanan apa pun yang setara dengan anti-cheat menyeluruh.
Jika tidak ada validasi sisi server, untuk game yang cukup populer atau memiliki hambatan yang cukup rendah untuk melakukan kecurangan, papan peringkat akan berisi nilai teratas yang semuanya adalah penipu.
8. Selamat
Selamat, Anda telah berhasil membuat empat papan peringkat berbeda di Firebase. Bergantung pada kebutuhan ketepatan dan kecepatan game Anda, Anda dapat memilih game yang cocok untuk Anda dengan biaya yang wajar.
Berikutnya, lihat jalur pembelajaran untuk game.