Перейти до основного вмісту
Сервіси навколо гри

Цю сторінку ще не перекладено — показано англійську версію.

Leaderboards

Одна модель таблиці, осі якої складаються в будь-який вид таблиці лідерів: щоденні рейтинги, таблиці найкращого кола, суми по гільдіях, сезони, турніри.

Коли це потрібно

  • Результати мають ранжувати гравців (щоденний рейтинг, найкраще коло, сума по гільдії) в межах однієї оголошеної моделі, а не окремої системи під кожну таблицю лідерів (leaderboard).
  • Вам потрібні стандартні читання — топ-N, вікно навколо гравця, рядки для заданого списку власників — без додаткового моделювання даних.
  • Цикли мають закриватися за розкладом, іти в архів (а не зникати) і викликати хук нагороди з фінальною таблицею.
  • Підозрілі результати ніколи не повинні потрапляти в таблицю: хук перед відправкою перевіряє їх, обрізає або відхиляє з типізованою причиною.
  • Турнір — це та сама таблиця, тільки з вікном реєстрації (entry window), максимальною кількістю учасників і кількістю спроб за цикл.
  • Не знадобиться, якщо число ніколи не порівнюють між гравцями. Особистий лічильник чи підсумок за всю кар’єру — це звичайні дані. Модуль упорядковує результати, але ніколи їх не обчислює й не будує сітки на вибування.

Хто що робить

АкторЩо робить на цій сторінці
playerчитає топ-N, вікно навколо себе й власне місце, підписується на зміни свого місця
backend-serviceнадсилає результати; виправляє або відхиляє їх у хуку перед відправкою; видає нагороди, коли цикл закривається
operatorоголошує таблиці; достроково закриває цикл, виправляє записи (з аудитом), стежить за частотою відправок

Коротко

Оголошення weekly-score: власник, агрегація, скидання в понеділок, відправка із сервера, ключ сортування
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
Незабаром — Go

Як оголошувати через атрибути в Go, поки не вирішено (O-1). Приклади з’являться, коли оберуть спосіб запису.

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — той самий клас-шаблон компілюється в Unity на базовій версії 2021.3 — синтаксису C# 12 тут немає
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

Оголошення лежить поруч з рештою вашої схеми (schema) — у серверному проєкті або в проєкті UE чи Unity. playserv push компілює його й відправляє на платформу: таблиця з’являється в панелі порожньою, а її наступне скидання вже заплановано. Розклади задають в UTC, тому ця таблиця закривається в понеділок о 00:00 UTC, а Reset.Weekly(DayOfWeek.Monday, at: "03:00") зсуває годину. Скидання за місцевим часом гравця немає: одна таблиця не може закритися в двадцять чотири різні моменти.

Ключ сортування — це список, а не очки плюс правило для нічиєї. Поля ранжуються в тому порядку, у якому ви їх пронумерували, кожне зі своїм напрямом. Останній рівень додає платформа: за рівних ключів вище стоїть та відправка, що надійшла раніше. Тому два однакові забіги ніколи не поміняються місцями між двома читаннями. Поле поза ключем — тут це Map — лише показують, і воно ніколи не зсуває рядок.

Agg визначає, що друга відправка робить з єдиним записом власника в поточному циклі:

AggДруга відправкаІдемпотентна
Setзамінює запис надісланими значеннямитак
Bestзамінює його, лише якщо нові значення стоять вище за ключем сортуваннятак
Incrementдодає надіслані значення до запису — вбивства, кола, внесок у гільдіюні — передавайте ключ ідемпотентності (idempotency key)
Decrementвіднімає їхні — передавайте ключ ідемпотентності

Відправка, яка не перевершує запис Best, — не помилка: вона повертається прийнятою, а порядок не змінюється. Increment і Decrement — саме ті два варіанти, які повторений виклик застосував би двічі. Тому вони приймають такий самий ключ ідемпотентності, як і будь-який інший запис, що його можна повторити.

Відправка — це один виклик, і для цієї таблиці він іде із серверного коду, бо так сказано в оголошенні:

Один Submit: два поля для ранжування й поле для показу — із функції, якій належить результат
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
Незабаром — Go

Як оголошувати через атрибути в Go, поки не вирішено (O-1). Приклади з’являться, коли оберуть спосіб запису.

Це викликає інший актор

Виклик є і в Unreal. Ця таблиця лишає типове Submit.ServerOnly, тому платформа приймає відправку лише від хмарної функції або від хоста кімнати з його ключем хоста. Оголосіть Submit.Players — і той самий виклик запрацює з клієнта. Див. Access & Roles.

Це викликає інший актор

Виклик є і в Unity. Ця таблиця лишає типове Submit.ServerOnly, тому платформа приймає відправку лише від хмарної функції або від хоста кімнати з його ключем хоста. Оголосіть Submit.Players — і той самий виклик запрацює з клієнта. Див. Access & Roles.

