با Firestore تابلوهای امتیازات بسازید

۱. مقدمه

آخرین به‌روزرسانی: 2023-01-27

برای ساخت جدول امتیازات چه چیزهایی لازم است؟

در اصل، جدول‌های امتیازات فقط جداولی از امتیازات هستند که یک عامل پیچیده دارند: خواندن رتبه برای هر امتیاز مشخص، مستلزم آگاهی از تمام امتیازات دیگر به ترتیب خاصی است. همچنین، اگر بازی شما موفق شود، جدول امتیازات شما بزرگ می‌شود و مرتباً از روی آن خوانده و نوشته می‌شود. برای ساختن یک جدول امتیازات موفق، باید بتواند این عملیات رتبه‌بندی را به سرعت انجام دهد.

آنچه خواهید ساخت

در این آزمایشگاه کد، شما جدول‌های امتیازات مختلفی را پیاده‌سازی خواهید کرد که هر کدام برای سناریوی متفاوتی مناسب هستند.

آنچه یاد خواهید گرفت

شما یاد خواهید گرفت که چگونه چهار جدول امتیازات مختلف را پیاده‌سازی کنید:

  • یک پیاده‌سازی ساده با استفاده از شمارش ساده رکوردها برای تعیین رتبه
  • یک جدول امتیازات ارزان و به‌روز شونده دوره‌ای
  • جدول امتیازات آنی با کمی بی‌نظمی در مورد درخت‌ها
  • یک جدول امتیازات تصادفی (احتمالی) برای رتبه‌بندی تقریبی پایگاه‌های بازیکنان بسیار بزرگ

آنچه نیاز دارید

  • نسخه جدید کروم (۱۰۷ یا بالاتر)
  • Node.js نسخه ۱۶ یا بالاتر (اگر از nvm استفاده می‌کنید، برای مشاهده شماره نسخه خود nvm --version را اجرا کنید)
  • یک طرح پولی Firebase Blaze (اختیاری)
  • رابط خط فرمان فایربیس نسخه ۱۱.۱۶.۰ یا بالاتر
    برای نصب رابط خط فرمان (CLI)، می‌توانید npm install -g firebase-tools اجرا کنید یا برای گزینه‌های نصب بیشتر به مستندات CLI مراجعه کنید.
  • آشنایی با جاوا اسکریپت، Cloud Firestore، توابع ابری و ابزارهای توسعه کروم

۲. راه‌اندازی

کد را دریافت کنید

ما هر آنچه را که برای این پروژه نیاز دارید در یک مخزن گیت قرار داده‌ایم. برای شروع، باید کد را دریافت کرده و آن را در محیط توسعه مورد علاقه خود باز کنید. برای این آزمایشگاه کد، ما از VS Code استفاده کردیم، اما هر ویرایشگر متنی هم مناسب است.

و فایل زیپ دانلود شده را از حالت فشرده خارج کنید.

یا، در دایرکتوری مورد نظر خود کلون کنید:

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

نقطه شروع ما کجاست؟

پروژه ما در حال حاضر یک صفحه خالی با چند تابع خالی است:

  • index.html شامل چند اسکریپت glue است که به ما اجازه می‌دهد توابع را از کنسول توسعه‌دهندگان فراخوانی کنیم و خروجی‌های آنها را ببینیم. ما از این برای ارتباط با backend خود و دیدن نتایج فراخوانی‌های تابع خود استفاده خواهیم کرد. در یک سناریوی واقعی، شما این فراخوانی‌های backend را مستقیماً از بازی خود انجام می‌دهید - ما در این codelab از یک بازی استفاده نمی‌کنیم زیرا انجام یک بازی هر بار که می‌خواهید امتیازی را به جدول امتیازات اضافه کنید، خیلی طول می‌کشد.
  • functions/index.js شامل تمام توابع ابری ما است. در اینجا برخی توابع کاربردی مانند addScores و deleteScores و همچنین توابعی که در این آزمایشگاه کد پیاده‌سازی خواهیم کرد را مشاهده خواهید کرد که توابع کمکی را در فایل دیگری فراخوانی می‌کنند.
  • functions/functions-helpers.js شامل توابع خالی است که ما پیاده‌سازی خواهیم کرد. برای هر جدول امتیازات، توابع خواندن، ایجاد و به‌روزرسانی را پیاده‌سازی خواهیم کرد و خواهید دید که چگونه انتخاب پیاده‌سازی ما بر پیچیدگی پیاده‌سازی و عملکرد مقیاس‌پذیری آن تأثیر می‌گذارد.
  • functions/utils.js شامل توابع کاربردی بیشتری است. ما در این آزمایشگاه کد به این فایل کاری نداریم.

