Firestore की मदद से लीडरबोर्ड बनाएं

1. शुरुआती जानकारी

पिछले अपडेट की तारीख: 27-01-2023

लीडरबोर्ड बनाने के लिए क्या करना होगा?

लीडरबोर्ड, स्कोर की टेबल होती हैं. हालांकि, इनमें एक मुश्किल यह होती है कि किसी भी स्कोर की रैंक जानने के लिए, आपको सभी स्कोर के बारे में क्रम से जानकारी होनी चाहिए. इसके अलावा, अगर आपका गेम लोकप्रिय हो जाता है, तो आपके लीडरबोर्ड में ज़्यादा डेटा होगा. साथ ही, उन्हें बार-बार पढ़ा और लिखा जाएगा. बेहतर लीडरबोर्ड बनाने के लिए, यह ज़रूरी है कि वह रैंकिंग के इस ऑपरेशन को तुरंत पूरा कर सके.

आपको क्या बनाना है

इस कोडलैब में, अलग-अलग तरह के लीडरबोर्ड लागू किए जाएंगे. हर लीडरबोर्ड, अलग-अलग स्थितियों के लिए सही होगा.

आपको क्या सीखने को मिलेगा

आपको चार अलग-अलग लीडरबोर्ड लागू करने का तरीका पता चलेगा:

  • रैंक तय करने के लिए, रिकॉर्ड की गिनती करने की आसान सुविधा का इस्तेमाल करने वाला एक सामान्य तरीका
  • सस्ता और समय-समय पर अपडेट होने वाला लीडरबोर्ड
  • रीयल-टाइम लीडरबोर्ड, जिसमें कुछ पेड़-पौधों के बारे में बकवास जानकारी दी गई है
  • बहुत ज़्यादा खिलाड़ियों के लिए, अनुमानित रैंकिंग दिखाने वाला स्टोकास्टिक (संभावित) लीडरबोर्ड

आपको इनकी ज़रूरत होगी

  • Chrome का नया वर्शन (107 या इसके बाद का वर्शन)
  • Node.js 16 या इसके बाद का वर्शन (अगर nvm का इस्तेमाल किया जा रहा है, तो अपना वर्शन नंबर देखने के लिए nvm --version चलाएं)
  • Firebase का सशुल्क ब्लेज़ प्लान (ज़रूरी नहीं)
  • Firebase CLI v11.16.0 या इसके बाद का वर्शन
    सीएलआई इंस्टॉल करने के लिए, npm install -g firebase-tools को चलाया जा सकता है. इसके अलावा, इंस्टॉल करने के ज़्यादा विकल्पों के लिए, सीएलआई का दस्तावेज़ देखें.
  • JavaScript, Cloud Firestore, Cloud Functions, और Chrome DevTools के बारे में जानकारी

2. सेट अप करना

कोड प्राप्त करें

हमने इस प्रोजेक्ट के लिए ज़रूरी सभी चीज़ों को Git repo में डाल दिया है. शुरू करने के लिए, आपको कोड को अपने पसंदीदा डेवलपमेंट एनवायरमेंट में खोलना होगा. इस कोडलैब के लिए, हमने VS Code का इस्तेमाल किया है. हालांकि, किसी भी टेक्स्ट एडिटर का इस्तेमाल किया जा सकता है.

और डाउनलोड की गई ज़िप फ़ाइल को अनपैक करें.

इसके अलावा, अपनी पसंद की डायरेक्ट्री में क्लोन करें:

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

हम कहां से शुरू करें?

