Эта страница ещё не переведена — показана английская версия.
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, пока не решено (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[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, пока не решено (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): он выполняется, когда на руках уже финальное состояние |
playerId | id игрока на платформе из Auth & Players, а не имя, которое вы придумали. Хук берёт его из своих данных (e.By.PlayerId в уроке), а хост комнаты отправляет id места, которым владеет |
Значения — это поля, названные в объявлении. Необъявленное поле отклоняется, а не сохраняется.
Чтения, которые нужны любой игре, и подписка, которая держит их в актуальном состоянии:
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 closesconst 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 closestop = 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, пока не решено (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.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 closesAroundMe("weekly-score", 5) — это окно по месту, а не страница: пять строк выше вас, пять ниже и ваша собственная — всего одиннадцать. У края таблицы окно урезается симметрично, поэтому игрок на втором месте получит окно короче с обеих сторон, а не сдвинутое. Top постраничный: он возвращает первые N строк и курсор, а через after: можно пройти остальные.
Так через ForOwners и делают таблицу друзей. Графа друзей на платформе нет: вы передаёте владельцев, которые уже есть в игре, — участников группы или список id из ваших собственных данных. Каждая строка возвращается со своим местом во всей таблице, а не внутри списка.
OnRankChanged присылает только место локального игрока и ничего больше: таблица на пятьдесят тысяч участников не рассылает каждую перестановку каждому клиенту. Колбэк получает изменившуюся строку — место, ранжируемые поля и поля для показа, — а Cancel() завершает подписку. Само место — это снапшот: два чтения с разницей в секунду могут отличаться, пока приходят отправки. Но собственную отправку вы всегда увидите при следующем своём чтении.
Модель
Что объявляет таблица.
| Ось | Значения | Как задать |
|---|---|---|
| Владелец | игрок · группа | Owner = Owner.Player — таблица гильдий — это та же таблица с Owner.Group |
| Ключ сортировки | одно или несколько объявленных полей, каждое по возрастанию или по убыванию | [Rank(1, Sort.Descending)] int Score |
| Агрегация | set · best · increment · decrement | Agg = 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 |
Что такое цикл и что происходит при его закрытии.
| Что это | |
|---|---|
сброс | закрывает цикл, а не удаляет его |
закрытый цикл | больше не принимает отправки и остаётся доступным для чтения по своей метке — 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, пока не решено (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 | срок хранения задаётся для проекта |