Membuat papan peringkat dengan Firestore

1. Pengantar

Terakhir Diperbarui: 27-01-2023

Apa yang diperlukan untuk membuat papan peringkat?

Pada dasarnya, papan peringkat hanyalah tabel skor dengan satu faktor yang rumit: membaca peringkat untuk skor tertentu memerlukan pengetahuan tentang semua skor lainnya dalam suatu urutan. Selain itu, jika game Anda sukses, papan peringkat akan menjadi besar dan sering dibaca serta ditulis. Agar berhasil membuat papan peringkat, papan peringkat harus mampu menangani operasi peringkat ini dengan cepat.

Yang akan Anda build

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

Yang akan Anda pelajari

Anda akan mempelajari cara menerapkan empat papan peringkat yang berbeda:

  • Implementasi 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 repo Git. Untuk memulai, Anda harus mengambil kode dan membukanya di lingkungan pengembangan favorit Anda. Untuk codelab ini, kami menggunakan VS Code, tetapi editor teks apa pun dapat digunakan.

dan ekstrak file zip yang didownload.

Atau, clone ke direktori pilihan Anda:

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

Apa titik awal kita?

Project kita saat ini kosong dengan beberapa fungsi kosong:

  • index.html berisi beberapa skrip glue yang memungkinkan kita memanggil fungsi dari konsol developer dan melihat outputnya. Kita akan menggunakannya untuk berinteraksi dengan backend dan melihat hasil pemanggilan fungsi. Dalam skenario dunia nyata, Anda akan melakukan panggilan backend ini dari game Anda secara langsung—kita tidak menggunakan game dalam codelab ini karena akan memakan waktu terlalu lama untuk memainkan game setiap kali Anda ingin menambahkan skor ke papan peringkat.
  • functions/index.js berisi semua Cloud Functions kita. Anda akan melihat beberapa fungsi utilitas, 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 menerapkan fungsi baca, buat, dan perbarui, dan Anda akan melihat pengaruh pilihan implementasi terhadap kompleksitas implementasi dan performa penskalaannya.
  • functions/utils.js berisi lebih banyak fungsi utilitas. 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 Lanjutkan.
  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 Lanjutkan.
  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. Pada 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, 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 langsung ke implementasi papan peringkat pertama.

3. Mengimplementasikan papan peringkat sederhana

Di akhir bagian ini, kita akan dapat menambahkan skor ke papan peringkat dan mengetahui peringkat kita.

Sebelum memulai, mari kita jelaskan cara kerja penerapan papan peringkat ini: Semua pemain disimpan dalam satu koleksi, dan pengambilan peringkat pemain dilakukan dengan mengambil koleksi dan menghitung jumlah pemain yang berada di atasnya. Hal ini memudahkan penyisipan dan pembaruan skor. Untuk menyisipkan skor baru, kita cukup menambahkannya ke koleksi, 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.

Setelah penulisan selesai, Anda akan melihat respons di konsol yang bertuliskan "Skor dibuat". Melihat error? Buka log Functions melalui Firebase console untuk melihat masalahnya.

Dan, akhirnya, kita dapat mengambil dan memperbarui skor.

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

Namun, implementasi ini memberi kita persyaratan waktu linear dan memori yang tidak diinginkan untuk mengambil peringkat skor tertentu. Karena waktu eksekusi fungsi dan memori terbatas, 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, hal ini diskalakan dengan baik di bawah satu juta pengguna, meskipun performanya masih linier.

Namun, tunggu, Anda mungkin berpikir, jika kita akan menghitung semua dokumen dalam koleksi, kita dapat menetapkan peringkat untuk setiap dokumen, lalu saat kita perlu mengambilnya, pengambilan kita akan menjadi waktu dan memori O(1)! Hal ini membawa kita ke pendekatan berikutnya, papan peringkat yang diperbarui secara berkala.

4. Mengimplementasikan papan peringkat yang diperbarui secara berkala

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

