Bangun papan peringkat dengan Firestore

1. Perkenalan

Terakhir Diperbarui: 27-01-2023

Apa yang diperlukan untuk membangun papan peringkat?

Pada intinya, papan peringkat hanyalah tabel skor dengan satu faktor rumit: membaca peringkat untuk skor tertentu memerlukan pengetahuan tentang semua skor lainnya dalam urutan tertentu. Selain itu, jika game Anda sukses, papan peringkat Anda akan bertambah besar dan sering dibaca serta ditulis. Untuk membangun papan peringkat yang sukses, perusahaan harus mampu menangani operasi pemeringkatan ini dengan cepat.

Apa yang akan Anda bangun

Dalam codelab ini, Anda akan menerapkan berbagai papan peringkat, yang masing-masing cocok untuk skenario berbeda.

Apa yang akan Anda pelajari

Anda akan mempelajari cara menerapkan empat papan peringkat yang berbeda:

  • Implementasi naif yang menggunakan penghitungan catatan sederhana untuk menentukan peringkat
  • Papan peringkat yang murah dan diperbarui secara berkala
  • Papan peringkat waktu nyata dengan beberapa hal yang tidak masuk akal
  • Papan peringkat stokastik (probabilistik) untuk perkiraan peringkat basis pemain yang sangat besar

Apa yang Anda perlukan

  • Chrome versi terbaru (107 atau lebih baru)
  • Node.js 16 atau lebih tinggi (jalankan nvm --version untuk melihat nomor versi Anda jika Anda menggunakan nvm)
  • Paket Firebase Blaze berbayar (opsional)
  • Firebase CLI v11.16.0 atau lebih tinggi
    Untuk menginstal CLI, Anda dapat menjalankan npm install -g firebase-tools atau lihat dokumentasi CLI untuk opsi instalasi lainnya.
  • Pengetahuan tentang JavaScript, Cloud Firestore, Cloud Functions, dan Chrome DevTools

2. Persiapan

Dapatkan kodenya

Kami telah memasukkan semua yang Anda perlukan untuk proyek ini ke dalam repo Git. Untuk memulai, Anda perlu mengambil kode dan membukanya di lingkungan pengembang favorit Anda. Untuk codelab ini, kami menggunakan VS Code, tetapi editor teks apa pun bisa melakukannya.

dan ekstrak file zip yang diunduh.

Atau, kloning ke direktori pilihan Anda:

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

Apa titik awal kita?

Proyek kami saat ini masih kosong dengan beberapa fungsi kosong:

  • index.html berisi beberapa skrip lem yang memungkinkan kita menjalankan fungsi dari konsol pengembang dan melihat hasilnya. Kami akan menggunakan ini untuk berinteraksi dengan backend kami dan melihat hasil pemanggilan fungsi kami. Dalam skenario dunia nyata, Anda akan melakukan panggilan backend ini dari game Anda secara langsung—kami 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 kami. Anda akan melihat beberapa fungsi utilitas, seperti addScores dan deleteScores , serta fungsi yang akan kita terapkan dalam codelab ini, yang memanggil fungsi pembantu di file lain.
  • functions/functions-helpers.js berisi fungsi kosong yang akan kita implementasikan. Untuk setiap papan peringkat, kami akan menerapkan fungsi baca, buat, dan perbarui, dan Anda akan melihat bagaimana pilihan penerapan kami memengaruhi kompleksitas penerapan dan performa penskalaannya.
  • functions/utils.js berisi lebih banyak fungsi utilitas. Kami tidak akan menyentuh file ini dalam codelab ini.

Membuat dan mengonfigurasi proyek Firebase

  1. Di Firebase console , klik Tambahkan proyek .
  2. Untuk membuat proyek baru, masukkan nama proyek yang diinginkan.
    Ini juga akan mengatur ID proyek (ditampilkan di bawah nama proyek) menjadi sesuatu berdasarkan nama proyek. Anda juga dapat mengklik ikon edit pada ID proyek untuk menyesuaikannya lebih lanjut.
  3. Jika diminta, tinjau dan setujui persyaratan Firebase .
  4. Klik Lanjutkan .
  5. Pilih opsi Aktifkan Google Analytics untuk proyek ini , lalu klik Lanjutkan .
  6. Pilih akun Google Analytics yang ada untuk digunakan atau pilih Buat akun baru untuk membuat akun baru.
  7. Klik Buat proyek .
  8. Ketika proyek telah dibuat, klik Lanjutkan .
  9. Dari menu Build , klik Functions , dan jika diminta, tingkatkan proyek Anda untuk menggunakan paket penagihan Blaze.
  10. Dari menu Build , klik Database Firestore .
  11. Pada dialog Buat database yang muncul, pilih Mulai dalam mode pengujian , lalu klik Berikutnya .
  12. Pilih region dari drop-down lokasi Cloud Firestore , lalu klik Aktifkan .