ایجاد و راه‌اندازی یک پروژه Firebase

ایجاد یک پروژه جدید فایربیس

  1. با استفاده از حساب گوگل خود وارد کنسول فایربیس شوید.
  2. برای ایجاد یک پروژه جدید، روی دکمه کلیک کنید و سپس نام پروژه را وارد کنید (برای مثال، Leaderboards Codelab ).
  3. روی ادامه کلیک کنید.
  4. در صورت درخواست، شرایط Firebase را مرور و قبول کنید و سپس روی ادامه کلیک کنید.
  5. (اختیاری) دستیار هوش مصنوعی را در کنسول Firebase (با نام "Gemini در Firebase") فعال کنید.
  6. برای این codelab، به گوگل آنالیتیکس نیاز ندارید ، بنابراین گزینه گوگل آنالیتیکس را غیرفعال کنید .
  7. روی ایجاد پروژه کلیک کنید، منتظر بمانید تا پروژه شما آماده شود و سپس روی ادامه کلیک کنید.

راه اندازی محصولات فایربیس

  1. از منوی Build ، روی Functions کلیک کنید و در صورت درخواست، پروژه خود را برای استفاده از طرح قیمت‌گذاری Blaze ارتقا دهید.
  2. از منوی Build ، روی پایگاه داده Firestore کلیک کنید.
  3. در پنجره‌ی «ایجاد پایگاه داده» که ظاهر می‌شود، گزینه‌ی «شروع در حالت آزمایشی» را انتخاب کنید، سپس روی «بعدی» کلیک کنید.
  4. از منوی کشویی موقعیت مکانی Cloud Firestore ، منطقه‌ای را انتخاب کنید، سپس روی فعال کردن کلیک کنید.

جدول امتیازات خود را پیکربندی و اجرا کنید

  1. در ترمینال، به ریشه پروژه بروید و firebase use --add را اجرا کنید. پروژه Firebase که ایجاد کرده‌اید را انتخاب کنید.
  2. در ریشه پروژه، دستور firebase emulators:start --only hosting اجرا کنید.
  3. در مرورگر خود، به localhost:5000 بروید.
  4. کنسول جاوا اسکریپت Chrome DevTools را باز کنید و leaderboard.js را وارد کنید:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. leaderboard.codelab(); را در کنسول اجرا کنید. اگر پیام خوشامدگویی مشاهده کردید، یعنی همه چیز آماده است! در غیر این صورت، شبیه‌ساز را خاموش کنید و مراحل ۲ تا ۴ را دوباره اجرا کنید.

بیایید به اولین پیاده‌سازی جدول امتیازات بپردازیم.

۳. یک جدول امتیازات ساده پیاده‌سازی کنید

در پایان این بخش، می‌توانیم امتیازی را به جدول امتیازات اضافه کنیم و رتبه‌مان را به آن نشان دهیم.

قبل از اینکه شروع کنیم، بیایید نحوه‌ی کار این پیاده‌سازی جدول امتیازات را توضیح دهیم: همه بازیکنان در یک مجموعه واحد ذخیره می‌شوند و دریافت رتبه‌ی یک بازیکن با بازیابی مجموعه و شمارش تعداد بازیکنانی که از او جلوتر هستند انجام می‌شود. این کار درج و به‌روزرسانی امتیاز را آسان می‌کند. برای درج یک امتیاز جدید، فقط آن را به مجموعه اضافه می‌کنیم و برای به‌روزرسانی آن، کاربر فعلی خود را فیلتر می‌کنیم و سپس سند حاصل را به‌روزرسانی می‌کنیم. بیایید ببینیم که در کد چگونه به نظر می‌رسد.

در functions/functions-helper.js ، تابع createScore را پیاده‌سازی کنید که تقریباً سرراست‌ترین حالت ممکن است:

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

