كوِّن قوائم الصدارة باستخدام Firestore

1- مقدمة

تاريخ آخر تعديل: 27/01/2023

ما المطلوب لإنشاء قائمة الصدارة؟

لوحات الصدارة في جوهرها هي مجرد جداول للدرجات مع عامل معقد واحد: تتطلب قراءة ترتيب لأي درجة معينة معرفة جميع الدرجات الأخرى بأي نوع من الترتيب. بالإضافة إلى ذلك، في حال إطلاق لعبتك، ستزداد قوائم الصدارة في الحجم وستتم قراءتها من خلالها وكتابتها بشكل متكرر. ولإنشاء قائمة صدارة ناجحة، يجب أن تكون قادرة على التعامل مع عملية الترتيب هذه بسرعة.

ما الذي ستقوم ببنائه

في هذا الدرس التطبيقي حول الترميز، ستُنفِّذ لوحات صدارة مختلفة، وكل منها مناسب لسيناريو مختلف.

المعلومات التي ستطّلع عليها

وستتعرّف على كيفية إنشاء أربع لوحات صدارة مختلفة:

  • يشير ذلك المصطلح إلى عملية تنفيذ سلِسة تستند إلى طريقة احتساب السجلات البسيطة لتحديد الترتيب.
  • لوحة صدارة رخيصة الثمن يتم تحديثها بشكل دوري
  • لوحة صدارة في الوقت الفعلي مع بعض البيانات البسيطة الشجرية
  • لوحة الصدارة العشوائية (احتمالية) التي تقدّم ترتيبًا تقريبيًا لقواعد اللاعبين الكبيرة جدًا

المتطلبات

  • إصدار حديث من Chrome (الإصدار 107 أو إصدار أحدث)
  • Node.js 16 أو إصدار أحدث (شغِّل nvm --version لمعرفة رقم الإصدار إذا كنت تستخدم nvm)
  • خطة Firebase Blaze المدفوعة (اختيارية)
  • الإصدار 11.16.0 من واجهة سطر الأوامر في Firebase أو إصدار أحدث
    لتثبيت واجهة سطر الأوامر، يمكنك تشغيل npm install -g firebase-tools أو الرجوع إلى وثائق واجهة سطر الأوامر للاطّلاع على المزيد من خيارات التثبيت.
  • معرفة JavaScript وCloud Firestore و"وظائف السحابة الإلكترونية" وأدوات مطوري البرامج في Chrome

2- بدء الإعداد

حصول على الشفرة

لقد وضعنا كل ما تحتاجه لهذا المشروع في مستودع Git. للبدء، يجب جلب الرمز وفتحه في بيئة تطوير البرامج المفضّلة لديك. في هذا الدرس التطبيقي حول الترميز، استخدمنا لغة VS Code، لكنّ أي محرر نصوص سيستخدم ذلك الإجراء.

وفُكَّ حزمة ملف ZIP الذي تم تنزيله.

أو استنساخه إلى الدليل الذي تختاره:

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

ما هي نقطة البداية؟

يُعد مشروعنا حاليًا قائمة فارغة مع بعض الدوال الفارغة:

  • يحتوي index.html على بعض النصوص البرمجية الملتصقة التي تتيح لنا استدعاء الدوال من وحدة تحكم dev Console والاطّلاع على نتائجها. سنستخدم هذا العنوان للتواصل مع الواجهة الخلفية والاطّلاع على نتائج استدعاء الدوال. في الواقع، سيكون عليك إجراء هذه المكالمات في الخلفية من لعبتك مباشرةً، لأنّنا لا نستخدم لعبة في هذا الدرس التطبيقي حول الترميز لأنّه يستغرق وقتًا طويلاً لتشغيل لعبة في كل مرة تريد فيها إضافة نتيجة إلى قائمة الصدارة.
  • يحتوي النطاق functions/index.js على جميع دوال السحابة الإلكترونية. ستظهر لك بعض دوال الأدوات المساعدة، مثل addScores وdeleteScores، بالإضافة إلى الدوال التي سننفذها في هذا الدرس التطبيقي حول الترميز، والتي تستدعي الدوال المساعدة في ملف آخر.
  • يحتوي functions/functions-helpers.js على الدوال الفارغة التي سننفذها. بالنسبة إلى كل قائمة صدارة، سنعمل على تنفيذ وظائف القراءة والإنشاء والتحديث، وستشاهد مدى تأثير اختيارنا لعملية التنفيذ على كلٍّ من تعقيد عملية التنفيذ ومستوى أدائها.
  • يحتوي functions/utils.js على المزيد من دوال الأدوات المساعدة. لن نتطرق إلى هذا الملف في هذا الدرس التطبيقي حول الترميز.