Di index.js, tambahkan kode berikut:

// Also add this to the top of your file
const admin = require("firebase-admin");

exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
    // Schedule this when most of your users are offline to avoid
    // database spikiness.
    .timeZone("America/Los_Angeles")
    .onRun((context) => {
      const scores = admin.firestore().collection("scores");
      scores.orderBy("score", "desc").get().then((snapshot) => {
        let rank = 1;
        const writes = [];
        for (const docSnapshot of snapshot.docs) {
          const docReference = scores.doc(docSnapshot.id);
          writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
          rank++;
        }
        Promise.all(writes).then((result) => {
          console.log(`Writes completed with results: ${result}`);
        });
      });
      return null;
    });

Sekarang operasi baca, 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 akan dapat men-deploy dan mengujinya tanpa menambahkan akun penagihan ke project. Jika Anda memiliki akun penagihan, perpendek interval pada fungsi yang dijadwalkan dan lihat fungsi Anda secara ajaib menetapkan peringkat ke skor papan peringkat Anda.

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

Hapus skor di database Firestore dengan mengklik 3 titik di samping koleksi skor untuk mempersiapkan bagian berikutnya.

Halaman dokumen skor Firestore dengan\nHapus Koleksi diaktifkan

5. Mengimplementasikan papan peringkat pohon real-time

Pendekatan ini berfungsi dengan menyimpan data penelusuran di koleksi database itu sendiri. Alih-alih memiliki koleksi yang seragam, sasaran kita adalah menyimpan semuanya dalam hierarki yang dapat kita lalui dengan menelusuri 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 bucket yang kira-kira merata, yang akan memerlukan beberapa pengetahuan tentang nilai skor yang dicatat pengguna; misalnya, jika Anda membuat papan peringkat untuk rating keterampilan dalam game kompetitif, rating keterampilan pengguna Anda hampir selalu akan didistribusikan secara normal. Fungsi pembuat skor acak kita menggunakan Math.random() JavaScript, yang menghasilkan distribusi yang hampir merata, sehingga kita akan membagi bucket secara merata.

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

Peringkat pemain ditentukan oleh jumlah pemain dengan skor lebih tinggi, ditambah satu untuk pemain itu sendiri. Setiap koleksi di bagian scores akan menyimpan tiga dokumen, masing-masing dengan rentang, jumlah dokumen dalam setiap rentang, lalu tiga subkoleksi yang sesuai. Untuk membaca peringkat, kita akan menelusuri hierarki ini untuk menelusuri 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 melakukan 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. Terakhir, karena kita memiliki semua kompleksitas hierarki dari pendekatan baru ini yang dikombinasikan dengan kebutuhan untuk menyimpan semua dokumen asli, biaya penyimpanan kita akan sedikit meningkat (tetapi masih bersifat linear).

Di functions-helpers.js:

