Tworzenie tabel wyników w Firestore

1. Wprowadzenie

Ostatnia aktualizacja: 27.01.2023 r.

Co jest potrzebne do stworzenia tabeli wyników?

Tabele wyników to przede wszystkim tabele z wynikami, w których jeden czynnik składowy jest jednym z elementów składowych: czytanie pozycji w przypadku danego wyniku wymaga znajomości wszystkich innych wyników w określonej kolejności. Dodatkowo, gdy gra zaczyna się rozkręcać, tabele wyników powiększą się i będą często odczytywane i zapisywane. Aby utworzyć skuteczną tabelę wyników, musi ona być w stanie szybko obsłużyć takie działania.

Co utworzysz

W ramach tego ćwiczenia w Codelabs wdrożysz różne tabele wyników odpowiednie do różnych scenariuszy.

Czego się nauczysz

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

  • Naiwna implementacja wykorzystująca proste liczenie rekordów do określenia rankingu
  • Tania, okresowo aktualizowana tabela wyników
  • Tabela wyników w czasie rzeczywistym, w której znajdziesz pozbawione sensu
  • Stochastyczna (prawdopodobna) tablica wyników dla przybliżonego rankingu bardzo dużej grupy graczy.

Czego potrzebujesz

  • najnowszą wersję Chrome (107 lub nowszą),
  • Node.js w wersji 16 lub nowszej (jeśli używasz nvm, uruchom nvm --version, aby sprawdzić numer wersji)
  • płatny abonament Firebase Blaze (opcjonalnie).
  • Interfejs wiersza poleceń Firebase w wersji 11.16.0 lub nowszej
    Aby zainstalować interfejs wiersza poleceń, możesz uruchomić npm install -g firebase-tools lub zapoznać się z dokumentacją interfejsu wiersza poleceń, aby poznać więcej opcji instalacji.
  • Znajomość języka JavaScript, Cloud Firestore, Cloud Functions i Narzędzi deweloperskich w Chrome

2. Przygotowanie

Pobierz kod

Wszystko, czego potrzebujesz do tego projektu, umieściliśmy w repozytorium Git. Aby rozpocząć, pobierz kod i otwórz go w ulubionym środowisku programistycznym. W tym ćwiczeniu z programowania użyliśmy VS Code, ale wystarczy każdy edytor tekstu.

i rozpakuj pobrany plik ZIP.

Możesz też sklonować do wybranego katalogu:

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

Od czego zacząć?

Nasz projekt to obecnie czysta plansza z kilkoma pustymi funkcjami:

  • index.html zawiera skrypty typu glue, które umożliwiają wywoływanie funkcji z konsoli programisty i sprawdzanie ich danych wyjściowych. Wykorzystamy go do interfejsu z backendem i będziemy sprawdzać wyniki wywołań funkcji. W rzeczywistości te wywołania backendu są wykonywane bezpośrednio z gry. Nie korzystamy z żadnej gry w tym ćwiczeniu, ponieważ za każdym razem, gdy chcesz dodać wynik do tabeli wyników, granie w nią zajmowałaby zbyt dużo czasu.
  • functions/index.js zawiera wszystkie nasze funkcje w Cloud Functions. Zobaczysz niektóre funkcje narzędziowe, takie jak addScores i deleteScores, a także funkcje, które wdrożymy w tym ćwiczeniu w programie, które będą wywoływać funkcje pomocnicze w innym pliku.
  • Pole functions/functions-helpers.js zawiera puste funkcje, które wdrożymy. W przypadku każdej tabeli wyników wdrożymy funkcje odczytu, tworzenia i aktualizowania. Zobaczysz też, jak nasz wybór implementacji wpływa zarówno na złożoność implementacji, jak i na wydajność jej skalowania.
  • functions/utils.js zawiera więcej funkcji użytkowych. W ramach tego ćwiczenia w Codelabs nie dotkniemy tego pliku.

