Mem-build papan peringkat dengan Firestore

1. Pengantar

Terakhir diperbarui: 27-01-2023

Apa yang diperlukan untuk membuat papan peringkat?

Pada intinya, papan peringkat hanyalah tabel skor dengan satu faktor yang mempersulit: membaca peringkat untuk skor tertentu memerlukan pengetahuan tentang semua skor lainnya dalam urutan tertentu. Selain itu, jika game Anda lepas landas, papan peringkat Anda akan bertambah besar dan sering dibaca serta ditulis. Agar berhasil membuat papan peringkat, 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, masing-masing sesuai untuk skenario yang berbeda.

Yang akan Anda pelajari

Anda akan mempelajari cara mengimplementasikan empat papan peringkat yang berbeda:

  • Implementasi naif yang menggunakan penghitungan catatan sederhana untuk menentukan peringkat
  • Papan peringkat murah dan diperbarui secara berkala
  • Papan peringkat real-time dengan elemen pohon yang tidak beralasan
  • 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 jika Anda menggunakan nvm)
  • Paket Firebase Blaze berbayar (opsional)
  • Firebase CLI v11.16.0 atau yang lebih baru
    Untuk menginstal CLI, Anda dapat menjalankan npm 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 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 titik awal kita?

Project kita saat ini kosong dengan beberapa fungsi kosong:

  • index.html berisi beberapa skrip glue yang memungkinkan kita memanggil fungsi dari konsol dev dan melihat output-nya. 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 membutuhkan waktu terlalu lama untuk memainkan game setiap kali Anda ingin menambahkan skor ke papan peringkat.
  • functions/index.js berisi semua Cloud Functions. Anda akan melihat beberapa fungsi utilitas, seperti addScores dan deleteScores, 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 mengimplementasikan fungsi baca, buat, dan perbarui, dan Anda akan melihat bagaimana pilihan implementasi kami memengaruhi kompleksitas implementasi dan performa penskalaannya.
  • functions/utils.js berisi fungsi utilitas lainnya. Kita tidak akan menyentuh file ini di codelab ini.

Membuat dan mengonfigurasi project Firebase

  1. Di Firebase console, klik Add project.
  2. Untuk membuat project baru, masukkan nama project yang diinginkan.
    Tindakan ini juga akan menetapkan project ID (yang ditampilkan di bawah nama project) ke sesuatu yang didasarkan pada nama project. Anda dapat mengklik ikon edit pada project ID untuk menyesuaikannya lebih lanjut.
  3. Jika diminta, tinjau dan setujui persyaratan Firebase.
  4. Klik Continue.
  5. Pilih opsi Enable Google Analytics for this project, lalu klik Continue.
  6. Pilih akun Google Analytics yang ada untuk digunakan atau pilih Create a new account untuk membuat akun baru.
  7. Klik Create project.
  8. Setelah project dibuat, klik Continue.
  9. Dari menu Build, klik Functions, lalu jika diminta, upgrade project Anda untuk menggunakan paket penagihan Blaze.
  10. Dari menu Build, klik Firestore database.
  11. Dalam dialog Create database yang muncul, pilih Start in test mode, lalu klik Next.
  12. Pilih region dari drop-down Lokasi Cloud Firestore, lalu klik Aktifkan.

Mengonfigurasi dan menjalankan papan peringkat

  1. Di terminal, buka root project dan jalankan firebase use --add. Pilih project Firebase yang baru saja Anda buat.
  2. Di root project, jalankan firebase emulators:start --only hosting.
  3. Di browser Anda, buka 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, berarti Anda sudah siap! Jika tidak, matikan emulator dan jalankan kembali langkah 2-4.

Mari kita lanjutkan ke implementasi papan peringkat pertama.

3. Mengimplementasikan papan peringkat sederhana

Di akhir bagian ini, kita dapat menambahkan skor ke papan peringkat dan membuatnya memberi tahu 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 ada di depannya. 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 seperti apa tampilannya dalam kode.

Di functions/functions-helper.js, implementasikan fungsi createScore, yang semudah mungkin:

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 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 uji! Deploy fungsi Anda dengan menjalankan perintah berikut di terminal:

firebase deploy --only functions

Lalu, 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 campuran:

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