async function createScore(playerID, score, firestore) {
  /**
   * This function assumes a minimum score of 0 and that value
   * is between min and max.
   * Returns the expected size of a bucket for a given score
   * so that bucket sizes stay constant, to avoid expensive
   * re-bucketing.
   * @param {number} value The new score.
   * @param {number} min The min of the previous range.
   * @param {number} max The max of the previous range. Must be greater than
   *     min.
   * @return {Object<string, number>} Returns an object containing the new min
   *     and max.
   */
  function bucket(value, min, max) {
    const bucketSize = (max - min) / 3;
    const bucketMin = Math.floor(value / bucketSize) * bucketSize;
    const bucketMax = bucketMin + bucketSize;
    return {min: bucketMin, max: bucketMax};
  }

  /**
   * A function used to store pending writes until all reads within a
   * transaction have completed.
   *
   * @callback PendingWrite
   * @param {admin.firestore.Transaction} transaction The transaction
   *     to be used for writes.
   * @return {void}
   */

  /**
   * Recursively searches for the node to write the score to,
   * then writes the score and updates any counters along the way.
   * @param {number} id The user associated with the score.
   * @param {number} value The new score.
   * @param {admin.firestore.CollectionReference} coll The collection this
   *     value should be written to.
   * @param {Object<string, number>} range An object with properties min and
   *     max defining the range this score should be in. Ranges cannot overlap
   *     without causing problems. Use the bucket function above to determine a
   *     root range from constant values to ensure consistency.
   * @param {admin.firestore.Transaction} transaction The transaction used to
   *     ensure consistency during tree updates.
   * @param {Array<PendingWrite>} pendingWrites A series of writes that should
   *     occur once all reads within a transaction have completed.
   * @return {void} Write error/success is handled via the transaction object.
   */
  async function writeScoreToCollection(
      id, value, coll, range, transaction, pendingWrites) {
    const snapshot = await transaction.get(coll);
    if (snapshot.empty) {
      // This is the first score to be inserted into this node.
      for (const write of pendingWrites) {
        write(transaction);
      }
      const docRef = coll.doc();
      transaction.create(docRef, {exact: {score: value, user: id}});
      return;
    }

    const min = range.min;
    const max = range.max;

    for (const node of snapshot.docs) {
      const data = node.data();
      if (data.exact !== undefined) {
        // This node held an exact score.
        const newRange = bucket(value, min, max);
        const tempRange = bucket(data.exact.score, min, max);

        if (newRange.min === tempRange.min &&
          newRange.max === tempRange.max) {
          // The scores belong in the same range, so we need to "demote" both
          // to a lower level of the tree and convert this node to a range.
          const rangeData = {
            range: newRange,
            count: 2,
          };
          for (const write of pendingWrites) {
            write(transaction);
          }
          const docReference = node.ref;
          transaction.set(docReference, rangeData);
          transaction.create(docReference.collection("scores").doc(), data);
          transaction.create(
              docReference.collection("scores").doc(),
              {exact: {score: value, user: id}},
          );
          return;
        } else {
          // The scores are in different ranges. Continue and try to find a
          // range that fits this score.
          continue;
        }
      }

      if (data.range.min <= value && data.range.max > value) {
        // The score belongs to this range that may have subvalues.
        // Increment the range's count in pendingWrites, since
        // subsequent recursion may incur more reads.
        const docReference = node.ref;
        const newCount = node.get("count") + 1;
        pendingWrites.push((t) => {
          t.update(docReference, {count: newCount});
        });
        const newRange = bucket(value, min, max);
        return writeScoreToCollection(
            id,
            value,
            docReference.collection("scores"),
            newRange,
            transaction,
            pendingWrites,
        );
      }
    }

    // No appropriate range was found, create an `exact` value.
    transaction.create(coll.doc(), {exact: {score: value, user: id}});
  }

  const scores = firestore.collection("scores");
  const players = firestore.collection("players");
  return firestore.runTransaction((transaction) => {
    return writeScoreToCollection(
        playerID, score, scores, {min: 0, max: 1000}, transaction, [],
    ).then(() => {
      transaction.create(players.doc(), {
        user: playerID,
        score: score,
      });
    });
  });
}

Ini tentu lebih rumit daripada implementasi terakhir kami, yang merupakan panggilan metode tunggal dan hanya enam baris kode. Setelah menerapkan metode ini, coba tambahkan beberapa skor ke database dan amati struktur hierarki yang dihasilkan. Di konsol JS:

leaderboard.addScores();

Struktur database yang dihasilkan akan terlihat seperti ini, dengan struktur hierarki yang terlihat jelas dan node daun yang mewakili setiap skor.

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