फ़िलहाल, हमारा प्रोजेक्ट एक खाली स्लेट है, जिसमें कुछ फ़ंक्शन खाली हैं:

  • index.html में कुछ ग्लू स्क्रिप्ट होती हैं. इनकी मदद से, हम डेवलपर कंसोल से फ़ंक्शन शुरू कर सकते हैं और उनके आउटपुट देख सकते हैं. हम इसका इस्तेमाल अपने बैकएंड के साथ इंटरफ़ेस करने के लिए करेंगे. साथ ही, इससे हमें फ़ंक्शन इनवोकेशन के नतीजे देखने में मदद मिलेगी. असल दुनिया में, आपको अपने गेम से सीधे तौर पर ये बैकएंड कॉल करने होंगे. हम इस कोडलैब में गेम का इस्तेमाल नहीं कर रहे हैं, क्योंकि हर बार लीडरबोर्ड में स्कोर जोड़ने के लिए गेम खेलने में बहुत समय लगेगा.
  • functions/index.js में हमारे सभी Cloud Functions शामिल हैं. आपको कुछ यूटिलिटी फ़ंक्शन दिखेंगे, जैसे कि addScores और deleteScores. साथ ही, वे फ़ंक्शन भी दिखेंगे जिन्हें हम इस कोडलैब में लागू करेंगे. ये फ़ंक्शन, दूसरी फ़ाइल में मौजूद हेल्पर फ़ंक्शन को कॉल करते हैं.
  • functions/functions-helpers.js में ऐसे फ़ंक्शन शामिल हैं जिन्हें हम लागू करेंगे. हम हर लीडरबोर्ड के लिए, पढ़ने, बनाने, और अपडेट करने की सुविधाएं लागू करेंगे. साथ ही, आपको दिखेगा कि इन सुविधाओं को लागू करने के हमारे तरीके से, इन्हें लागू करने की जटिलता और इनकी परफ़ॉर्मेंस को बेहतर बनाने पर क्या असर पड़ता है.
  • functions/utils.js में ज़्यादा यूटिलिटी फ़ंक्शन होते हैं. हम इस कोडलैब में इस फ़ाइल में कोई बदलाव नहीं करेंगे.

Firebase प्रोजेक्ट बनाना और उसे सेट अप करना

नया Firebase प्रोजेक्ट बनाना

  1. अपने Google खाते का इस्तेमाल करके, Firebase कंसोल में साइन इन करें.
  2. नया प्रोजेक्ट बनाने के लिए, बटन पर क्लिक करें. इसके बाद, प्रोजेक्ट का नाम डालें. उदाहरण के लिए, Leaderboards Codelab.
  3. जारी रखें पर क्लिक करें.
  4. अगर आपसे कहा जाए, तो Firebase की शर्तें पढ़ें और स्वीकार करें. इसके बाद, जारी रखें पर क्लिक करें.
  5. (ज़रूरी नहीं) Firebase कंसोल में एआई की मदद पाने की सुविधा चालू करें. इसे "Firebase में Gemini" कहा जाता है.
  6. इस कोडलैब के लिए, आपको Google Analytics की ज़रूरत नहीं है. इसलिए, Google Analytics के विकल्प को टॉगल करके बंद करें.
  7. प्रोजेक्ट बनाएं पर क्लिक करें. इसके बाद, प्रोजेक्ट के प्रोविज़न होने का इंतज़ार करें. इसके बाद, जारी रखें पर क्लिक करें.

Firebase प्रॉडक्ट सेट अप करना

  1. बनाएं मेन्यू में जाकर, फ़ंक्शन पर क्लिक करें. अगर आपसे कहा जाए, तो ब्लेज़ प्राइसिंग प्लान का इस्तेमाल करने के लिए अपने प्रोजेक्ट को अपग्रेड करें.
  2. बनाएं मेन्यू में जाकर, Firestore डेटाबेस पर क्लिक करें.
  3. दिखने वाले डेटाबेस बनाएं डायलॉग में, टेस्ट मोड में शुरू करें को चुनें. इसके बाद, आगे बढ़ें पर क्लिक करें.
  4. Cloud Firestore लोकेशन ड्रॉप-डाउन से कोई क्षेत्र चुनें. इसके बाद, चालू करें पर क्लिक करें.