برای به‌روزرسانی نمرات، فقط باید یک بررسی خطا اضافه کنیم تا مطمئن شویم نمره‌ای که قرار است به‌روزرسانی شود، از قبل وجود دارد:

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

و در نهایت، تابع رتبه‌بندی ساده اما کمتر مقیاس‌پذیر ما:

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

بیایید آن را امتحان کنیم! توابع خود را با اجرای دستور زیر در ترمینال مستقر کنید:

firebase deploy --only functions

و سپس، در کنسول JS کروم، امتیازات دیگری اضافه کنید تا بتوانیم رتبه خود را در بین سایر بازیکنان ببینیم.

leaderboard.addScores(); // Results may take some time to appear.

حالا می‌توانیم امتیاز خودمان را به این مجموعه اضافه کنیم:

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

وقتی نوشتن تمام شد، باید در کنسول پاسخی با عنوان «امتیاز ایجاد شد» ببینید. آیا به جای آن خطایی می‌بینید؟ گزارش‌های توابع را از طریق کنسول Firebase باز کنید تا ببینید چه مشکلی پیش آمده است.

و در نهایت، می‌توانیم امتیاز خود را دریافت و به‌روزرسانی کنیم.

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

با این حال، این پیاده‌سازی، زمان و حافظه خطی نامطلوبی را برای واکشی رتبه امتیاز داده شده به ما می‌دهد. از آنجایی که زمان اجرای تابع و حافظه هر دو محدود هستند، این نه تنها به این معنی است که واکشی‌های ما به طور فزاینده‌ای کند می‌شوند، بلکه پس از اضافه شدن امتیازهای کافی به جدول امتیازات، توابع ما قبل از اینکه بتوانند نتیجه‌ای را برگردانند، دچار وقفه یا از کار می‌افتند. واضح است که اگر قرار است تعداد بازیکنان را فراتر از تعداد انگشت‌شماری افزایش دهیم، به چیزی بهتر نیاز خواهیم داشت.

اگر از طرفداران Firestore هستید، ممکن است با کوئری‌های تجمیعی COUNT آشنا باشید، که این جدول امتیازات را بسیار کارآمدتر می‌کند. و حق با شماست! با کوئری‌های COUNT، این جدول به خوبی در محدوده زیر یک میلیون کاربر مقیاس‌پذیر می‌شود، اگرچه عملکرد آن هنوز خطی است.

اما صبر کنید، ممکن است با خودتان فکر کنید، اگر قرار باشد به هر حال تمام اسناد موجود در مجموعه را بشماریم، می‌توانیم به هر سند یک رتبه اختصاص دهیم و سپس وقتی نیاز به واکشی آن داریم، واکشی‌های ما زمان و حافظه O(1) خواهند داشت! این ما را به رویکرد بعدی خود، یعنی جدول امتیازات با به‌روزرسانی دوره‌ای، هدایت می‌کند.

۴. یک جدول امتیازات با به‌روزرسانی دوره‌ای پیاده‌سازی کنید

نکته‌ی کلیدی این رویکرد، ذخیره‌ی رتبه در خود سند است، بنابراین واکشی آن، رتبه را بدون هیچ کار اضافی به ما می‌دهد. برای دستیابی به این هدف، به نوع جدیدی از تابع نیاز داریم.

در index.js ، کد زیر را اضافه کنید:

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

حالا عملیات خواندن، به‌روزرسانی و نوشتن ما همگی خوب و ساده هستند. نوشتن و به‌روزرسانی هر دو بدون تغییر باقی می‌مانند، اما خواندن به صورت زیر می‌شود (در 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"),
  };
}

متأسفانه، بدون اضافه کردن یک حساب صورتحساب به پروژه خود، نمی‌توانید این را مستقر و آزمایش کنید. اگر حساب صورتحساب دارید، فاصله زمانی را در تابع زمان‌بندی‌شده کوتاه کنید و ببینید که تابع شما به طور جادویی رتبه‌ها را به امتیازات جدول امتیازات شما اختصاص می‌دهد.

اگر اینطور نیست، تابع زمان‌بندی‌شده را حذف کنید و به پیاده‌سازی بعدی بروید.

