Перейти к основному содержимому
Сервисы вокруг игры

Эта страница ещё не переведена — показана английская версия.

Leaderboards

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

Когда это нужно

  • Игроков нужно ранжировать по очкам — дневной рейтинг, лучший круг, сумма по гильдии, — и всё это одной объявленной моделью, а не отдельной системой под каждую таблицу лидеров (leaderboard).
  • Нужны стандартные чтения — топ-N, окно вокруг игрока, строки для заданного списка владельцев — без дополнительного моделирования данных.
  • Циклы должны закрываться по расписанию, уходить в архив (а не удаляться) и вызывать хук награды с финальной таблицей.
  • Подозрительные результаты не должны попадать в таблицу: хук перед отправкой проверяет их, урезает или отклоняет с типизированной причиной.
  • Турнир — это та же таблица, только с окном регистрации, максимумом участников и числом попыток за цикл.
  • Не нужен, если число никогда не сравнивают между игроками. Личный счётчик или общий итог за карьеру — это обычные данные. Модуль упорядочивает результаты, но никогда их не вычисляет и не строит сетку на выбывание.

Кто что делает

АкторЧто делает на этой странице
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
}

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

Ключ сортировки — это список, а не очки плюс правило для ничьей. Поля ранжируются в том порядке, в котором вы их пронумеровали, у каждого своё направление, а последний уровень добавляет платформа: при равных ключах выше стоит та отправка, что пришла раньше. Поэтому два одинаковых забега никогда не поменяются местами между двумя чтениями. Поле вне ключа — здесь это Map — только показывается и никогда не сдвигает строку.

Agg определяет, что делает вторая отправка с единственной записью владельца в текущем цикле:

AggВторая отправкаИдемпотентна
Setзаменяет запись присланными значениямида
Bestзаменяет её, только если новые значения стоят выше по ключу сортировкида
Incrementприбавляет присланные значения к записи — убийства, круги, вклад в гильдиюнет — передавайте ключ идемпотентности
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дескриптор облачной функции и экземпляр клиента, которые SDK выдаёт вам при запуске. API один, вызывающих двое — в Go это ps и psv, и в каждом примере используется тот, что есть у вызывающего
отправительфункция, которой принадлежит результат матча. В Tanks это хук комнаты on dispose (Rooms): он выполняется, когда на руках уже финальное состояние
playerIdid игрока на платформе из Auth & Players, а не имя, которое вы придумали. Хук берёт его из своих данных (e.By.PlayerId в уроке), а хост комнаты отправляет id места, которым владеет

Значения — это поля, названные в объявлении. Необъявленное поле отклоняется, а не сохраняется.

Чтения, которые нужны любой игре, и подписка, которая держит их в актуальном состоянии:

Топ-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), а не правкой
ровно одна запись на владельца в поколениивторая отправка не создаёт вторую строку
записьне сущность: у неё нет своего жизненного цикла и своей машины. Её создаёт первая отправка, а меняет её тот оператор агрегации, который объявила таблица
поля вне ключа сортировки не влияют на порядокони служат для показа, поэтому и объявляются отдельно
поколениеистекает, а не удаляется: open → expired → evicted from retention. Истёкшие поколения остаются доступными для чтения в течение объявленного срока хранения
таблица— авторский контент: объявляется в коде, адресуется по key и попадает в админ-консоль в режиме владения seed, поэтому правки расписания, сделанные геймдизайнером, переживают следующий push
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 выдаёт награды первой десятке
[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 подписывается на событие закрытия цикла.

Награда — это выдача из модуля Catalog & Commerce, а не механика этого модуля. chest.gold — id в каталоге, а источник reward отличает эту выдачу от покупки. Возвраты, отзыв и событие изменения права владения работают с ней ровно так же, как с купленным предметом.

Турниры. Турнир — это та же таблица плюс ограничения на участие. Второго механизма и отдельной сущности нет. Четыре ограничения, их единицы и поведение на границе:

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

Все четыре ограничения от начала до конца объявлены в рецепте Ежедневный турнир.

Ошибки

Если сессия player вызывает операцию, которую таблица оставляет за fn, — отправку в таблицу только для сервера или досрочное закрытие цикла, — вызов отклоняется как ошибка прав ещё до какой-либо записи. Тот же вызов из облачной функции проходит. Отправка в уже закрытый цикл — это уже конфликт: повторять нужно отправкой в текущий цикл.

Ограничения

Каждое ограничение и то, что происходит на его границе.

ОграничениеНа границеЧисло
строк за одно чтениестраница обрезается, флаг «есть ещё» остаётся выставленным, after: продолжаетпредел страницы задаётся для проекта
окно вокруг владельцаурезается симметричнопредел окна задаётся для проекта
записей в одном циклеотправка отклоняется как конфликт; никого не вытесняютmaxEntrants для таблицы; без ограничения, если не задан
попыток на владельца за циклконфликт «попытки исчерпаны», обнуляется при сбросе циклаattemptsPerCycle для таблицы; без ограничения, если не задан
частота отправок на владельцаотказ по ограничению частоты с моментом, когда можно повторитьчастота задаётся для проекта
таблиц на проектновое объявление отклоняется при деплоепредел задаётся для проекта
хранение закрытых цикловцикл уходит из хранилища с событием, после этого чтения отвечают not foundсрок хранения задаётся для проекта