У цьому виклику варто окремо розібрати три речі:

У викликуЩо це
PlayServ · playservдескриптор (handle) хмарної функції та екземпляр клієнта, які SDK видає вам під час запуску. API один, а тих, хто викликає, двоє — у Go це ps і psv, і кожен приклад бере той, що є в його викликача
відправникфункція, якій належить результат матчу. У Tanks це хук кімнати (room) on dispose (Rooms): він виконується, коли фінальний стан уже на руках
playerIdid гравця на платформі з Auth & Players, а не ім’я, яке ви вигадали. Хук бере його зі своїх вхідних даних (e.By.PlayerId в уроці), а хост кімнати надсилає id місця (seat), яким володіє

Значення — це поля, названі в оголошенні. Неоголошене поле платформа відхиляє, а не зберігає.

Читання, потрібні кожній грі, і підписка (subscription), що тримає їх актуальними:

Топ-100, вікно навколо мене, рядки гільдії за списком власників і жива підписка на місце в рейтингу
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
Незабаром — Go

Як оголошувати через атрибути в Go, поки не вирішено (O-1). Приклади з’являться, коли оберуть спосіб запису.

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — в Unity працює без змін зі згенерованими типами
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) — це вікно за місцем у рейтингу, а не сторінка: п’ять рядків над вами, п’ять під вами і ваш власний — одинадцять рядків. Там, де таблиця закінчується, вікно обрізається симетрично, тож гравець на другому місці отримує коротше вікно з обох боків, а не зсунуте. Top посторінковий: він повертає перші N рядків і курсор, а after: проходить решту.

ForOwners — це те, на чому тримається таблиця друзів. Графа друзів платформа не зберігає: ви передаєте власників, які вже є у вашій грі, — учасників групи або список id з ваших власних даних. Кожен рядок повертається зі своїм місцем у повній таблиці, а не в межах списку.

OnRankChanged доставляє лише власне місце локального гравця і нічого більше: таблиця з п’ятдесятьма тисячами учасників не розсилає кожну перестановку кожному клієнтові. Колбек отримує змінений рядок — місце, поля для ранжування, поля для показу, — а Cancel() завершує підписку. Саме місце — це снапшот: два читання з різницею в секунду можуть не збігатися, поки надходять відправки, але вашу власну відправку ваше наступне читання бачить завжди.

Модель

Що оголошує таблиця.

ВісьЗначенняЯк задати
Власникгравець · групаOwner = Owner.Player — таблиця гільдій — це та сама таблиця з Owner.Group
Ключ сортуванняодне або кілька оголошених полів, кожне за зростанням або спаданням[Rank(1, Sort.Descending)] int Score
Агрегаціяset · best · increment · decrementAgg = Aggregation.Best
Скиданнярозклад в UTC; цикл спливає, але не видаляєтьсяReset = Reset.Weekly(DayOfWeek.Monday)
Хто може надсилатилише сервер (типово) · гравціSubmit = Submit.ServerOnly
Поля для показуоголошені й типізовані; у порядок ніколи не входять[Display] string Map
Список власниківобирають під час читання, а не оголошуютьForOwners("weekly-score", ids) — друзі, гільдія, лобі
Правила турнірувікно реєстрації · максимум учасників · спроби за цикл · обов’язкова реєстраціяRules = Tournament.Define(…), у таблиці розділу Турніри

Осі області дії немає: таблиця на регіон, на кімнату чи на сезон — це окрема таблиця зі своїм ключем, і саме на цей ключ посилається ваш код.

Що справджується для кожної таблиці.

ЗавждиЩо це
напрям і оператор агрегаціїпісля першого запису незмінні, бо інакше історія мовчки переранжувалася б. Механіку змінюють через нове покоління (generation), а не правкою
рівно один запис на власника в поколіннідруга відправка не створює другого рядка
записне сутність (entity): власного життєвого циклу й машини станів у нього немає. Його створює перша відправка, а змінює оператор агрегації, який оголосила таблиця
поля поза ключем сортування ніколи не впливають на порядоквони для показу, тому їх і оголошують окремо
поколінняспливає, а не видаляється: open → expired → evicted from retention. Покоління, що спливли, лишаються доступними для читання протягом оголошеного строку зберігання (retention)
таблиця— авторський контент: її оголошують у коді, адресують за key, і вона потрапляє в адмін-консоль у режимі володіння seed, тож зміни розкладу від геймдизайнера переживають наступне завантаження
a submit, from server codeyour pre-submit hook: accept, correct or rejectthe open cycle, ranked by the order keythe reset (Monday 00:00 UTC) closes itthe closed cycle, readable under its labelthe next cycle opens, emptyyour cycle-closed hook: reads by the label, grants rewardsevicted when retention ends, with an event acceptedthe close event carries the label
Один цикл: відправки проходять через ваш фільтр, скидання закриває таблицю, а хук закриття циклу читає її за міткою, поки наступний цикл починається порожнім