Tworzenie i konfigurowanie projektu Firebase

  1. W konsoli Firebase kliknij Dodaj projekt.
  2. Aby utworzyć nowy projekt, wpisz odpowiednią nazwę.
    Spowoduje to również ustawienie identyfikatora projektu (wyświetlanego pod nazwą projektu) na podstawie nazwy projektu. Opcjonalnie możesz kliknąć ikonę edytuj przy identyfikatorze projektu, aby jeszcze bardziej go dostosować.
  3. W razie potrzeby przeczytaj i zaakceptuj warunki korzystania z Firebase.
  4. Kliknij Dalej.
  5. Wybierz opcję Włącz Google Analytics dla tego projektu, a potem kliknij Dalej.
  6. Wybierz istniejące konto Google Analytics, którego chcesz użyć, lub kliknij Utwórz nowe konto, aby utworzyć nowe konto.
  7. Kliknij Utwórz projekt.
  8. Po utworzeniu projektu kliknij Dalej.
  9. W menu Tworzenie kliknij Funkcje i, jeśli pojawi się taka prośba, przenieś projekt na wyższy abonament, aby korzystać z abonamentu Blaze.
  10. W menu Build (Tworzenie) kliknij Firestore database (Baza danych Firestore).
  11. W wyświetlonym oknie Tworzenie bazy danych kliknij kolejno Rozpocznij w trybie testowym i Dalej.
  12. Wybierz region z menu Lokalizacja Cloud Firestore i kliknij Włącz.

Konfigurowanie i prowadzenie tabeli wyników

  1. W terminalu przejdź do katalogu głównego projektu i uruchom firebase use --add. Wybierz utworzony przed chwilą projekt Firebase.
  2. W katalogu głównym projektu uruchom polecenie firebase emulators:start --only hosting.
  3. W przeglądarce otwórz stronę localhost:5000.
  4. Otwórz konsolę JavaScript w Narzędziach deweloperskich w Chrome i zaimportuj plik leaderboard.js:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Uruchom leaderboard.codelab(); w konsoli. Jeśli zobaczysz wiadomość powitalną, nie musisz nic więcej robić. Jeśli tak nie jest, wyłącz emulator i powtórz kroki 2–4.

Przejdźmy do pierwszej implementacji tablicy wyników.

3. Wdróż prostą tabelę wyników

Po zakończeniu tej sekcji będziemy mogli dodać wynik do tabeli wyników i na tej podstawie określić naszą pozycję.

Zanim zaczniemy, wyjaśnijmy, jak działa ta implementacja tablicy wyników: wszyscy gracze są zapisani w jednej kolekcji, a ocena rankingu polega na pobraniu kolekcji i liczeniu, ilu graczy jest przed nimi. Ułatwia to wstawianie i aktualizowanie oceny. Aby wstawić nową ocenę, po prostu dodajemy ją do kolekcji. W celu jej zaktualizowania filtrujemy według bieżącego użytkownika, a następnie aktualizujemy wynikowy dokument. Zobaczmy, jak to wygląda w kodzie.

W projekcie functions/functions-helper.js zaimplementuj funkcję createScore, która jest maksymalnie prosta:

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

Aby zaktualizować wyniki, wystarczy dodać funkcję sprawdzania 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,
  });
}

Na koniec nasza prosta, ale mniej skalowalna funkcja rankingu:

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

Sprawdźmy to! Wdróż swoje funkcje, uruchamiając w terminalu to polecenie:

firebase deploy --only functions

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

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

Teraz możemy dodać własny wynik do tego składu:

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

Po zakończeniu zapisu w konsoli powinien pojawić się komunikat „Utworzono wynik”. Zamiast tego widzisz błąd? Otwórz logi funkcji w konsoli Firebase, aby sprawdzić, co poszło nie tak.

Możemy też pobrać i zaktualizować wynik.

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

Taka implementacja powoduje jednak niepożądane linearne wymagania dotyczące czasu i pamięci potrzebne do pobierania pozycji danego wyniku. Ponieważ czas wykonywania funkcji i ilość pamięci są ograniczone, oznacza to nie tylko, że pobieranie będzie coraz wolne, ale po dodaniu wystarczającej liczby wyników do tabeli wyników, funkcje z powodu przekroczenia limitu czasu lub awarii, zanim będą mogły zwrócić wynik. Oczywiście, jeśli chcemy rozszerzyć zasięg na kilku graczy, potrzebujemy czegoś lepszego.