Konfigurasikan dan jalankan papan peringkat Anda

  1. Di terminal, navigasikan ke root proyek dan jalankan firebase use --add . Pilih proyek Firebase yang baru saja Anda buat.
  2. Di root proyek, jalankan firebase emulators:start --only hosting .
  3. Di browser Anda, navigasikan ke localhost:5000 .
  4. Buka konsol JavaScript Chrome DevTools dan impor leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Jalankan leaderboard.codelab(); di konsol. Jika Anda melihat pesan selamat datang, Anda sudah siap! Jika tidak, matikan emulator dan jalankan kembali langkah 2-4.

Mari beralih ke penerapan papan peringkat pertama.

3. Terapkan papan peringkat sederhana

Di akhir bagian ini, kita akan bisa menambahkan skor ke papan peringkat dan memberitahukan peringkat kita.

Sebelum kita masuk lebih dalam, mari kita jelaskan cara kerja penerapan papan peringkat ini: Semua pemain disimpan dalam satu koleksi, dan pengambilan peringkat pemain dilakukan dengan mengambil koleksi tersebut dan menghitung berapa banyak pemain yang berada di depan mereka. Hal ini membuat penyisipan dan pembaruan skor menjadi mudah. Untuk memasukkan skor baru, kami hanya menambahkannya ke koleksi, dan untuk memperbaruinya, kami memfilter pengguna kami saat ini dan kemudian memperbarui dokumen yang dihasilkan. Mari kita lihat seperti apa kodenya.

Dalam functions/functions-helper.js , implementasikan 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 kesalahan 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,
  });
}

Dan terakhir, fungsi peringkat kami yang sederhana namun 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 uji! Terapkan fungsi Anda dengan menjalankan perintah berikut di terminal:

firebase deploy --only functions

Lalu, di konsol JS Chrome, tambahkan beberapa skor lainnya sehingga kami dapat melihat peringkat kami 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.

Saat penulisan selesai, Anda akan melihat respons di konsol yang mengatakan "Skor dibuat". Melihat kesalahan saja? Buka log Fungsi melalui Firebase console untuk melihat apa yang salah.

Dan terakhir, kami dapat mengambil dan memperbarui skor kami.

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 eksekusi fungsi dan memori keduanya terbatas, hal ini tidak hanya berarti pengambilan kita menjadi semakin lambat, namun setelah skor yang cukup ditambahkan ke papan peringkat, waktu fungsi kita akan habis atau terhenti sebelum dapat memberikan hasil. Tentu saja, kita memerlukan sesuatu yang lebih baik jika kita ingin mengembangkan lebih dari sekedar pemain saja.

Jika Anda penggemar Firestore, Anda mungkin mengetahui COUNT kueri agregasi , yang akan membuat papan peringkat ini jauh lebih berperforma. Dan Anda benar! Dengan kueri COUNT, skalanya jauh di bawah satu juta pengguna atau lebih, meskipun kinerjanya masih linier.

Tapi tunggu dulu, Anda mungkin berpikir, jika kita tetap akan menghitung semua dokumen dalam koleksi, kita dapat menetapkan peringkat pada setiap dokumen dan kemudian ketika kita perlu mengambilnya, pengambilan kita akan menjadi O(1) waktu dan ingatan! Hal ini membawa kami ke pendekatan berikutnya, papan peringkat yang diperbarui secara berkala.

4. Menerapkan papan peringkat yang diperbarui secara berkala

Kunci dari pendekatan ini adalah menyimpan peringkat dalam dokumen itu sendiri, jadi mengambilnya memberi kita peringkat tanpa perlu melakukan pekerjaan tambahan. Untuk mencapai hal ini, kita memerlukan fungsi jenis baru.

Di index.js , tambahkan yang berikut ini:

// 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, perbarui, dan tulis kami semuanya bagus dan sederhana. Tulis dan perbarui keduanya tidak berubah, tetapi baca 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 menerapkan dan mengujinya tanpa menambahkan akun penagihan ke proyek Anda. Jika Anda memiliki akun penagihan, persingkat interval pada fungsi terjadwal dan saksikan fungsi Anda secara ajaib menetapkan peringkat ke skor papan peringkat Anda.