إنشاء مشروع على Firebase وإعداده

  1. في وحدة تحكُّم Firebase، انقر على إضافة مشروع.
  2. لإنشاء مشروع جديد، أدخِل اسم المشروع المطلوب.
    سيؤدي ذلك أيضًا إلى ضبط رقم تعريف المشروع (المعروض أسفل اسم المشروع) على رقم استنادًا إلى اسم المشروع. يمكنك النقر على الرمز تعديل في رقم تعريف المشروع اختياريًا لتخصيصه بشكل أكبر.
  3. راجِع بنود Firebase واقبلها إذا طُلب منك ذلك.
  4. انقر على متابعة.
  5. حدِّد الخيار تفعيل "إحصاءات Google" لهذا المشروع، ثمّ انقر على متابعة.
  6. اختَر حسابًا حاليًا على "إحصاءات Google" لاستخدامه أو اختَر إنشاء حساب جديد لإنشاء حساب جديد.
  7. انقر على إنشاء مشروع.
  8. عند إنشاء المشروع، انقر على متابعة.
  9. من القائمة إنشاء، انقر على الوظائف وإذا طُلب منك، يمكنك ترقية مشروعك لاستخدام خطة فوترة Blaze.
  10. من القائمة إنشاء، انقر على قاعدة بيانات Firestore.
  11. في مربّع الحوار إنشاء قاعدة بيانات الذي يظهر، اختَر البدء في وضع الاختبار، ثم انقر على التالي.
  12. اختَر منطقة من القائمة المنسدلة موقع Cloud Firestore، ثم انقر على تفعيل.

ضبط لوحة الصدارة وتشغيلها

  1. في الوحدة الطرفية، انتقِل إلى جذر المشروع وشغِّل firebase use --add. اختَر مشروع Firebase الذي أنشأته للتو.
  2. في جذر المشروع، شغِّل firebase emulators:start --only hosting.
  3. في المتصفّح، انتقِل إلى localhost:5000.
  4. افتح وحدة تحكّم JavaScript في "أدوات مطوري البرامج في Chrome" واستورِد 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

وبعد ذلك، يمكنك إضافة بعض النتائج الأخرى في وحدة تحكم JavaScript في Chrome لنتمكّن من الاطّلاع على ترتيبنا بين اللاعبين الآخرين.

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

الآن، أصبحت جميع عمليات القراءة والتحديث والكتابة رائعة وبسيطة. لم يتم إجراء أي تغيير على خيارَي الكتابة والتعديل، ولكن ستصبح القراءة (في 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 مع\nتفعيل "حذف المجموعة"

5- تنفيذ قائمة صدارة على شكل شجرة في الوقت الفعلي

يعمل هذا الأسلوب عن طريق تخزين بيانات البحث في مجموعة قاعدة البيانات نفسها. فبدلاً من وجود مجموعة موحَّدة، يتمثل هدفنا في تخزين كل شيء في شجرة يمكننا اجتيازها بالتنقُّل بين المستندات. ويسمح لنا هذا بإجراء بحث ثنائي (أو نظام nary) عن ترتيب درجة معينة. كيف يمكن أن يبدو ذلك؟

كبداية، سنكون قادرين على توزيع نتائجنا على مجموعات موحدة تقريبًا، والتي ستتطلب بعض المعرفة بقيم النتائج التي يسجلها المستخدمون؛ على سبيل المثال، إذا كنت تبني قائمة صدارة لتقييم المهارات في لعبة تنافسية، فإن وتصنيفات المهارات غالبًا ما يتم توزيعها بشكل طبيعي. تستخدم دالة إنشاء النتائج العشوائية Math.random() في JavaScript، ما يؤدي إلى توزيع متساوي تقريبًا، ولذلك سنقسم المجموعات بالتساوي.

في هذا المثال، سنستخدم 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,
      });
    });
  });
}

