Twórz rankingi za pomocą Firestore

1. Wstęp

Ostatnia aktualizacja: 27.01.2023

Co jest potrzebne do zbudowania tabeli liderów?

W swojej istocie rankingi to po prostu tabele wyników z jednym komplikującym czynnikiem: odczytanie rankingu dla dowolnego wyniku wymaga znajomości wszystkich pozostałych wyników w określonej kolejności. Ponadto, jeśli Twoja gra odniesie sukces, Twoje tabele wyników powiększą się i będą często odczytywane i zapisywane. Aby zbudować udaną tabelę liderów, musi ona być w stanie szybko obsłużyć tę operację rankingową.

Co zbudujesz

Podczas tych ćwiczeń z programowania zaimplementujesz różne tabele wyników, z których każda będzie odpowiednia dla innego scenariusza.

Czego się dowiesz

Dowiesz się, jak wdrożyć cztery różne tabele wyników:

  • Naiwna implementacja wykorzystująca proste liczenie rekordów w celu określenia rangi
  • Tani, okresowo aktualizowany ranking
  • Tablica wyników w czasie rzeczywistym z pewnymi bzdurami dotyczącymi drzewka
  • Stochastyczna (probabilistyczna) tabela liderów służąca do przybliżonego rankingu bardzo dużych baz graczy

Co będziesz potrzebował

  • Najnowsza wersja przeglądarki Chrome (107 lub nowsza)
  • Node.js 16 lub nowszy (uruchom nvm --version , aby zobaczyć numer wersji, jeśli używasz nvm)
  • Płatny plan Firebase Blaze (opcjonalnie)
  • Interfejs Firebase CLI w wersji 11.16.0 lub nowszej
    Aby zainstalować interfejs CLI, możesz uruchomić npm install -g firebase-tools lub zapoznać się z dokumentacją CLI , aby uzyskać więcej opcji instalacji.
  • Znajomość JavaScript, Cloud Firestore, Cloud Functions i Chrome DevTools

2. Przygotowanie do pracy

Zdobądź kod

Wszystko, czego potrzebujesz do tego projektu, umieściliśmy w repozytorium Git. Aby rozpocząć, musisz pobrać kod i otworzyć go w swoim ulubionym środowisku programistycznym. W tym laboratorium z kodowania użyliśmy VS Code, ale poradzi sobie z nim każdy edytor tekstu.

i rozpakuj pobrany plik zip.

Lub sklonuj do wybranego katalogu:

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

Jaki jest nasz punkt wyjścia?

Nasz projekt to obecnie pusta karta z kilkoma pustymi funkcjami:

  • index.html zawiera kilka skryptów klejących, które pozwalają nam wywoływać funkcje z konsoli deweloperskiej i przeglądać ich wyniki. Wykorzystamy to do połączenia się z naszym backendem i zobaczenia wyników naszych wywołań funkcji. W prawdziwym scenariuszu te wywołania zaplecza wykonywałbyś bezpośrednio ze swojej gry — w tych ćwiczeniach z programowania nie używamy gry, ponieważ gra w grę trwałaby zbyt długo za każdym razem, gdy chcesz dodać wynik do tabeli liderów .
  • functions/index.js zawiera wszystkie nasze funkcje chmury. Zobaczysz kilka funkcji narzędziowych, takich jak addScores i deleteScores , a także funkcje, które zaimplementujemy w tym ćwiczeniu z programowania, które wywołują funkcje pomocnicze w innym pliku.
  • functions/functions-helpers.js zawiera puste funkcje, które zaimplementujemy. Dla każdej tabeli liderów zaimplementujemy funkcje odczytu, tworzenia i aktualizacji, a zobaczysz, jak nasz wybór implementacji wpływa zarówno na złożoność naszej implementacji, jak i jej wydajność skalowania.
  • functions/utils.js zawiera więcej funkcji narzędziowych. Nie będziemy dotykać tego pliku podczas tych zajęć z programowania.