Setelah penulisan selesai, Anda akan melihat respons di konsol yang menyatakan "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 linear dan memori yang tidak diinginkan untuk mengambil peringkat skor tertentu. Karena waktu eksekusi fungsi dan memori terbatas, bukan hanya berarti pengambilan data menjadi semakin lambat, tetapi setelah skor yang cukup ditambahkan ke papan peringkat, waktu tunggu fungsi akan habis atau error sebelum dapat memberikan hasil. Jelas, kita membutuhkan sesuatu yang lebih baik jika kita ingin meningkatkan skala di luar beberapa pemain.

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

Namun tunggu dulu, Anda mungkin berpikir, jika kita akan menghitung semua dokumen dalam koleksi, kita dapat memberikan peringkat untuk setiap dokumen, lalu ketika kita perlu mengambilnya, pengambilannya akan menjadi O(1) waktu dan memori. Hal ini membawa kami 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, sehingga mengambilnya akan memberi kita peringkat tanpa pekerjaan tambahan. Untuk mencapai ini, 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 sudah bagus dan sederhana. Tulis dan perbarui tidak berubah, tetapi operasi 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 akan dapat men-deploy dan mengujinya tanpa menambahkan akun penagihan ke project Anda. Jika Anda memiliki akun penagihan, persingkat interval pada fungsi terjadwal dan lihat fungsi Anda menetapkan peringkat secara ajaib ke skor papan peringkat.

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

Lanjutkan dan hapus skor di database Firestore Anda dengan mengklik 3 titik di samping koleksi skor untuk bersiap melanjutkan ke bagian berikutnya.

Halaman dokumen skor Firestore dengan\nHapus Koleksi diaktifkan

5. Mengimplementasikan papan peringkat hierarki real-time

Pendekatan ini bekerja dengan menyimpan data penelusuran dalam koleksi database itu sendiri. Alih-alih memiliki koleksi yang seragam, tujuan kita adalah menyimpan semua yang ada di pohon yang bisa kita jelajahi dengan memindahkan dokumen. Hal ini memungkinkan kita melakukan penelusuran biner (atau n-ary) untuk peringkat skor tertentu. Seperti apa tampilannya?

Untuk memulai, kita ingin dapat mendistribusikan skor ke dalam kelompok yang kurang lebih sama, sehingga diperlukan pengetahuan tentang nilai skor yang dicatat pengguna; misalnya, jika Anda membuat papan peringkat untuk rating keahlian dalam game kompetitif, rating keterampilan pengguna akan hampir selalu didistribusikan secara normal. Fungsi yang menghasilkan skor acak menggunakan Math.random() JavaScript, yang menghasilkan distribusi yang kira-kira merata, sehingga kita akan membagi bucket secara merata.

Dalam contoh ini, kita akan menggunakan 3 bucket agar lebih mudah, tetapi Anda mungkin akan menemukan bahwa jika menggunakan implementasi ini dalam aplikasi nyata, lebih banyak bucket akan memberikan hasil yang lebih cepat–pohon yang lebih dangkal berarti rata-rata lebih sedikit pengambilan koleksi dan lebih sedikit pertentangan kunci.

Peringkat pemain diberikan dari jumlah pemain yang memiliki skor lebih tinggi, ditambah satu untuk pemain itu sendiri. Setiap koleksi pada scores akan menyimpan tiga dokumen, masing-masing dengan rentang, jumlah dokumen di bawah setiap rentang, lalu tiga subkoleksi yang sesuai. Untuk membaca peringkat, kita akan menelusuri pohon ini untuk menelusuri skor dan melacak jumlah skor yang lebih besar. Ketika kita menemukan skor, kita juga akan memiliki jumlah yang benar.