Setelah menyelesaikan bagian yang sulit, kita dapat membaca skor dengan menelusuri hierarki seperti yang dijelaskan sebelumnya.

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = firestore.collection("scores");

  /**
   * Recursively finds a player score in a collection.
   * @param {string} id The player's ID, since some players may be tied.
   * @param {number} value The player's score.
   * @param {admin.firestore.CollectionReference} coll The collection to
   *     search.
   * @param {number} currentCount The current count of players ahead of the
   *     player.
   * @return {Promise<number>} The rank of the player (the number of players
   *     ahead of them plus one).
   */
  async function findPlayerScoreInCollection(id, value, coll, currentCount) {
    const snapshot = await coll.get();
    for (const doc of snapshot.docs) {
      if (doc.get("exact") !== undefined) {
        // This is an exact score. If it matches the score we're looking
        // for, return. Otherwise, check if it should be counted.
        const exact = doc.data().exact;
        if (exact.score === value) {
          if (exact.user === id) {
            // Score found.
            return currentCount + 1;
          } else {
            // The player is tied with another. In this case, don't increment
            // the count.
            continue;
          }
        } else if (exact.score > value) {
          // Increment count
          currentCount++;
          continue;
        } else {
          // Do nothing
          continue;
        }
      } else {
        // This is a range. If it matches the score we're looking for,
        // search the range recursively, otherwise, check if it should be
        // counted.
        const range = doc.data().range;
        const count = doc.get("count");
        if (range.min > value) {
          // The range is greater than the score, so add it to the rank
          // count.
          currentCount += count;
          continue;
        } else if (range.max <= value) {
          // do nothing
          continue;
        } else {
          const subcollection = doc.ref.collection("scores");
          return findPlayerScoreInCollection(
              id,
              value,
              subcollection,
              currentCount,
          );
        }
      }
    }
    // There was no range containing the score.
    throw Error(`Range not found for score: ${value}`);
  }

  const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
  return {
    user: playerID,
    rank: rank,
    score: score,
  };
}

Pembaruan dibiarkan sebagai latihan tambahan. Coba tambahkan dan ambil skor di konsol JS dengan metode leaderboard.addScore(id, score) dan leaderboard.getRank(id), lalu lihat perubahan papan peringkat Anda di Firebase console.

Namun, dengan 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 menerapkan 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 melanjutkan ke penerapan papan peringkat terakhir.

6. Menerapkan papan peringkat stokastik (probabilistik)

Saat menjalankan kode penyisipan, Anda mungkin melihat bahwa jika menjalankannya terlalu banyak secara paralel, fungsi Anda akan mulai gagal dengan pesan error yang terkait dengan pertentangan kunci transaksi. Ada cara untuk mengatasi hal ini yang tidak akan kita pelajari dalam codelab ini, tetapi jika Anda tidak memerlukan peringkat yang tepat, Anda dapat menghapus semua kompleksitas pendekatan sebelumnya untuk sesuatu yang lebih sederhana dan lebih cepat. Mari kita lihat bagaimana kita bisa mengembalikan perkiraan peringkat untuk skor alih-alih peringkat yang tepat, dan bagaimana hal itu mengubah logika {i>database<i} kita.

Untuk pendekatan ini, kita akan membagi papan peringkat menjadi 100 bucket, yang masing-masing mewakili sekitar satu persen skor yang diperkirakan akan kita terima. Pendekatan ini berfungsi bahkan tanpa mengetahui distribusi skor, sehingga tidak ada cara untuk menjamin distribusi skor yang merata di seluruh bucket, tetapi kami akan mencapai presisi yang lebih tinggi dalam perkiraan jika mengetahui bagaimana skor akan didistribusikan.

Pendekatan kami adalah sebagai berikut: seperti sebelumnya, setiap bucket menyimpan jumlah skor dalam dan rentang skor. 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. Hal ini memberi kita pencarian dan penyisipan waktu konstan yang sangat bagus, dan memerlukan lebih sedikit kode.

Pertama, penyisipan:

// Add this line to the top of your file.
const admin = require("firebase-admin");