با کلیک روی سه نقطه کنار مجموعه نمرات، نمرات موجود در پایگاه داده Firestore خود را حذف کنید تا برای بخش بعدی آماده شوید.

Firestore scores document page with\nDelete Collection activated

۵. یک جدول امتیازات درختی بلادرنگ پیاده‌سازی کنید

این رویکرد با ذخیره داده‌های جستجو در خود مجموعه پایگاه داده کار می‌کند. به جای داشتن یک مجموعه یکنواخت، هدف ما ذخیره همه چیز در یک درخت است که می‌توانیم با حرکت در اسناد از آن عبور کنیم. این به ما امکان می‌دهد یک جستجوی دودویی (یا n تایی) برای رتبه یک امتیاز مشخص انجام دهیم. این چه شکلی می‌تواند باشد؟

برای شروع، می‌خواهیم بتوانیم امتیازات خود را تقریباً در دسته‌های زوج توزیع کنیم، که این امر مستلزم آگاهی از مقادیر امتیازاتی است که کاربران ما ثبت می‌کنند؛ برای مثال، اگر در حال ساخت یک جدول امتیازات برای رتبه‌بندی مهارت در یک بازی رقابتی هستید، رتبه‌بندی مهارت کاربران شما تقریباً همیشه به صورت نرمال توزیع خواهد شد. تابع تولید امتیاز تصادفی ما از Math.random() جاوا اسکریپت استفاده می‌کند که منجر به توزیع تقریباً زوج می‌شود، بنابراین دسته‌های خود را به طور مساوی تقسیم خواهیم کرد.

در این مثال، برای سادگی از ۳ سطل استفاده خواهیم کرد، اما احتمالاً متوجه خواهید شد که اگر از این پیاده‌سازی در یک برنامه واقعی استفاده کنید، سطل‌های بیشتر نتایج سریع‌تری به همراه خواهند داشت - یک درخت کم‌عمق‌تر به طور متوسط ​​به معنای واکشی‌های کمتر مجموعه و رقابت کمتر در قفل است.

رتبه یک بازیکن از مجموع تعداد بازیکنانی که امتیاز بالاتری دارند به علاوه یک امتیاز برای خود بازیکن به دست می‌آید. هر مجموعه در زیر scores ، سه سند را ذخیره می‌کند که هر کدام دارای یک محدوده، تعداد اسناد زیر هر محدوده و سپس سه زیرمجموعه مربوطه هستند. برای خواندن یک رتبه، این درخت را با جستجوی یک امتیاز و پیگیری مجموع امتیازات بیشتر طی می‌کنیم. وقتی امتیاز خود را پیدا کردیم، مجموع صحیح را نیز خواهیم داشت.

نوشتن به طور قابل توجهی پیچیده‌تر است. اول، ما باید تمام نوشتن‌های خود را در یک تراکنش انجام دهیم تا از ناسازگاری داده‌ها هنگام وقوع چندین نوشتن یا خواندن همزمان جلوگیری کنیم. همچنین باید تمام شرایطی را که در بالا توضیح دادیم، هنگام پیمایش درخت برای نوشتن اسناد جدید خود حفظ کنیم. و در نهایت، از آنجایی که تمام پیچیدگی درخت این رویکرد جدید را با نیاز به ذخیره تمام اسناد اصلی خود ترکیب کرده‌ایم، هزینه ذخیره‌سازی ما کمی افزایش می‌یابد (اما هنوز خطی است).

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

این قطعاً پیچیده‌تر از پیاده‌سازی قبلی ما است که شامل یک فراخوانی متد و فقط شش خط کد بود. پس از پیاده‌سازی این متد، سعی کنید چند امتیاز به پایگاه داده اضافه کنید و ساختار درخت حاصل را مشاهده کنید. در کنسول جاوا اسکریپت خود:

leaderboard.addScores();

ساختار پایگاه داده حاصل باید چیزی شبیه به این باشد، که ساختار درختی آن به وضوح قابل مشاهده است و برگ‌های درخت نشان‌دهنده نمرات فردی هستند.

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

حالا که بخش سخت کار را پشت سر گذاشتیم، می‌توانیم با پیمایش درخت همانطور که قبلاً توضیح داده شد، امتیازها را بخوانیم.

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

