Membangun 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 rumit: membaca peringkat untuk skor tertentu memerlukan pengetahuan tentang semua skor lainnya dalam urutan tertentu. 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 berbeda, masing-masing sesuai untuk skenario yang berbeda.

Yang akan Anda pelajari

Anda akan mempelajari cara mengimplementasikan empat papan peringkat yang berbeda:

  • Implementasi naif menggunakan penghitungan catatan sederhana untuk menentukan peringkat
  • Papan peringkat yang murah dan diperbarui secara berkala
  • Papan peringkat real-time dengan sedikit omong kosong
  • 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 menjalankan npm 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 repositori 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. 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 update, serta Anda akan melihat bagaimana pilihan implementasi kami memengaruhi kompleksitas implementasi dan performa penskalaannya.
  • functions/utils.js berisi lebih banyak fungsi utilitas. Kami tidak akan menyentuh file ini dalam 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 berdasarkan nama project. Jika ingin, 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 Buat akun baru untuk membuat akun baru.
  7. Klik Create project.
  8. Setelah project dibuat, klik Continue.
  9. Dari menu Build, klik Functions, lalu upgrade project Anda jika diminta untuk menggunakan paket penagihan Blaze.
  10. Dari menu Build, klik Firestore database.
  11. Di 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 Anda

  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 di Chrome DevTools dan impor leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. 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 mulai penerapan papan peringkat yang 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 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 berapa banyak pemain yang berada di depan mereka. Hal ini memudahkan penyisipan dan pembaruan skor. Untuk menyisipkan skor baru, kita cukup menambahkannya ke koleksi, lalu 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 cukup sederhana:

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,
  });
}

Dan 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 coba! 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 kami 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.

Saat penulisan selesai, Anda akan melihat respons di konsol yang menyatakan "Skor dibuat". Apakah Anda melihat error? Buka log Functions melalui Firebase console untuk melihat apa yang salah.

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, ini tidak hanya berarti pengambilan kita menjadi semakin lambat, tetapi setelah cukup skor ditambahkan ke papan peringkat, fungsi kita akan kehabisan waktu atau 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 penggemar Firestore, Anda mungkin mengetahui COUNT kueri agregasi, yang akan membuat papan peringkat ini berperforma lebih baik. 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. Terapkan 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, pembaruan, dan tulis semuanya bagus dan sederhana. Operasi tulis dan update 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 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 implementasi berikutnya.

Lanjutkan dan hapus skor di database Firestore Anda dengan mengklik 3 titik di samping kumpulan skor untuk mempersiapkan bagian berikutnya.

Halaman dokumen skor Firestore dengan\nHapus Koleksi diaktifkan

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 akan dapat mendistribusikan skor ke dalam kategori yang kurang lebih sama, yang akan memerlukan sedikit pengetahuan tentang nilai skor yang dicatat pengguna; misalnya, jika Anda membuat papan peringkat untuk peringkat keterampilan dalam game kompetitif, peringkat keterampilan pengguna Anda hampir selalu didistribusikan 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 nyata, 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 dalam scores akan menyimpan tiga dokumen, masing-masing dengan rentang, jumlah dokumen di 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 menjaga semua kondisi yang telah dijelaskan di atas saat melintasi pohon untuk menulis dokumen baru. Dan, terakhir, karena kita memiliki semua kompleksitas pohon dari pendekatan baru ini yang dikombinasikan dengan kebutuhan untuk menyimpan semua dokumen asli, biaya penyimpanan kita akan sedikit meningkat (tetapi ini 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,
      });
    });
  });
}

Ini tentu lebih rumit daripada implementasi terakhir kami, yang merupakan panggilan 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 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 disediakan sebagai latihan tambahan. Coba tambahkan dan ambil skor di konsol JS dengan metode leaderboard.addScore(id, score) dan leaderboard.getRank(id), lalu lihat bagaimana papan peringkat Anda berubah di Firebase console.

Namun, dengan penerapan ini, kompleksitas 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 operasi baca dan tulis ke dokumen untuk memastikannya tetap konsisten.
  • Kedua, Firestore memberlakukan batas kedalaman subkoleksi sebesar 100, yang berarti Anda harus menghindari pembuatan subpohon setelah 100 skor yang terikat, yang tidak dilakukan dalam 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 beralih 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 dapat menampilkan perkiraan peringkat untuk skor pemain alih-alih peringkat yang tepat, dan bagaimana hal ini mengubah logika database kita.

Untuk pendekatan ini, kami akan membagi papan peringkat menjadi 100 kategori, masing-masing mewakili kira-kira satu persen dari skor yang kami harapkan akan diterima. 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 meningkatkan 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 menginisialisasi status database Anda 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.

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 pada dasarnya mereka 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 tangguh untuk melakukannya adalah dengan menonaktifkan penulisan klien ke database Anda melalui aturan keamanan, serta 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 sulit dari fenomena ini adalah validasi sisi server untuk setiap game secara inheren 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-penipu secara menyeluruh.

Apa pun yang kurang dari validasi sisi server akan, untuk game yang cukup populer atau penghalang yang cukup rendah untuk kecurangan, akan menghasilkan papan peringkat yang semua nilai tertingginya adalah curang.

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.

Selanjutnya, lihat jalur pembelajaran untuk game.