Utwórz i skonfiguruj projekt Firebase

  1. W konsoli Firebase kliknij Dodaj projekt .
  2. Aby utworzyć nowy projekt, wprowadź żądaną nazwę projektu.
    Spowoduje to również ustawienie identyfikatora projektu (wyświetlanego pod nazwą projektu) na wartość opartą na nazwie projektu. Opcjonalnie możesz kliknąć ikonę edycji na identyfikatorze projektu, aby go jeszcze bardziej dostosować.
  3. Jeśli pojawi się monit, przejrzyj i zaakceptuj warunki Firebase .
  4. Kliknij Kontynuuj .
  5. Wybierz opcję Włącz Google Analytics dla tego projektu , a następnie kliknij Kontynuuj .
  6. Wybierz istniejące konto Google Analytics, którego chcesz użyć, lub wybierz opcję Utwórz nowe konto , aby utworzyć nowe konto.
  7. Kliknij opcję Utwórz projekt .
  8. Po utworzeniu projektu kliknij Kontynuuj .
  9. W menu Kompilacja kliknij opcję Funkcje i jeśli zostanie wyświetlony monit, zaktualizuj swój projekt, aby korzystać z planu rozliczeniowego Blaze.
  10. W menu Kompilacja kliknij Baza danych Firestore .
  11. W wyświetlonym oknie dialogowym Utwórz bazę danych wybierz opcję Uruchom w trybie testowym , a następnie kliknij przycisk Dalej .
  12. Wybierz region z listy rozwijanej lokalizacji Cloud Firestore , a następnie kliknij Włącz .

Skonfiguruj i uruchom swoją tabelę wyników

  1. W terminalu przejdź do katalogu głównego projektu i uruchom firebase use --add . Wybierz właśnie utworzony projekt Firebase.
  2. W katalogu głównym projektu uruchom firebase emulators:start --only hosting .
  3. W przeglądarce przejdź do localhost:5000 .
  4. Otwórz konsolę JavaScript Chrome DevTools i zaimportuj leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Uruchom leaderboard.codelab(); w konsoli. Jeśli zobaczysz wiadomość powitalną, wszystko gotowe! Jeśli nie, zamknij emulator i wykonaj ponownie kroki 2-4.

Przejdźmy do pierwszej implementacji tabeli liderów.

3. Zaimplementuj prostą tabelę wyników

Pod koniec tej sekcji będziemy mogli dodać wynik do tabeli liderów i poinformować nas o naszej pozycji.

Zanim przejdziemy do rzeczy, wyjaśnijmy, jak działa ta implementacja tabeli liderów: wszyscy gracze są przechowywani w jednej kolekcji, a pobieranie rangi gracza odbywa się poprzez pobranie kolekcji i zliczenie, ilu graczy jest przed nimi. Ułatwia to wstawianie i aktualizowanie partytury. Aby wstawić nową partyturę, po prostu dołączamy ją do kolekcji, a aby ją zaktualizować, filtrujemy według naszego bieżącego użytkownika, a następnie aktualizujemy powstały dokument. Zobaczmy jak to wygląda w kodzie.

W functions/functions-helper.js zaimplementuj funkcję createScore , która jest tak prosta, jak to tylko możliwe:

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

Aby zaktualizować wyniki, wystarczy dodać kontrolę błędów, aby upewnić się, że aktualizowany wynik już istnieje:

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

I na koniec nasza prosta, ale mniej skalowalna funkcja rangi:

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

Przetestujmy to! Wdróż swoje funkcje, uruchamiając następujące polecenie w terminalu:

firebase deploy --only functions

Następnie w konsoli JS przeglądarki Chrome dodaj kilka innych wyników, abyśmy mogli zobaczyć nasz ranking wśród innych graczy.

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

Teraz możemy dodać do miksu własną partyturę:

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

Po zakończeniu zapisu w konsoli powinna pojawić się odpowiedź „Utworzono wynik”. Zamiast tego widzisz błąd? Otwórz dzienniki funkcji za pomocą konsoli Firebase, aby zobaczyć, co poszło nie tak.

I wreszcie możemy pobrać i zaktualizować nasz wynik.

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

Jednakże ta implementacja daje nam niepożądane liniowe wymagania dotyczące czasu i pamięci potrzebne do pobrania rangi danego wyniku. Ponieważ zarówno czas wykonywania funkcji, jak i pamięć są ograniczone, nie tylko będzie to oznaczać, że pobieranie będzie coraz wolniejsze, ale po dodaniu wystarczającej liczby wyników do tabeli liderów nasze funkcje przekroczą limit czasu lub zawiodą, zanim będą mogły zwrócić wynik. Oczywiście będziemy potrzebować czegoś lepszego, jeśli mamy zamiar skalować grę powyżej garstki graczy.