به‌روزرسانی‌ها به عنوان یک تمرین اضافی باقی مانده‌اند. سعی کنید با استفاده از متدهای leaderboard.addScore(id, score) و leaderboard.getRank(id) در کنسول JS خود، امتیازات را اضافه و دریافت کنید و ببینید که جدول امتیازات شما در کنسول Firebase چگونه تغییر می‌کند.

با این پیاده‌سازی، پیچیدگی‌ای که برای دستیابی به عملکرد لگاریتمی اضافه کرده‌ایم، هزینه‌ای به همراه دارد.

  • اولاً، پیاده‌سازی این جدول امتیازات می‌تواند با مشکلات مربوط به قفل‌گذاری مواجه شود، زیرا تراکنش‌ها برای اطمینان از ثبات اسناد، نیاز به قفل‌گذاری خواندن و نوشتن در آنها دارند.
  • دوم، Firestore محدودیت عمق زیرمجموعه‌ها را ۱۰۰ تعیین می‌کند، به این معنی که شما باید از ایجاد زیردرخت‌ها پس از ۱۰۰ امتیاز مساوی خودداری کنید، که این پیاده‌سازی این محدودیت را ندارد.
  • و در نهایت، این جدول امتیازات فقط در حالت ایده‌آل که درخت متعادل است، به صورت لگاریتمی مقیاس‌بندی می‌شود - اگر نامتعادل باشد، بدترین حالت عملکرد این جدول امتیازات بار دیگر خطی است.

وقتی کارتان تمام شد، مجموعه scores و players را از طریق کنسول فایربیس حذف کنید و به سراغ آخرین پیاده‌سازی جدول امتیازات می‌رویم.

۶. یک جدول امتیازات تصادفی (احتمالی) پیاده‌سازی کنید

هنگام اجرای کد درج، ممکن است متوجه شوید که اگر آن را به صورت موازی بیش از حد اجرا کنید، توابع شما با یک پیام خطا مربوط به رقابت قفل تراکنش شروع به شکست می‌کنند. راه‌هایی برای دور زدن این مشکل وجود دارد که در این آزمایشگاه کد بررسی نمی‌کنیم، اما اگر به رتبه‌بندی دقیق نیاز ندارید، می‌توانید تمام پیچیدگی رویکرد قبلی را کنار بگذارید و از چیزی ساده‌تر و سریع‌تر استفاده کنید. بیایید نگاهی به نحوه‌ی بازگرداندن یک رتبه‌ی تخمینی برای امتیازات بازیکنانمان به جای رتبه‌بندی دقیق و چگونگی تغییر منطق پایگاه داده‌ی ما بیندازیم.

برای این رویکرد، جدول امتیازات خود را به ۱۰۰ دسته تقسیم می‌کنیم که هر کدام تقریباً یک درصد از امتیازاتی را که انتظار داریم دریافت کنیم، نشان می‌دهند. این رویکرد حتی بدون اطلاع از توزیع امتیازات ما نیز کار می‌کند، که در این صورت هیچ راهی برای تضمین توزیع تقریباً یکنواخت امتیازات در کل دسته نداریم، اما اگر بدانیم که امتیازات ما چگونه توزیع خواهند شد، به دقت بیشتری در تخمین‌های خود دست خواهیم یافت.

رویکرد ما به شرح زیر است: مانند قبل، هر سطل تعداد نمرات درون و محدوده نمرات را ذخیره می‌کند. هنگام درج یک نمره جدید، سطل مربوط به نمره را پیدا کرده و تعداد آن را افزایش می‌دهیم. هنگام واکشی یک رتبه، به جای جستجوی بیشتر، سطل‌های قبل از آن را جمع کرده و سپس درون سطل خود تقریب می‌زنیم. این روش جستجوها و درج‌های زمان ثابت بسیار خوبی را به ما می‌دهد و به کد بسیار کمتری نیاز دارد.

اول، درج:

// 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();
    }
  }
}

