יצירת לוחות לידרבורד באמצעות Firestore

1. מבוא

העדכון האחרון: 27 בינואר 2023

מה צריך כדי ליצור טבלת באז?

בבסיסן, טבלאות של רשימות מובילים הן פשוט טבלאות של ציונים עם גורם אחד שמסבך את העניין: כדי לקרוא את הדירוג של ציון מסוים, צריך לדעת את כל הציונים האחרים בסדר כלשהו. בנוסף, אם המשחק שלכם יצליח, לוחות הלידרבורד יגדלו ויתבצעו בהם קריאה וכתיבה לעיתים קרובות. כדי ליצור טבלת הישגים מוצלחת, היא צריכה להיות מסוגלת לבצע את פעולת הדירוג הזו במהירות.

מה תפַתחו

ב-codelab הזה תטמיעו טבלאות שונות של דירוגים, שכל אחת מהן מתאימה לתרחיש אחר.

מה תלמדו

במאמר הזה נסביר איך להטמיע ארבע טבלאות שונות של דירוגים:

  • הטמעה פשוטה שמתבססת על ספירת רשומות כדי לקבוע את הדירוג
  • לידרבורד זול שמתעדכן מעת לעת
  • טבלת מובילים בזמן אמת עם קצת שטויות על עצים
  • טבלת באז סטוכסטית (הסתברותית) לדירוג משוער של בסיסי שחקנים גדולים מאוד

מה צריך להכין

  • גרסה עדכנית של Chrome (גרסה 107 ומעלה)
  • ‫Node.js 16 ואילך (מריצים את הפקודה nvm --version כדי לראות את מספר הגרסה אם משתמשים ב-nvm)
  • מינוי בתשלום לתוכנית Blaze ב-Firebase (אופציונלי)
  • Firebase CLI גרסה 11.16.0 ואילך
    כדי להתקין את ה-CLI, אפשר להריץ את הפקודה npm install -g firebase-tools או לעיין במסמכי התיעוד של ה-CLI כדי לראות אפשרויות התקנה נוספות.
  • ידע ב-JavaScript, ב-Cloud Firestore, ב-Cloud Functions ובכלי הפיתוח ל-Chrome

2. תהליך ההגדרה

קבל את הקוד

ריכזנו את כל מה שצריך לפרויקט הזה במאגר Git. כדי להתחיל, צריך להעתיק את הקוד ולפתוח אותו בסביבת הפיתוח המועדפת. ב-codelab הזה השתמשנו ב-VS Code, אבל אפשר להשתמש בכל כלי לעריכת טקסט.

ומחלצים את קובץ ה-ZIP שהורדתם.

אפשר גם לשכפל לספרייה הרצויה:

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

מה נקודת ההתחלה שלנו?

הפרויקט שלנו הוא כרגע דף חלק עם כמה פונקציות ריקות:

  • index.html מכיל כמה סקריפטים של דבק שמאפשרים לנו להפעיל פונקציות ממסוף הפיתוח ולראות את הפלט שלהן. נשתמש בזה כדי ליצור אינטראקציה עם ה-Backend ולראות את התוצאות של הפעלות הפונקציה. בתרחיש מהעולם האמיתי, הייתם מבצעים את הקריאות האלה לשרת ישירות מהמשחק – אנחנו לא משתמשים במשחק ב-codelab הזה כי ייקח יותר מדי זמן לשחק במשחק בכל פעם שרוצים להוסיף ניקוד לטבלת המובילים.
  • functions/index.js מכיל את כל הפונקציות של Cloud. יוצגו לכם כמה פונקציות עזר, כמו addScores ו-deleteScores, וגם הפונקציות שנשתמש בהן ב-codelab הזה, שקוראות לפונקציות עזר בקובץ אחר.
  • functions/functions-helpers.js מכיל את הפונקציות הריקות שנבצע. לכל טבלת הישגים, נטמיע פונקציות של קריאה, יצירה ועדכון, ותוכלו לראות איך הבחירה שלנו בהטמעה משפיעה על המורכבות של ההטמעה ועל ביצועי ההתאמה שלה.
  • functions/utils.js מכיל פונקציות שימושיות נוספות. לא נשתמש בקובץ הזה ב-codelab הזה.

יצירה והגדרה של פרויקט Firebase