Jeśli jesteś miłośnikiem Firestore, być może wiesz, że istnieje COUNT zapytań agregujących , dzięki którym ta tabela wyników byłaby znacznie wydajniejsza. I będziesz miał rację! W przypadku zapytań COUNT skaluje się to znacznie poniżej około miliona użytkowników, chociaż jego wydajność jest nadal liniowa.

Ale poczekaj, możesz sobie pomyśleć, jeśli i tak mamy zamiar wyliczyć wszystkie dokumenty w kolekcji, możemy przypisać każdemu dokumentowi rangę, a kiedy będziemy musieli go pobrać, nasze pobrania będą wynosić O(1) czas i pamięć! To prowadzi nas do kolejnego podejścia, czyli okresowo aktualizowanej tablicy wyników.

4. Wprowadź okresowo aktualizowaną tabelę wyników

Kluczem do tego podejścia jest przechowywanie rangi w samym dokumencie, więc pobranie jej daje nam rangę bez dodatkowej pracy. Aby to osiągnąć, będziemy potrzebować nowego rodzaju funkcji.

W index.js dodaj następujące elementy:

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

Teraz nasze operacje odczytu, aktualizacji i zapisu są ładne i proste. Zapis i aktualizacja pozostają niezmienione, ale odczyt staje się (w 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"),
  };
}

Niestety, bez dodania konta rozliczeniowego do projektu nie będzie można tego wdrożyć i przetestować. Jeśli masz konto rozliczeniowe, skróć przerwę w zaplanowanej funkcji i obserwuj, jak w magiczny sposób przypisuje ona rangi do Twoich wyników w tabeli liderów.

Jeśli nie, usuń zaplanowaną funkcję i przejdź do następnej implementacji.

Śmiało, usuń wyniki z bazy danych Firestore, klikając 3 kropki obok kolekcji wyników, aby przygotować się do następnej sekcji.

Firestore scores document page with\nDelete Collection activated

5. Zaimplementuj tabelę liderów w czasie rzeczywistym

To podejście polega na przechowywaniu danych wyszukiwania w samym zbiorze bazy danych. Zamiast mieć jednolitą kolekcję, naszym celem jest przechowywanie wszystkiego w drzewie, po którym możemy się poruszać, poruszając się po dokumentach. Dzięki temu możemy przeprowadzić wyszukiwanie binarne (lub n-arne) rangi danego wyniku. Jak to może wyglądać?

Na początek będziemy chcieli podzielić nasze wyniki na mniej więcej równe segmenty, co będzie wymagało pewnej wiedzy na temat wartości wyników rejestrowanych przez naszych użytkowników; na przykład, jeśli tworzysz tabelę wyników uwzględniającą oceny umiejętności w grze konkurencyjnej, oceny umiejętności Twoich użytkowników prawie zawsze będą miały rozkład normalny. Nasza funkcja generowania losowych wyników korzysta z JavaScriptu Math.random() , co daje w przybliżeniu równy rozkład, więc podzielimy nasze koszyki po równo.

W tym przykładzie dla uproszczenia użyjemy 3 segmentów, ale prawdopodobnie przekonasz się, że jeśli użyjesz tej implementacji w prawdziwej aplikacji, więcej segmentów przyniesie szybsze wyniki – płytsze drzewo oznacza średnio mniej pobrań kolekcji i mniejszą rywalizację o blokady.

Rangę gracza wyznacza suma liczby graczy z wyższymi wynikami plus jeden punkt dla samego gracza. Każda kolekcja w ramach scores będzie przechowywać trzy dokumenty, każdy z zakresem, liczbą dokumentów w każdym zakresie, a następnie trzy odpowiadające im podkolekcje. Aby odczytać rangę, przeszukamy to drzewo w poszukiwaniu wyniku i śledząc sumę większych wyników. Kiedy znajdziemy nasz wynik, będziemy mieli również poprawną sumę.

