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

1. معرفی

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

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

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

چیزی که خواهی ساخت

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

چیزی که یاد خواهید گرفت

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

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

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

  • نسخه اخیر کروم (107 یا بالاتر)
  • Node.js 16 یا بالاتر (اگر از nvm استفاده می کنید nvm --version اجرا کنید تا شماره نسخه خود را ببینید)
  • طرح Firebase Blaze پولی (اختیاری)
  • Firebase CLI نسخه 11.16.0 یا بالاتر
    برای نصب CLI، می توانید npm install -g firebase-tools اجرا کنید یا برای گزینه های نصب بیشتر به مستندات CLI مراجعه کنید.
  • آشنایی با JavaScript، Cloud Firestore، Cloud Functions و Chrome DevTools

2. راه اندازی

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

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

و فایل فشرده دانلود شده را باز کنید.

یا در فهرست انتخابی خود کلون کنید:

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

نقطه شروع ما چیست؟

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

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

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

  1. در کنسول Firebase ، روی افزودن پروژه کلیک کنید.
  2. برای ایجاد یک پروژه جدید، نام پروژه مورد نظر را وارد کنید.
    با این کار شناسه پروژه (که در زیر نام پروژه نمایش داده می شود) بر اساس نام پروژه نیز تعیین می شود. برای سفارشی سازی بیشتر می توانید به صورت اختیاری روی نماد ویرایش در شناسه پروژه کلیک کنید.
  3. در صورت درخواست، شرایط Firebase را بررسی کرده و بپذیرید.
  4. روی Continue کلیک کنید.
  5. گزینه Enable Google Analytics for this project را انتخاب کنید و سپس روی Continue کلیک کنید.
  6. یک حساب Google Analytics موجود را برای استفاده انتخاب کنید یا برای ایجاد حساب جدید Create a new account را انتخاب کنید.
  7. روی ایجاد پروژه کلیک کنید.
  8. پس از ایجاد پروژه، روی Continue کلیک کنید.
  9. از منوی ساخت ، روی توابع کلیک کنید و در صورت درخواست، پروژه خود را برای استفاده از طرح صورتحساب Blaze ارتقا دهید.
  10. از منوی Build ، روی پایگاه داده Firestore کلیک کنید.
  11. در گفتگوی ایجاد پایگاه داده که ظاهر می شود، Start in test mode را انتخاب کنید، سپس روی Next کلیک کنید.
  12. یک منطقه را از منوی کشویی 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(); در کنسول اگر پیام خوشامدگویی می بینید، همه چیز آماده است! اگر نه، شبیه ساز را خاموش کنید و مراحل 2-4 را دوباره اجرا کنید.

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

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

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

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

در 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) خواهد بود. زمان و حافظه! این ما را به رویکرد بعدی ما هدایت می کند، تابلوی امتیازات که به طور دوره ای به روز می شود.

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

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

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

اکنون عملیات خواندن، به‌روزرسانی و نوشتن ما همگی زیبا و ساده هستند. Write و Update هر دو بدون تغییر هستند، اما read تبدیل می شود (در 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 خود با کلیک بر روی 3 نقطه کنار مجموعه امتیازات حذف کنید تا برای بخش بعدی آماده شوید.

Firestore scores document page with\nDelete Collection activated

5. یک تابلوی درختی بلادرنگ پیاده سازی کنید

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

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

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

رتبه یک بازیکن با مجموع تعداد بازیکنانی که امتیازات بالاتری دارند به اضافه یک برای خود بازیکن داده می شود. هر مجموعه تحت 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,
      });
    });
  });
}

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

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 محدودیت عمق زیر مجموعه را 100 اعمال می‌کند، به این معنی که باید از ایجاد زیردرخت‌ها پس از 100 امتیاز متقابل اجتناب کنید، که این پیاده‌سازی اینطور نیست.
  • و در نهایت، این تابلوی امتیازات تنها در حالت ایده‌آل که درخت متعادل است، مقیاس لگاریتمی می‌شود – اگر نامتعادل باشد، بدترین حالت عملکرد این تابلوی امتیازات یک بار دیگر خطی است.

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

6. یک تابلوی امتیازات تصادفی (احتمالی) پیاده سازی کنید

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

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

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

اول، درج:

// 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 توزیع یکنواخت امتیازات را ایجاد کرده‌ایم و از درون یابی خطی در سطل‌ها استفاده می‌کنیم، نتایج بسیار دقیقی دریافت خواهیم کرد، با افزایش تعداد کاربران، عملکرد تابلوی امتیازات ما کاهش نمی‌یابد. و زمانی که به‌روزرسانی حساب می‌شود، لازم نیست نگران بحث قفل (به همان اندازه) باشیم.

7. الحاقیه: تقلب

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

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

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

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

8. تبریک می گویم

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

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