Jika tidak, hapus fungsi terjadwal dan lanjutkan ke implementasi berikutnya.

Silakan hapus skor di database Firestore Anda dengan mengklik 3 titik di sebelah kumpulan skor untuk mempersiapkan bagian selanjutnya.

Firestore scores document page with\nDelete Collection activated

5. Menerapkan papan peringkat pohon secara real-time

Pendekatan ini bekerja dengan menyimpan data pencarian dalam kumpulan database itu sendiri. Daripada memiliki koleksi yang seragam, tujuan kami adalah menyimpan segala sesuatu di pohon yang dapat kami lalui dengan menelusuri dokumen. Hal ini memungkinkan kita melakukan pencarian biner (atau n-ary) untuk peringkat skor tertentu. Seperti apa bentuknya?

Untuk memulainya, kami ingin dapat mendistribusikan skor kami ke dalam kelompok yang kira-kira sama, yang memerlukan pengetahuan tentang nilai skor yang dicatat oleh pengguna kami; misalnya, jika Anda membuat papan peringkat untuk peringkat keterampilan dalam permainan kompetitif, peringkat keterampilan pengguna Anda hampir selalu akan terdistribusi secara normal. Fungsi penghasil skor acak kami menggunakan Math.random() JavaScript, yang menghasilkan distribusi yang kira-kira merata, jadi kami akan membagi kelompok kami secara merata.

Dalam contoh ini kita akan menggunakan 3 keranjang untuk mempermudah, namun Anda mungkin akan menemukan bahwa jika Anda menggunakan penerapan ini di aplikasi nyata, lebih banyak keranjang akan memberikan hasil yang lebih cepat–pohon yang lebih dangkal berarti rata-rata lebih sedikit pengambilan koleksi dan lebih sedikit pertikaian kunci.

Pangkat seorang pemain diberikan berdasarkan jumlah jumlah pemain dengan skor lebih tinggi, ditambah satu untuk pemain itu sendiri. Setiap koleksi di bawah scores akan menyimpan tiga dokumen, masing-masing dengan rentang, jumlah dokumen di bawah setiap rentang, dan kemudian tiga subkoleksi yang sesuai. Untuk membaca peringkat, kita akan melintasi pohon ini untuk mencari skor dan mencatat jumlah skor yang lebih besar. Saat kami menemukan skor kami, kami juga akan mendapatkan jumlah yang benar.

Menulis jauh lebih rumit. Pertama, kita harus membuat semua penulisan dalam suatu transaksi untuk mencegah inkonsistensi data ketika beberapa penulisan atau pembacaan terjadi secara bersamaan. Kita juga harus mempertahankan semua kondisi yang telah kita jelaskan di atas saat kita melintasi pohon untuk menulis dokumen baru. Dan, yang terakhir, karena kita memiliki seluruh kompleksitas pendekatan baru ini yang dipadukan dengan kebutuhan untuk menyimpan semua dokumen asli, biaya penyimpanan kita akan sedikit meningkat (namun masih linier).

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 tentunya lebih rumit dibandingkan implementasi terakhir kita, yang merupakan pemanggilan metode tunggal 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 individu.

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

Sekarang setelah kita menyelesaikan bagian tersulitnya, kita dapat membaca skor dengan menelusuri pohon 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) dan lihat perubahan papan peringkat Anda di Firebase console.

Namun, dengan penerapan ini, kompleksitas yang kami tambahkan untuk mencapai kinerja logaritmik harus dibayar mahal.

  • Pertama, penerapan papan peringkat ini dapat mengalami masalah pertentangan kunci, karena transaksi memerlukan penguncian baca dan tulis pada dokumen untuk memastikan transaksi tetap konsisten.
  • Kedua, Firestore menerapkan batas kedalaman subkoleksi sebesar 100 , artinya Anda harus menghindari pembuatan subpohon setelah 100 skor imbang, yang tidak dilakukan oleh implementasi ini.
  • Dan yang terakhir, papan peringkat ini berskala secara logaritmik hanya dalam kasus ideal ketika pohonnya seimbang–jika tidak seimbang, dalam kasus terburuk, performa papan peringkat ini sekali lagi linier.