Jeśli jesteś fanem Firestore, prawdopodobnie znasz zapytania dotyczące agregacji COUNT, które znacznie zwiększałyby wydajność tej tabeli wyników. I masz rację. W przypadku zapytań (COUNT) wynik dobrze skaluje się poniżej miliona użytkowników, mimo że jego skuteczność jest wciąż liniowa.

Chwila moment. Być może zastanawiasz się, czy jeśli i tak mamy wyliczyć wszystkie dokumenty w kolekcji, możemy przypisać każdemu dokumentowi rangę, a gdy trzeba będzie pobrać materiał, zajmie to pomiar czasu i pamięci przez O(1)! Dlatego doszliśmy do kolejnego podejścia – tabeli wyników, która jest okresowo aktualizowana.

4. Wdrażanie okresowo aktualizowanej tabeli wyników

Kluczem do tej metody jest zapisanie pozycji w samym dokumencie, więc pobranie go daje nam pozycję bez dodatkowego nakładu pracy. Aby to osiągnąć, potrzebujemy funkcji nowego rodzaju.

W polu index.js dodaj:

// 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 operacje odczytu, aktualizacji i zapisu są przyjemne i proste. Zapis i aktualizacja nie uległy zmianie, ale odczyt zmienia 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 nie będzie można wdrożyć ani przetestować tej funkcji bez dodania konta rozliczeniowego do projektu. Jeśli masz konto rozliczeniowe, skróć przedział czasu dla zaplanowanej funkcji i obserwuj, jak funkcja magicznie przypisuje rankingi do wyników w tabeli wyników.

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

Możesz usunąć wyniki w bazie danych Firestore, klikając 3 kropki obok kolekcji wyników, co pozwoli Ci przygotować się do następnej sekcji.

Strona dokumentu z wynikami Firestore\nz aktywowanym usuwaniem kolekcji

5. Wdróż tabelę drzewa wyników w czasie rzeczywistym

Ta metoda polega na przechowywaniu danych wyszukiwania w samym zbiorze baz danych. Zamiast jednolitego zbioru, naszym celem jest przechowywanie wszystkich elementów w drzewie, które można przeglądać, poruszając się po dokumentach. Umożliwia to przeprowadzenie wyszukiwania binarnego (lub znaków n-ary) dla pozycji danego wyniku. Jak to może wyglądać?

Na początek chcemy mieć możliwość rozdzielenia naszych wyników na mniej więcej równomierne zbiory, co wymaga pewnej wiedzy na temat wartości wyników rejestrowanych przez użytkowników. Jeśli na przykład tworzysz tabelę wyników w celu oceny umiejętności w grach, w których rywalizuje, użytkownicy oceny umiejętności niemal zawsze będą mieć normalny rozkład. Nasza funkcja generująca wynik losowy korzysta z kodu Math.random() JavaScriptu, co skutkuje w przybliżeniu równomiernym rozkładzie wyników, więc dzielisz nasze segmenty po równo.

W tym przykładzie wykorzystamy dla uproszczenia 3 segmenty, ale może się okazać, że jeśli użyjesz tej implementacji w prawdziwej aplikacji, więcej zasobników przyniesie szybsze wyniki. Płytsze drzewo oznacza średnio mniej pobrań kolekcji i mniej rywalizacji o blokady.

Pozycja gracza jest ustalana przez sumę liczby graczy z wyższymi wynikami plus jeden dla niego samego. Każda kolekcja w hierarchii scores będzie przechowywać 3 dokumenty, każdy z określonym zakresem, liczbę dokumentów w każdym zakresie, a potem 3 odpowiednie kolekcje podrzędne. Aby odczytać pozycję, przejdziemy do tego drzewa, w poszukiwaniu wyniku i śledząc sumę najlepszych wyników. Kiedy znajdziemy nasz wynik, również znajdziemy właściwą sumę.