وهذا الأمر بالتأكيد أكثر تعقيدًا من عملية التنفيذ الأخيرة، والتي كانت تتطلب استدعاء طريقة واحدة فقط وستة أسطر من الرموز. وبعد تطبيق هذه الطريقة، جرِّب إضافة بعض النقاط إلى قاعدة البيانات ومراقبة بنية الشجرة الناتجة. في وحدة تحكّم JavaScript:

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

تُترك التحديثات كتمرين إضافي. حاول إضافة النتائج وجلبها في وحدة تحكم JavaScript باستخدام الطريقتين leaderboard.addScore(id, score) وleaderboard.getRank(id)، وشاهد مدى تغيُّر لوحة الصدارة في وحدة تحكُّم 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- ملحق: الغش

مهلاً، قد يكون السؤال: إذا كنت أكتب قيمًا في الدرس التطبيقي حول الترميز باستخدام وحدة تحكم JavaScript ضمن علامة تبويب في المتصفح، ألا يستطيع أي من اللاعبين الاكتفاء بلوحة الصدارة والقول إنّهم حصلوا على نتيجة عالية لم يحقّقوها بشكل عادل؟

نعم، يمكنهم ذلك. لمنع الغش، فإن الطريقة الأكثر فعالية لإجراء ذلك هي إيقاف عمليات الكتابة من قِبل العميل في قاعدة بياناتك من خلال قواعد الأمان والوصول الآمن إلى دوال Cloud كي لا يتمكّن العملاء من الاتصال بهم مباشرةً، ثم التحقّق من صحة الإجراءات داخل اللعبة على خادمك قبل إرسال تعديلات النتائج إلى قائمة الصدارة.

من المهم أن نلاحظ أن هذه الاستراتيجية لا توفر علاجًا للغشّ. فمع توفّر حافز كبير بما يكفي، يمكن للغشّين إيجاد طرق للتحايل على عمليات التحقّق من جهة الخادم، وتلعب العديد من ألعاب الفيديو الناجحة والكبيرة باستمرار مع الغشاش لتحديد عمليات الغش الجديدة ومنعها من الانتشار. ونتيجة لذلك، تتمثل النتيجة الصعبة لهذه الظاهرة في أن عملية التحقق من جهة الخادم لكل لعبة هي بطبيعة الحال. على الرغم من أنّ منصة Firebase توفّر أدوات لمكافحة إساءة الاستخدام، مثل "التحقّق من التطبيقات" والتي ستمنع المستخدم من نسخ لعبتك باستخدام برنامج يستند إلى نص برمجي بسيط، لا توفّر منصة Firebase أي خدمة تعتبر أسلوبًا شاملاً لمكافحة الغش.

ولا يؤدي أي شيء أقل من عملية التحقق من جهة الخادم، بالنسبة إلى لعبة رائجة بما فيه الكفاية أو حاجز كافٍ للغش، إلى ظهور ليدربورد تكون أعلى القيَم فيه كلها غشًا.

8- تهانينا

تهانينا، لقد نجحت في إنشاء أربع لوحات صدارة مختلفة على Firebase. بناءً على ما تحتاج إليه لعبتك من حيث الدقة والسرعة، سيكون بإمكانك اختيار اللعبة التي تناسبك بتكلفة معقولة.

اطّلِع بعد ذلك على المسارات التعليمية للألعاب.