// Implement this method (again).
async function createScore(playerID, score, firestore) {
  const scores = await firestore.collection("scores").get();
  if (scores.empty) {
    // Create the buckets since they don't exist yet.
    // In a real app, don't do this in your write function. Do it once
    // manually and then keep the buckets in your database forever.
    for (let i = 0; i < 10; i++) {
      const min = i * 100;
      const max = (i + 1) * 100;
      const data = {
        range: {
          min: min,
          max: max,
        },
        count: 0,
      };
      await firestore.collection("scores").doc().create(data);
    }
    throw Error("Database not initialized");
  }

  const buckets = await firestore.collection("scores")
      .where("range.min", "<=", score).get();
  for (const bucket of buckets.docs) {
    const range = bucket.get("range");
    if (score < range.max) {
      const writeBatch = firestore.batch();
      const playerDoc = firestore.collection("players").doc();
      writeBatch.create(playerDoc, {
        user: playerID,
        score: score,
      });
      writeBatch.update(
          bucket.ref,
          {count: admin.firestore.FieldValue.increment(1)},
      );
      const scoreDoc = bucket.ref.collection("scores").doc();
      writeBatch.create(scoreDoc, {
        user: playerID,
        score: score,
      });
      return writeBatch.commit();
    }
  }
}

Anda akan melihat 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 sama sekali tidak dilindungi dari kondisi race, jadi jika Anda melakukannya, beberapa operasi tulis 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();

Sekarang, untuk membaca skor:

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = await firestore.collection("scores").get();
  let currentCount = 1; // Player is rank 1 if there's 0 better players.
  let interp = -1;
  for (const bucket of scores.docs) {
    const range = bucket.get("range");
    const count = bucket.get("count");
    if (score < range.min) {
      currentCount += count;
    } else if (score >= range.max) {
      // do nothing
    } else {
      // interpolate where the user is in this bucket based on their score.
      const relativePosition = (score - range.min) / (range.max - range.min);
      interp = Math.round(count - (count * relativePosition));
    }
  }

  if (interp === -1) {
    // Didn't find a correct bucket
    throw Error(`Score out of bounds: ${score}`);
  }

  return {
    user: playerID,
    rank: currentCount + interp,
    score: score,
  };
}

Karena kita telah membuat fungsi addScores menghasilkan distribusi skor yang seragam dan kita menggunakan interpolasi linear dalam bucket, kita akan mendapatkan hasil yang sangat akurat, performa papan peringkat tidak akan menurun saat kita meningkatkan jumlah pengguna, dan kita tidak perlu khawatir tentang pertentangan kunci (sebanyak) saat memperbarui jumlah.

7. Adendum: Perbuatan curang

Tunggu, Anda mungkin berpikir, jika saya menulis nilai ke codelab melalui konsol JS di tab browser, tidak bisakah pemain saya berbohong pada papan peringkat dan mengatakan bahwa mereka mendapat skor tinggi yang tidak dicapai dengan adil?

Ya, tentu saja. Jika Anda ingin mencegah kecurangan, cara paling ampuh untuk melakukannya adalah dengan menonaktifkan penulisan klien ke database Anda melalui aturan keamanan, 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 solusi untuk mencegah kecurangan. Dengan insentif yang cukup besar, penipu dapat menemukan cara untuk mengakali validasi sisi server, dan banyak game video besar yang sukses terus-menerus bermain kucing-kucingan dengan penipu mereka untuk mengidentifikasi cheat baru dan menghentikannya agar tidak menyebar. Konsekuensi sulit dari fenomena ini adalah validasi sisi server untuk setiap game secara inheren dibuat khusus; 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.

Jika tidak ada validasi sisi server, untuk game yang cukup populer atau memiliki hambatan yang cukup rendah untuk melakukan kecurangan, papan peringkat akan berisi nilai teratas yang semuanya adalah penipu.

8. Selamat

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

Selanjutnya, lihat jalur pembelajaran untuk game.