Pisanie jest znacznie bardziej skomplikowane. Najpierw musimy wykonać wszystkie zapisy w ramach transakcji, aby zapobiec niespójnościom danych w przypadku wielu zapisów lub odczytów w tym samym czasie. Musimy też zachować wszystkie warunki opisane powyżej, ponieważ przemierzamy drzewo podczas pisania nowych dokumentów. Ze względu na to, że w tym nowym podejściu mamy do czynienia ze złożonością drzewa, a także z koniecznością przechowywania wszystkich oryginalnych dokumentów, nasz koszt miejsca na dane nieco wzrośnie (ale nadal będzie liniowy).

W aplikacji 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 od naszej ostatniej implementacji, która wymagała użycia pojedynczej metody i 6 wierszy kodu. Po zaimplementowaniu tej metody spróbuj dodać do bazy danych kilka wyników i obserwować strukturę powstałego w ten sposób drzewa. W konsoli JS:

leaderboard.addScores();

Powstała struktura bazy danych powinna wyglądać mniej więcej tak: struktura drzewa jest wyraźnie widoczna, a jego liście reprezentują poszczególne wyniki.

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

Skoro wykonaliśmy już najtrudniejsze zadania, możemy odczytać wyniki, przechodząc przez drzewo w sposób opisany powyżej.

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 pozostają jako dodatkowe ćwiczenie. Spróbuj dodawać i pobierać wyniki w konsoli JS za pomocą metod leaderboard.addScore(id, score) oraz leaderboard.getRank(id) i sprawdzać, jak zmienia się tablica wyników w konsoli Firebase.

Jednak złożoność, którą dodaliśmy w celu osiągnięcia wydajności logarytmicznej, wiąże się z kosztami.

  • Po pierwsze, w przypadku implementacji tablicy wyników mogą wystąpić problemy z rywalizacją o blokady, ponieważ ze względu na spójność transakcji wymagane jest blokowanie odczytów i zapisów w dokumentach.
  • Po drugie, Firestore nakłada limit głębokości podzbioru wynoszący 100, co oznacza, że nie trzeba tworzyć drzew podrzędnych po uzyskaniu 100 powiązanych wyników, co nie jest możliwe w przypadku tej implementacji.
  • I w końcu tablica wyników skaluje się logarytmicznie tylko w idealnym przypadku, gdy drzewo jest zrównoważone – jeśli jest nierównomierne, najgorsze wyniki w tym przypadku są znowu liniowe.

Gdy skończysz, usuń kolekcje scores i players w konsoli Firebase, a my przejdziemy do ostatniej implementacji tablicy wyników.

6. Stosuj stochastyczną (prawdopodobną) tabelę wyników

Gdy uruchomisz kod wstawiania, możesz zauważyć, że jeśli uruchomisz go zbyt wiele razy równolegle, funkcje zaczną kończyć się niepowodzeniem i wyświetli się komunikat o błędzie związanym z rywalizacją o blokadę transakcji. Istnieją sposoby, których nie omówimy w tym ćwiczeniu w programowaniu, ale jeśli nie potrzebujesz dokładnego rankingu, możesz zrezygnować z całkowitej złożoności poprzedniego podejścia, aby osiągnąć coś zarówno prostszego, jak i szybszego. Zastanówmy się, w jaki sposób możemy przyznać zawodnikom szacowaną pozycję w rankingu wyników zamiast dokładnego rankingu oraz tego, jak zmienia to logikę naszej bazy danych.

W tym przypadku podzielimy tablicę wyników na 100 grup, z których każda odpowiada około jednego procentu oczekiwanych wyników. To podejście działa nawet bez wiedzy o naszym rozkładzie wyników – w takim przypadku nie możemy zagwarantować mniej więcej równomiernego rozkładu wyników w całym zbiorze, ale jeśli będziemy wiedzieć, jak zostaną rozłożone wyniki, osiągnęmy większą precyzję w naszych przybliżeniach.