लीडरबोर्ड को कॉन्फ़िगर करना और उसे चलाना

  1. टर्मिनल में, प्रोजेक्ट रूट पर जाएं और firebase use --add चलाएं. आपने अभी-अभी जो Firebase प्रोजेक्ट बनाया है उसे चुनें.
  2. प्रोजेक्ट के रूट में, firebase emulators:start --only hosting चलाएं.
  3. अपने ब्राउज़र में, localhost:5000 पर जाएं.
  4. Chrome DevTools का JavaScript कंसोल खोलें और 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

इसके बाद, Chrome की 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;
    });

अब पढ़ने, अपडेट करने, और लिखने से जुड़े सभी काम आसानी से किए जा सकते हैं. लिखने और अपडेट करने की अनुमतियों में कोई बदलाव नहीं हुआ है. हालांकि, पढ़ने की अनुमति (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 में, Delete Collection की सुविधा चालू होने पर, दस्तावेज़ के पेज के स्कोर

5. रीयल-टाइम ट्री लीडरबोर्ड लागू करना

इस तरीके में, खोज से जुड़े डेटा को डेटाबेस कलेक्शन में ही सेव किया जाता है. हमारा लक्ष्य, एक जैसा कलेक्शन बनाने के बजाय, हर चीज़ को एक ट्री में सेव करना है. इससे हम दस्तावेज़ों के ज़रिए ट्री में घूम सकते हैं. इससे हमें किसी दिए गए स्कोर की रैंक के लिए बाइनरी (या एन-एरी) खोज करने की अनुमति मिलती है. यह कैसा दिख सकता है?

शुरुआत में, हम अपने स्कोर को लगभग बराबर बकेट में बांटना चाहेंगे. इसके लिए, हमें उन स्कोर की वैल्यू के बारे में कुछ जानकारी चाहिए जिन्हें हमारे उपयोगकर्ता लॉग कर रहे हैं. उदाहरण के लिए, अगर आपको किसी प्रतिस्पर्धी गेम में कौशल की रेटिंग के लिए लीडरबोर्ड बनाना है, तो आपके उपयोगकर्ताओं के कौशल की रेटिंग लगभग हमेशा सामान्य रूप से डिस्ट्रिब्यूट की जाएगी. स्कोर जनरेट करने वाला हमारा रैंडम फ़ंक्शन, JavaScript के 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,
      });
    });
  });
}

यह तरीका, हमारे पिछले तरीके से ज़्यादा मुश्किल है. पिछले तरीके में, सिर्फ़ एक तरीके को कॉल किया गया था और कोड की सिर्फ़ छह लाइनें थीं. इस तरीके को लागू करने के बाद, डेटाबेस में कुछ स्कोर जोड़ें और नतीजे के तौर पर मिले ट्री के स्ट्रक्चर को देखें. 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 टाइड स्कोर के बाद सबट्री बनाने से बचना होगा. हालांकि, इस तरीके में ऐसा नहीं किया गया है.
  • आखिर में, यह लीडरबोर्ड सिर्फ़ उस स्थिति में लॉगरिथमिक तरीके से बढ़ता है जब ट्री बैलेंस हो. अगर यह बैलेंस नहीं है, तो इस लीडरबोर्ड की परफ़ॉर्मेंस सबसे खराब होती है.

इसके बाद, Firebase कंसोल के ज़रिए scores और players कलेक्शन मिटाएं. इसके बाद, हम लीडरबोर्ड को लागू करने के आखिरी चरण पर पहुंच जाएंगे.

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 पर चार अलग-अलग लीडरबोर्ड बना लिए हैं! आपके गेम की ज़रूरतों के हिसाब से, सटीक और तेज़ तरीके से काम करने वाले किसी एक विकल्प को कम कीमत पर चुना जा सकता है.

इसके बाद, गेम के लिए लर्निंग पाथवे देखें.