Menulis menjadi jauh lebih rumit. Pertama, kita harus membuat semua penulisan dalam transaksi untuk mencegah inkonsistensi data saat beberapa penulisan atau pembacaan terjadi secara bersamaan. Kita juga perlu mempertahankan semua kondisi yang telah kita jelaskan di atas saat kita menelusuri hierarki untuk menulis dokumen baru. Dan, terakhir, karena kita memiliki semua kerumitan pohon dari pendekatan baru ini dikombinasikan dengan kebutuhan untuk menyimpan semua dokumen asli, biaya penyimpanan kita akan sedikit meningkat (tetapi 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 tentu lebih rumit daripada implementasi terakhir kita, yang merupakan panggilan metode tunggal dan hanya enam baris kode. Setelah Anda mengimplementasikan metode ini, coba tambahkan beberapa skor ke database dan amati struktur hierarki yang dihasilkan. Di konsol JS Anda:

leaderboard.addScores();

Struktur database yang dihasilkan akan terlihat seperti ini, dengan struktur pohon yang terlihat jelas dan daun pohon yang mewakili skor individu.

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 bisa membaca skor dengan melewati 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 penerapan ini, kerumitan yang kami tambahkan untuk mencapai performa logaritma juga 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 menerapkan batas kedalaman subkoleksi sebesar 100, yang berarti Anda harus menghindari pembuatan sub-hierarki setelah 100 skor terikat, yang tidak diberikan oleh implementasi ini.
  • Dan terakhir, papan peringkat ini diskalakan secara logaritmis hanya dalam kasus ideal di mana pohon seimbang–jika tidak seimbang, performa kasus terburuk dari papan peringkat ini sekali lagi akan linear.

Setelah selesai, hapus koleksi scores dan players melalui Firebase console dan kita akan melanjutkan ke implementasi papan peringkat terakhir.

6. Mengimplementasikan papan peringkat stokastik (probabilistik)

Saat menjalankan kode penyisipan, Anda mungkin melihat bahwa jika Anda terlalu sering menjalankannya secara paralel, fungsi Anda akan mulai gagal dengan pesan error yang terkait dengan pertentangan kunci transaksi. Ada beberapa cara untuk mengatasi hal ini yang tidak akan kita pelajari dalam codelab ini, tetapi jika 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 dapat menampilkan perkiraan peringkat untuk skor pemain, bukan peringkat yang tepat, dan bagaimana hal tersebut mengubah logika database.

Untuk pendekatan ini, kami akan membagi papan peringkat menjadi 100 bucket, masing-masing mewakili sekitar satu persen skor yang diharapkan akan diterima. Pendekatan ini dapat diterapkan tanpa mengetahui distribusi skor kami. Jika demikian, kami tidak dapat menjamin distribusi skor yang kurang lebih merata di seluruh bucket. Namun, perkiraan kami akan lebih akurat jika kami mengetahui cara distribusi skor kami.

Pendekatan kami adalah sebagai berikut: seperti sebelumnya, setiap kotak menyimpan jumlah skor dalam dan rentang skor. Saat menyisipkan skor baru, kita akan menemukan bucket untuk skor dan menambahkan jumlahnya. Saat mengambil peringkat, kita hanya 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 sehingga jika Anda melakukannya, beberapa penulisan serentak akan merusak database dengan memberi Anda sekumpulan bucket duplikat.

Lanjutkan dan 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 mereka sedikit 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 di dalam bucket, kita akan mendapatkan hasil yang sangat akurat, performa papan peringkat tidak akan menurun seiring bertambahnya jumlah pengguna, dan kita tidak perlu khawatir tentang pertentangan kunci (terlalu banyak) saat memperbarui jumlah.

7. Adendum: Kecurangan

Tunggu sebentar, mungkin Anda berpikir, jika saya menulis nilai ke codelab melalui konsol JS dari tab browser, bukankah pemain saya bisa berbohong pada papan peringkat dan mengatakan bahwa mereka mendapat skor tinggi yang tidak dicapai 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, serta mengakses aman ke Cloud Functions 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 melawan kecurangan–dengan insentif yang cukup besar, penipu dapat menemukan cara untuk mengakali validasi sisi server, dan banyak video game besar yang sukses terus bermain-main dengan curang untuk mengidentifikasi kecurangan baru dan menghentikannya berkembang. 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 bernaskah sederhana, Firebase tidak menyediakan layanan apa pun yang sama dengan anti-penipuan menyeluruh.

Apa pun yang kurang dari validasi sisi server akan, untuk game yang cukup populer atau rintangan yang cukup rendah untuk melakukan kecurangan, akan menghasilkan papan peringkat dengan nilai teratas semuanya adalah penipu.

8. Selamat

Selamat, Anda telah berhasil membangun empat papan peringkat berbeda di Firebase. Anda dapat memilih satu yang cocok dengan biaya yang wajar, tergantung kebutuhan game yang tepat dan kecepatan.

Selanjutnya, lihat jalur pembelajaran untuk game.