Setelah selesai, hapus scores dan koleksi 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 memperhatikan bahwa jika Anda menjalankannya terlalu sering secara paralel, fungsi Anda akan mulai gagal dengan pesan kesalahan terkait pertikaian kunci transaksi. Ada beberapa 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 kerumitan pendekatan sebelumnya untuk sesuatu yang lebih sederhana dan lebih cepat. Mari kita lihat bagaimana kita bisa mengembalikan perkiraan peringkat untuk skor pemain kita, bukan peringkat pastinya, dan bagaimana hal itu mengubah logika database kita.

Untuk pendekatan ini, kami akan membagi papan peringkat menjadi 100 kelompok, masing-masing mewakili sekitar satu persen dari skor yang kami harapkan akan diterima. Pendekatan ini bisa diterapkan bahkan tanpa mengetahui distribusi skor kita, yang dalam hal ini kita tidak punya cara untuk menjamin distribusi skor yang kira-kira merata di seluruh kelompok, namun kita akan mencapai ketepatan yang lebih baik dalam perkiraan kita jika kita tahu bagaimana skor kita akan didistribusikan. .

Pendekatan kami adalah sebagai berikut: seperti sebelumnya, setiap keranjang menyimpan jumlah skor dalam dan rentang skor. Saat memasukkan skor baru, kami akan menemukan wadah untuk skor tersebut dan menambah jumlahnya. Saat mengambil peringkat, kami hanya akan menjumlahkan keranjang di depannya dan kemudian memperkirakannya dalam keranjang kami alih-alih mencari lebih jauh. 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 kode penyisipan ini memiliki logika untuk menginisialisasi status database Anda di bagian atas dengan peringatan untuk tidak melakukan hal seperti ini dalam produksi. Kode untuk inisialisasi tidak dilindungi sama sekali terhadap kondisi balapan, jadi jika Anda melakukan ini, beberapa penulisan secara bersamaan akan merusak database Anda dengan memberi Anda banyak keranjang duplikat.

Lanjutkan dan terapkan fungsi Anda lalu jalankan penyisipan untuk menginisialisasi semua bucket dengan hitungan nol. Ini akan mengembalikan kesalahan, yang dapat Anda abaikan dengan aman.

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

Sekarang setelah database diinisialisasi dengan benar, kita dapat menjalankan addScores dan melihat struktur data kita di Firebase console. Struktur yang dihasilkan jauh lebih datar dibandingkan penerapan terakhir kami, meskipun secara dangkal serupa.

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 kami telah membuat fungsi addScores menghasilkan distribusi skor yang seragam dan kami menggunakan interpolasi linier dalam keranjang, kami akan mendapatkan hasil yang sangat akurat, kinerja papan peringkat kami tidak akan menurun seiring bertambahnya jumlah pengguna, dan kita tidak perlu khawatir tentang pertikaian kunci (sebanyak itu) saat memperbarui jumlah.

7. Tambahan: Kecurangan

Tunggu sebentar, Anda mungkin berpikir, jika saya menulis nilai ke codelab saya melalui konsol JS di tab browser, tidak bisakah pemain saya berbohong ke papan peringkat dan mengatakan bahwa mereka mendapat skor tinggi padahal sebenarnya tidak? mencapai secara adil?

Ya mereka bisa. Jika Anda ingin mencegah kecurangan, cara paling ampuh untuk melakukannya adalah dengan menonaktifkan penulisan klien ke database Anda melalui aturan keamanan , mengamankan akses ke Cloud Functions Anda sehingga klien tidak dapat menghubungi mereka secara langsung, lalu memvalidasi tindakan dalam game di server Anda sebelumnya. mengirimkan pembaruan skor ke papan peringkat.

Penting untuk dicatat bahwa strategi ini bukanlah obat mujarab untuk melawan kecurangan – dengan insentif yang cukup besar, para penipu dapat menemukan cara untuk menghindari validasi sisi server, dan banyak video game besar yang sukses terus-menerus bermain kucing-kucingan dengan para penipu untuk mengidentifikasi cheat baru dan menghentikan penyebarannya. Konsekuensi sulit dari fenomena ini adalah validasi sisi server untuk setiap game secara inheren dibuat 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 merupakan anti-cheat holistik.

Apa pun yang kurang dari validasi sisi server, untuk game yang cukup populer atau penghalang curang yang cukup rendah, akan menghasilkan papan peringkat yang nilai teratasnya semuanya curang.

8. Selamat

Selamat, Anda berhasil membuat empat papan peringkat berbeda di Firebase! Bergantung pada kebutuhan ketepatan dan kecepatan game Anda, Anda akan dapat memilih salah satu yang sesuai untuk Anda dengan biaya yang wajar.

Selanjutnya, lihat jalur pembelajaran untuk permainan.