יצירת פרויקט חדש ב-Firebase

  1. נכנסים למסוף Firebase באמצעות חשבון Google.
  2. לוחצים על הלחצן כדי ליצור פרויקט חדש, ואז מזינים שם לפרויקט (לדוגמה, Leaderboards Codelab).
  3. לוחצים על המשך.
  4. אם מוצגת בקשה לעשות זאת, קוראים ומאשרים את התנאים של Firebase, ואז לוחצים על המשך.
  5. (אופציונלי) מפעילים את העזרה מבוססת-AI במסוף Firebase (שנקראת Gemini ב-Firebase).
  6. ב-codelab הזה לא צריך להשתמש ב-Google Analytics, ולכן משביתים את האפשרות Google Analytics.
  7. לוחצים על יצירת פרויקט, מחכים שהפרויקט יוקצה ולוחצים על המשך.

הגדרת מוצרי Firebase

  1. בתפריט Build (בנייה), לוחצים על Functions (פונקציות), ואם מוצגת בקשה, משדרגים את הפרויקט לתוכנית התמחור Blaze.
  2. בתפריט Build (פיתוח), לוחצים על Firestore database (מסד נתונים של Firestore).
  3. בתיבת הדו-שיח יצירת מסד נתונים שמופיעה, בוחרים באפשרות התחלה במצב בדיקה ולוחצים על הבא.
  4. בוחרים אזור מהתפריט הנפתח מיקום 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

אחר כך, במסוף ה-JS של 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;
    });

עכשיו פעולות הקריאה, העדכון והכתיבה שלנו פשוטות ונוחות. הפעולות 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"),
  };
}

לצערנו, לא תוכלו לפרוס ולבדוק את הפרויקט הזה בלי להוסיף לו חשבון לחיוב. אם יש לכם חשבון לחיוב, תוכלו לקצר את המרווח בפונקציה המתוזמנת ולראות איך הפונקציה מקצה באופן אוטומטי דרגות לתוצאות שלכם בטבלת המובילים.

אם לא, מוחקים את הפונקציה המתוזמנת ועוברים להטמעה הבאה.

כדי להתכונן לקטע הבא, לוחצים על סמל האפשרויות הנוספות (3 נקודות) שלצד אוסף הציונים כדי למחוק את הציונים במסד הנתונים של Firestore.

דף המסמך של Firestore עם הפעלת האפשרות 'מחיקת אוסף'

5. הטמעה של לידרבורד של עץ בזמן אמת

הגישה הזו פועלת על ידי אחסון נתוני החיפוש באוסף מסדי הנתונים עצמו. במקום אוסף אחיד, המטרה שלנו היא לאחסן את הכול במבנה עץ שאפשר לעבור בו על ידי מעבר בין מסמכים. כך אנחנו יכולים לבצע חיפוש בינארי (או n-ארי) של הדירוג של ציון מסוים. איך זה יכול להיראות?

כדי להתחיל, נרצה לחלק את הציונים שלנו לקטגוריות שוות בערך, ולשם כך נצטרך לדעת מהם ערכי הציונים שהמשתמשים שלנו מתעדים. לדוגמה, אם אתם יוצרים טבלת הישגים לדירוג מיומנות במשחק תחרותי, דירוגי המיומנות של המשתמשים כמעט תמיד יתפלגו בצורה נורמלית. פונקציית יצירת הניקוד האקראי שלנו משתמשת ב-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,
      });
    });
  });
}

ההטמעה הזו מורכבת יותר מההטמעה הקודמת שלנו, שהייתה הפעלה של method אחת וכללה שש שורות קוד בלבד. אחרי שמטמיעים את השיטה הזו, כדאי לנסות להוסיף כמה ציונים למסד הנתונים ולבחון את המבנה של העץ שמתקבל. במסוף 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,
  };
}

העדכונים נשארים כתרגיל נוסף. אפשר לנסות להוסיף ולשלוף ציונים במסוף JS באמצעות השיטות leaderboard.addScore(id, score) ו-leaderboard.getRank(id) ולראות איך הטבלה משתנה במסוף Firebase.