Nasze podejście wygląda tak: podobnie jak wcześniej, w każdym segmencie zapisywana jest liczba wyników w zakresie wyników. Podczas wstawiania nowego wyniku znajdziemy przedział dla wyniku i zwiększymy jego liczbę. Podczas pobierania pozycji zsumujemy znajdujące się przed nią zasobniki, a następnie wyświetlimy przybliżone wartości w naszym zasobniku, zamiast szukać dalej. Zapewnia nam to dużą wygodę stałego wyszukiwania i wstawiania, a przy tym wymaga znacznie mniej pisania kodu.

Po pierwsze, 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 u góry ten kod wstawiania ma jakąś logikę do inicjowania stanu bazy danych. Pojawia się też ostrzeżenie z ostrzeżeniem, że nie należy robić czegoś takiego w środowisku produkcyjnym. Kod inicjowania w ogóle nie jest chroniony przed warunkami wyścigu, więc w takim przypadku wielokrotne zapisy jednocześnie uszkodziłyby bazę danych, powodując utworzenie grupy zduplikowanych zasobników.

Wdróż funkcje, a następnie uruchom wstawianie, aby zainicjować wszystkie zasobniki, podając liczbę 0. Zwrócony zostanie błąd, który możesz bezpiecznie zignorować.

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

Po prawidłowym zainicjowaniu bazy danych możemy uruchomić polecenie addScores i zobaczyć strukturę naszych danych w konsoli Firebase. Powstała struktura jest znacznie bardziej płaska niż w poprzedniej implementacji, chociaż są one powierzchownie podobne.

leaderboard.addScores();

A teraz, aby odczytać wyniki:

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ż funkcja addScores generuje jednolity rozkład wyników i używamy interpolacji liniowej w obrębie zasobników, uzyskamy bardzo dokładne wyniki, a skuteczność tabeli wyników nie zmniejszy się wraz ze wzrostem liczby użytkowników i nie musimy się martwić o (równomierne) rywalizację o blokady przy aktualizowaniu liczby.

7. Załącznik: Oszukiwanie

Chwila. Pewnie zastanawiasz się, czy skoro piszę wartości do ćwiczeń z programowania za pomocą konsoli JS na karcie przeglądarki, to czy żaden z moich graczy nie zaskoczyłby zarazem tabeli wyników i powiedział, że ma rekordowy wynik, którego nie udało mu się osiągnąć odpowiednio?

Tak. Jeśli chcesz zapobiec oszustwom, najprostszym sposobem na to jest wyłączenie zapisywania danych w bazie danych przez klienta za pomocą reguł zabezpieczeń i bezpiecznego dostępu do Twoich funkcji w Cloud Functions, aby klienci nie mogli ich bezpośrednio wywoływać, a następnie weryfikowanie działań w grze na serwerze przed wysłaniem aktualizacji wyników do tabeli wyników.

Trzeba pamiętać, że ta strategia nie jest panaceum na oszustów – wystarczy motywacja, aby oszustom udało się obejść weryfikację po stronie serwera. Wiele dużych gier wideo, które odniosły sukces, nieustannie walczy z oszustami, aby znajdować nowe i powstrzymywać ich rozprzestrzenianie się. Trudną konsekwencją tego zjawiska jest to, że walidacja każdej gry po stronie serwera jest z natury spersonalizowana. chociaż Firebase zapewnia narzędzia zapobiegające nadużyciom, takie jak Sprawdzanie aplikacji, które uniemożliwiają użytkownikowi skopiowanie Twojej gry za pomocą prostego opartego na skryptach klienta. Firebase nie oferuje jednak żadnej usługi, która mogłaby stanowić kompleksowy system zapobiegania oszustwom.

Wszystko, co nie zostanie zweryfikowane po stronie serwera, w przypadku wystarczającej popularności gry lub dostatecznie niskiej przeszkody na oszukiwaniu spowoduje pojawienie się na tablicy wyników oszustami.

8. Gratulacje

Gratulacje! W Firebase udało Ci się utworzyć 4 różne tabele wyników. W zależności od wymagań gry w zakresie dokładności i szybkości możesz wybrać model, który sprawdzi się najlepiej w przystępnej cenie.

W następnej kolejności zapoznaj się ze ścieżkami szkoleniowymi dotyczącymi gier.