متوجه خواهید شد که این کد درج، منطقی برای مقداردهی اولیه وضعیت پایگاه داده شما در بالا دارد و هشدار می‌دهد که چنین کاری را در محیط عملیاتی انجام ندهید. کد مقداردهی اولیه به هیچ وجه در برابر شرایط رقابتی محافظت نشده است، بنابراین اگر این کار را انجام دهید، چندین بار نوشتن همزمان، با ایجاد تعدادی سطل تکراری، پایگاه داده شما را خراب می‌کند.

توابع خود را مستقر کنید و سپس یک درج اجرا کنید تا همه سطل‌ها با تعداد صفر مقداردهی اولیه شوند. این کار یک خطا برمی‌گرداند که می‌توانید با خیال راحت آن را نادیده بگیرید.

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

حالا که پایگاه داده به درستی مقداردهی اولیه شده است، می‌توانیم addScores اجرا کنیم و ساختار داده‌های خود را در کنسول Firebase ببینیم. ساختار حاصل بسیار مسطح‌تر از آخرین پیاده‌سازی ما است، هرچند که از نظر ظاهری مشابه هستند.

leaderboard.addScores();

و حالا، برای خواندن نمرات:

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

از آنجایی که ما تابع addScores طوری تنظیم کرده‌ایم که توزیع یکنواختی از امتیازات ایجاد کند و از درون‌یابی خطی درون سطل‌ها استفاده می‌کنیم، نتایج بسیار دقیقی خواهیم گرفت، عملکرد جدول امتیازات ما با افزایش تعداد کاربران کاهش نمی‌یابد و هنگام به‌روزرسانی تعداد، لازم نیست نگران تداخل قفل‌ها (به همان اندازه) باشیم.

۷. ضمیمه: تقلب

صبر کنید، ممکن است با خودتان فکر کنید، اگر من مقادیر را از طریق کنسول JS یک تب مرورگر در codelab خود بنویسم، آیا هیچ یک از بازیکنان من نمی‌توانند به جدول امتیازات دروغ بگویند و بگویند که امتیاز بالایی کسب کرده‌اند که به طور منصفانه به آن دست نیافته‌اند؟

بله، می‌توانند. اگر می‌خواهید از تقلب جلوگیری کنید، قوی‌ترین راه برای انجام این کار غیرفعال کردن نوشتن‌های کلاینت در پایگاه داده شما از طریق قوانین امنیتی ، دسترسی امن به توابع ابری شما است تا کلاینت‌ها نتوانند مستقیماً آنها را فراخوانی کنند و سپس قبل از ارسال به‌روزرسانی‌های امتیاز به جدول امتیازات، اقدامات درون بازی را روی سرور خود تأیید کنید.

لازم به ذکر است که این استراتژی، نوشدارویی برای تقلب نیست - با انگیزه‌ای به اندازه کافی بزرگ، متقلبان می‌توانند راه‌هایی برای دور زدن اعتبارسنجی‌های سمت سرور پیدا کنند و بسیاری از بازی‌های ویدیویی بزرگ و موفق دائماً با متقلبان خود موش و گربه بازی می‌کنند تا تقلب‌های جدید را شناسایی کرده و از تکثیر آنها جلوگیری کنند. یکی از پیامدهای دشوار این پدیده این است که اعتبارسنجی سمت سرور برای هر بازی ذاتاً سفارشی است. اگرچه Firebase ابزارهای ضد سوءاستفاده مانند App Check را ارائه می‌دهد که از کپی کردن بازی شما توسط کاربر از طریق یک کلاینت اسکریپت‌شده ساده جلوگیری می‌کند، Firebase هیچ سرویسی را ارائه نمی‌دهد که به عنوان یک ضد تقلب جامع عمل کند.

هر چیزی که اعتبارسنجی سمت سرور در آن کم باشد، برای یک بازی به اندازه کافی محبوب یا یک مانع به اندازه کافی کم برای تقلب، منجر به جدول امتیازاتی می‌شود که در آن همه بازیکنان متقلب در صدر جدول قرار دارند.

۸. تبریک

تبریک می‌گویم، شما با موفقیت چهار جدول امتیازات مختلف را در Firebase ساختید! بسته به نیاز بازی‌تان به دقت و سرعت، می‌توانید یکی را که با هزینه‌ای معقول برای شما مناسب است، انتخاب کنید.

در مرحله بعد، مسیرهای یادگیری بازی‌ها را بررسی کنید.