עם זאת, ההטמעה הזו כרוכה בעלויות, כי הוספנו מורכבות כדי להשיג ביצועים לוגריתמיים.

  • קודם כל, יכול להיות שיישום של טבלת לידרבורד כזו ייתקל בבעיות של תחרות על נעילה, כי כדי לוודא שהמסמכים יישארו עקביים, טרנזקציות מחייבות נעילה של פעולות קריאה וכתיבה במסמכים.
  • שנית, ב-Firestore יש מגבלת עומק של 100 אוספי משנה, כלומר צריך להימנע מיצירת עצי משנה אחרי 100 ציונים שווים, ובהטמעה הזו לא נמנעים מכך.
  • לבסוף, הלידרבורד הזה מתרחב באופן לוגריתמי רק במקרה האידיאלי שבו העץ מאוזן – אם הוא לא מאוזן, הביצועים במקרה הגרוע של הלידרבורד הזה הם שוב לינאריים.

אחרי שתסיימו, תמחקו את הקולקציות scores ו-players דרך מסוף Firebase, ואנחנו נעבור להטמעה האחרונה של טבלת המובילים.

6. הטמעה של לידרבורד סטוכסטי (הסתברותי)

כשמריצים את קוד ההוספה, יכול להיות שאם תריצו אותו יותר מדי פעמים במקביל, הפונקציות יתחילו להיכשל עם הודעת שגיאה שקשורה לתחרות על נעילת טרנזקציות. יש דרכים לעקוף את הבעיה הזו, אבל לא נתעמק בהן ב-Codelab הזה. אם אתם לא צריכים דירוג מדויק, אתם יכולים לוותר על כל המורכבות של הגישה הקודמת ולבחור בגישה פשוטה ומהירה יותר. בואו נראה איך אפשר להחזיר דירוג משוער לניקוד של השחקנים במקום דירוג מדויק, ואיך זה משנה את הלוגיקה של מסד הנתונים.

בגישה הזו, נחלק את טבלת הדירוג ל-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 ליצור התפלגות אחידה של ציונים, ואנחנו משתמשים באינטרפולציה לינארית בתוך הדליים, נקבל תוצאות מדויקות מאוד, הביצועים של טבלת המובילים לא יורדים ככל שמספר המשתמשים גדל, ולא צריך לדאוג (יותר מדי) לגבי תחרות על נעילה (lock contention) כשמעדכנים את הספירות.

7. נספח: רמאות

רגע, אולי אתם חושבים, אם אני כותב ערכים ל-codelab דרך מסוף ה-JS של כרטיסיית דפדפן, האם שחקנים יכולים לשקר בטבלת המובילים ולומר שהם קיבלו ציון גבוה שלא הושג בצורה הוגנת?

כן, הם יכולים. אם רוצים למנוע רמאות, הדרך הכי טובה לעשות את זה היא להשבית את הכתיבה של הלקוח למסד הנתונים באמצעות כללי אבטחה, לאבטח את הגישה ל-Cloud Functions כדי שהלקוחות לא יוכלו להתקשר אליהם ישירות, ואז לאמת את הפעולות במשחק בשרת לפני שליחת עדכוני הניקוד לטבלת המובילים.

חשוב לציין שהאסטרטגיה הזו היא לא תרופת פלא נגד רמאות – אם התמריץ לרמאות גדול מספיק, הרמאים יכולים למצוא דרכים לעקוף את האימותים בצד השרת, ובמשחקי וידאו רבים ופופולריים מתנהל כל הזמן משחק חתול ועכבר עם הרמאים כדי לזהות שיטות רמאות חדשות ולמנוע את ההתפשטות שלהן. אחת התוצאות הבעייתיות של התופעה הזו היא שאימות בצד השרת לכל משחק הוא ייחודי. אמנם Firebase מספקת כלים למניעת התנהלות פוגעת כמו App Check, שימנעו ממשתמש להעתיק את המשחק שלכם באמצעות לקוח פשוט מבוסס-סקריפט, אבל Firebase לא מספקת שירות שמהווה פתרון הוליסטי למניעת רמאות.

אם לא תשתמשו באימות בצד השרת, במשחק פופולרי או במשחק שקל לרמות בו, יכול להיות שכל הערכים הגבוהים בטבלת המובילים יהיו של רמאים.

8. מזל טוב

כל הכבוד, הצלחת ליצור ארבע טבלאות שונות של דירוגים ב-Firebase! בהתאם לצרכים של המשחק מבחינת דיוק ומהירות, תוכלו לבחור את האפשרות המתאימה לכם במחיר סביר.

בשלב הבא, כדאי לעיין בתוכניות הלימודים למשחקים.