Pisanie jest znacznie bardziej skomplikowane. Po pierwsze, będziemy musieli dokonać wszystkich zapisów w ramach transakcji, aby zapobiec niespójnościom danych w przypadku jednoczesnego wystąpienia wielu zapisów lub odczytów. Będziemy także musieli zachować wszystkie warunki, które opisaliśmy powyżej, gdy będziemy przemierzać drzewo, aby zapisać nowe dokumenty. I wreszcie, ponieważ mamy do czynienia z całą złożonością tego nowego podejścia w połączeniu z koniecznością przechowywania wszystkich naszych oryginalnych dokumentów, nasze koszty przechowywania nieznacznie wzrosną (ale nadal są liniowe).

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

Jest to z pewnością bardziej skomplikowane niż nasza ostatnia implementacja, która składała się z pojedynczego wywołania metody i zaledwie sześciu linii kodu. Po zaimplementowaniu tej metody spróbuj dodać kilka wyników do bazy danych i obserwować strukturę powstałego drzewa. W konsoli JS:

leaderboard.addScores();

Wynikowa struktura bazy danych powinna wyglądać mniej więcej tak, z wyraźnie widoczną strukturą drzewa i liśćmi drzewa reprezentującymi poszczególne wyniki.

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

Teraz, gdy najtrudniejsza część jest już za nami, możemy odczytać wyniki, przechodząc przez drzewo, jak opisano wcześniej.

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

Aktualizacje pozostawia się jako dodatkowe ćwiczenie. Spróbuj dodać i pobrać wyniki w konsoli JS za pomocą metod leaderboard.addScore(id, score) i leaderboard.getRank(id) i zobacz, jak zmienia się tabela wyników w konsoli Firebase.

Jednak w przypadku tej implementacji złożoność, którą dodaliśmy w celu osiągnięcia wydajności logarytmicznej, ma swoją cenę.

  • Po pierwsze, ta implementacja tabeli liderów może powodować problemy z rywalizacją o blokady, ponieważ transakcje wymagają blokowania odczytów i zapisów w dokumentach, aby zapewnić ich spójność.
  • Po drugie, Firestore narzuca limit głębokości podkolekcji wynoszący 100 , co oznacza, że ​​będziesz musiał unikać tworzenia poddrzew po 100 remisowych wynikach, czego nie robi ta implementacja.
  • I wreszcie, tabela wyników skaluje się logarytmicznie tylko w idealnym przypadku, gdy drzewo jest zrównoważone – jeśli jest niezrównoważone, w najgorszym przypadku wydajność tej tabeli liderów jest ponownie liniowa.

Gdy już skończysz, usuń scores i kolekcje players za pomocą konsoli Firebase, a my przejdziemy do naszej ostatniej implementacji tabeli liderów.

6. Wprowadź tabelę stochastyczną (probabilistyczną).

Podczas uruchamiania kodu wstawiania możesz zauważyć, że jeśli uruchomisz go zbyt wiele razy równolegle, funkcje zaczną działać nieprawidłowo i wyświetli się komunikat o błędzie związany z rywalizacją o blokadę transakcji. Istnieją sposoby obejścia tego problemu, których nie będziemy omawiać w tym ćwiczeniu z programowania, ale jeśli nie potrzebujesz dokładnego rankingu, możesz porzucić całą złożoność poprzedniego podejścia na rzecz czegoś prostszego i szybszego. Przyjrzyjmy się, w jaki sposób zamiast dokładnego rankingu moglibyśmy zwrócić szacowaną rangę na podstawie wyników naszych graczy i jak zmienia to logikę naszej bazy danych.

W tym podejściu podzielimy naszą tabelę wyników na 100 segmentów, z których każdy reprezentuje około jeden procent oczekiwanych wyników. To podejście działa nawet bez wiedzy o naszym rozkładzie wyników. W takim przypadku nie możemy zagwarantować w przybliżeniu równomiernego rozkładu wyników w całym koszyku, ale osiągniemy większą precyzję w naszych przybliżeniach, jeśli będziemy wiedzieć, jak nasze wyniki zostaną rozłożone .