Що таке цикл і що відбувається, коли він закривається.

Що це
скиданнязакриває цикл, а не видаляє його
закритий циклбільше не приймає відправок і лишається доступним для читання за своєю міткою — Top("weekly-score", 100, cycle: label): це параметр читання, а не завдання на експорт
подія закриттянесе цю мітку, тож обробник читає саме ту таблицю, що закрилася, а не порожню, яка щойно відкрилася

Два хуки таблиці, і вони різного роду.

ХукЩо може робити
pre-submitфільтр на вході: його викликають до запису, і платформа чекає на відповідь. Він повертає один із трьох вердиктів: прийняти, прийняти виправлену відправку або відхилити. Хук може звірити значення з вашими сутностями й виправити або обмежити їх, але не може змінити власника запису чи його таблицю — їх уже закріплено. Якщо хук збоїть, відправку відхиляють, а відмова надходить до викликача як типізований Problem (Core)
cycle-closedспостерігач: спрацьовує вже після події й нічого заборонити не може, а якщо він збоїть, цикл однаково лишається закритим
Обидва хуки на weekly-score: pre-submit відхиляє неможливий результат, cycle-closed нагороджує топ-10
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
Незабаром — Go

Як оголошувати через атрибути в Go, поки не вирішено (O-1). Приклади з’являться, коли оберуть спосіб запису.

Виконується поза рушієм

Хук — це хмарна функція: він виконується на платформі, а не в рушії. Пишіть його на C#, TypeScript або Python, а Unreal підписується на подію закриття циклу. Згодом можна буде писати функції і в рушії: графами Blueprint або на C++ (такий код перекладатимуть у скриптовий формат, який перевіряє платформа). Обидва варіанти проходять аналіз і відправку на платформу, як будь-яке оголошення, а виконуються все одно на платформі.

Виконується поза рушієм

Хук — це хмарна функція: він виконується на платформі, а не в рушії. Пишіть його на C#, TypeScript або Python, а Unity підписується на подію закриття циклу.

Нагорода — це видача (grant) з модуля Catalog & Commerce, а не механіка цього модуля. chest.gold — id у каталозі, а джерело reward відрізняє цю видачу від покупки. Повернення коштів, відкликання й подія зміни права володіння працюють із нею так само, як із купленим предметом.

Турніри. Турнір — це та сама таблиця плюс обмеження на участь. Другого механізму й окремої сутності немає. Чотири обмеження, їхні одиниці й поведінка на межі:

ОбмеженняЯк оголошуютьНа межі
вікно реєстраціїentryWindow: TimeSpan — скільки часу після відкриття циклу можна приєднатисяреєстрацію після закриття вікна відхиляють, а цикл триває до свого скидання
максимум учасниківmaxEntrants: int — кількість записів в одному циклі65-го учасника при максимумі 64 відхиляють як конфлікт, і нікого не витісняють: таблиця, яка викидає найгірші рядки, ранжувала б тих, хто прийшов першим
спроби за циклattemptsPerCycle: int — відправки на власниканаступна відправка отримує відповідь «спроби вичерпано» — це конфлікт, а не помилка дозволів, і лічильник обнуляється разом із циклом
обов’язкова реєстраціяjoinRequired: true — учасники — це ті, хто приєднався, а не всі, хто граєвідправку від гравця, який не зареєструвався, відхиляють

Щоденний турнір оголошує всі чотири обмеження від початку до кінця.

Помилки

Якщо сесія player викликає операцію, яку ця таблиця залишає для fn, — відправку в таблицю лише для сервера чи дострокове закриття циклу, — платформа відхиляє виклик як помилку дозволів, ще нічого не записавши. Той самий виклик із хмарної функції проходить. А от відправка в цикл, який уже закрився, — це конфлікт: повторіть її в поточний цикл.

Обмеження

Кожен ліміт і те, що відбувається на його межі.

ЛімітНа межіЧисло
рядків за одне читаннясторінку обрізають, прапорець „є ще“ лишається піднятим, after: продовжуєліміт сторінки задають для проєкту
вікно навколо власникаобрізають симетричноліміт вікна задають для проєкту
записів в одному циклівідправку відхиляють як конфлікт; нікого не витісняютьmaxEntrants для таблиці; без обмеження, якщо не задано
спроб на власника за циклконфлікт «спроби вичерпано», скидання його знімаєattemptsPerCycle для таблиці; без обмеження, якщо не задано
частота відправок на власникавідмова через обмеження частоти з моментом, коли можна повторитичастоту задають для проєкту
таблиць на проєктнове оголошення відхиляють під час деплоюліміт задають для проєкту
зберігання закритих циклівцикл іде зі сховища з подією, після цього читання відповідають not foundвікно зберігання задають для проєкту