Nasze podejście jest następujące: tak jak poprzednio, każdy segment przechowuje liczbę wyników w ramach oraz zakres wyników. Podczas wstawiania nowego wyniku znajdziemy segment wyniku i zwiększymy jego liczbę. Pobierając rangę, po prostu zsumujemy segmenty znajdujące się przed nią, a następnie przybliżymy ją w obrębie naszego segmentu, zamiast szukać dalej. Daje nam to bardzo ładne wyszukiwanie i wstawianie w stałym czasie i wymaga znacznie mniej kodu.

Najpierw wstawienie:

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

Zauważysz, że ten kod wstawiania ma pewną logikę inicjowania stanu bazy danych na górze z ostrzeżeniem, aby nie robić czegoś takiego w środowisku produkcyjnym. Kod inicjujący nie jest w ogóle chroniony przed warunkami wyścigu, więc jeśli to zrobisz, wielokrotne równoczesne zapisy uszkodzą twoją bazę danych, tworząc kilka zduplikowanych segmentów.

Śmiało, wdróż swoje funkcje, a następnie uruchom wstawianie, aby zainicjować wszystkie segmenty z liczbą zero. Zwróci błąd, który możesz bezpiecznie zignorować.

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

Teraz, gdy baza danych została poprawnie zainicjowana, możemy uruchomić addScores i zobaczyć strukturę naszych danych w konsoli Firebase. Powstała struktura jest znacznie bardziej płaska niż nasza ostatnia implementacja, chociaż powierzchownie są podobne.

leaderboard.addScores();

A teraz, aby przeczytać partytury:

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

Ponieważ sprawiliśmy, że funkcja addScores generuje równomierny rozkład wyników i używamy interpolacji liniowej w przedziałach, otrzymamy bardzo dokładne wyniki, a wydajność naszej tabeli liderów nie ulegnie pogorszeniu wraz ze wzrostem liczby użytkowników, i nie musimy się martwić o rywalizację o blokady (tak bardzo) podczas aktualizacji liczników.

7. Dodatek: Oszukiwanie

Poczekaj, możesz pomyśleć, jeśli piszę wartości do mojego laboratorium kodowania za pośrednictwem konsoli JS na karcie przeglądarki, czy żaden z moich graczy nie może po prostu okłamać tabeli liderów i powiedzieć, że uzyskał wysoki wynik, czego nie zrobił osiągnąć uczciwie?

Tak, moga. Jeśli chcesz zapobiec oszustwom, najskuteczniejszym sposobem jest wyłączenie zapisywania klientów w bazie danych za pomocą reguł bezpieczeństwa , zabezpieczenie dostępu do funkcji w chmurze, aby klienci nie mogli do nich dzwonić bezpośrednio, a następnie sprawdzenie przed rozpoczęciem działań w grze na serwerze. wysyłanie aktualizacji wyników do tabeli liderów.

Należy zauważyć, że ta strategia nie jest panaceum na oszustwa — przy wystarczająco dużej zachętie oszuści mogą znaleźć sposoby na obejście weryfikacji po stronie serwera, a wiele dużych, odnoszących sukcesy gier wideo nieustannie bawi się w kotka i myszkę ze swoimi oszustami, aby zidentyfikować nowe kody i zapobiegać ich rozprzestrzenianiu się. Trudną konsekwencją tego zjawiska jest to, że weryfikacja każdej gry po stronie serwera jest z natury dostosowana do indywidualnych potrzeb; chociaż Firebase zapewnia narzędzia zapobiegające nadużyciom, takie jak App Check, które uniemożliwiają użytkownikowi kopiowanie Twojej gry za pośrednictwem prostego klienta skryptowego, Firebase nie zapewnia żadnej usługi, która byłaby równoznaczna z całościowym zabezpieczeniem przed oszustwami.

Wszystko inne niż weryfikacja po stronie serwera, w przypadku wystarczająco popularnej gry lub wystarczająco niskiej bariery dla oszukiwania, spowoduje utworzenie tabeli liderów, w której wszystkie najwyższe wartości to oszustzy.

8. Gratulacje

Gratulacje, udało Ci się utworzyć cztery różne tabele wyników w Firebase! W zależności od wymagań Twojej gry w zakresie dokładności i szybkości, będziesz mógł wybrać taki, który będzie dla Ciebie odpowiedni za rozsądną cenę.

Następnie sprawdź ścieżki edukacyjne dotyczące gier.