Esta página ainda não foi traduzida: mostrando a versão em inglês.
Leaderboards
Um único modelo de ranking cujos eixos se combinam em qualquer tipo de ranking: classificação do dia, melhor volta, totais de guilda, temporadas, torneios.
Quando usar
- As pontuações precisam ordenar os jogadores (classificação do dia, melhor volta, totais de guilda) com um único modelo declarado, e não com um sistema diferente para cada ranking (leaderboard).
- Você precisa das leituras padrão (top N, ao redor de mim, uma lista específica de donos) sem modelar dados extras.
- Os ciclos precisam fechar no horário, ser arquivados (nunca apagados) e disparar um hook de recompensa com a tabela final.
- Pontuações suspeitas nunca podem entrar na tabela: um hook antes do envio valida, limita ou rejeita com um motivo tipado.
- Um torneio é o mesmo ranking com um período de inscrição, um máximo de participantes e um número de tentativas por ciclo.
- Dispensável quando o número nunca é comparado entre jogadores. Um contador pessoal ou um total de carreira são dados comuns. O módulo ordena resultados, nunca os calcula, e não monta chaveamento eliminatório.
Quem faz o quê
| Ator | O que faz nesta página |
|---|---|
player | lê o top N, a janela ao redor de si e a própria posição, e assina as mudanças de posição |
backend-service | envia resultados; corrige ou rejeita esses resultados no hook antes do envio; concede recompensas quando um ciclo fecha |
operator | declara os rankings; fecha um ciclo antes da hora, corrige registros (com auditoria), acompanha a frequência de envios |
Resumo
weekly-score: dono, agregação, reinício na segunda-feira, envio pelo servidor e a chave de ordenação[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 itAinda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
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
}A declaração fica junto com o resto do seu esquema, no projeto do servidor ou no projeto da UE ou do Unity, e playserv push a compila e a envia: o ranking aparece vazio no painel, com o próximo reinício já agendado. Os horários são em UTC, então este ranking fecha na segunda-feira às 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") muda a hora. O horário local de cada jogador não é uma opção de reinício: uma mesma tabela não pode fechar em vinte e quatro momentos diferentes.
A chave de ordenação é uma lista, não uma pontuação mais um critério de desempate. Os campos ordenam na sequência em que você os numera, cada um com a própria direção, e o último nível é da plataforma: com chaves iguais, o envio mais antigo fica mais bem posicionado, então duas partidas idênticas nunca trocam de lugar entre duas leituras. Um campo fora da chave (aqui, Map) vai junto só para exibição e nunca move uma linha.
Agg define o que um segundo envio faz com o único registro que um dono tem no ciclo atual:
Agg | Um segundo envio | Idempotente |
|---|---|---|
Set | substitui o registro pelos valores enviados | sim |
Best | substitui só quando os valores novos ficam mais bem posicionados pela chave de ordenação | sim |
Increment | soma os valores enviados ao registro: abates, voltas, contribuição para a guilda | não: passe uma chave de idempotência |
Decrement | subtrai esses valores | não: passe uma chave de idempotência |
Um envio que não supera um registro Best não é um erro: ele volta como aceito, e a ordem não muda. Increment e Decrement são os dois que uma chamada repetida aplicaria duas vezes, por isso levam a mesma chave de idempotência de qualquer outra escrita que pode ser repetida.
Enviar é uma chamada só, e neste ranking ela sai do código do servidor porque a declaração mandou assim:
Submit: os dois campos de ordenação e o campo de exibição, a partir da função dona do resultadoawait 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")Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
A chamada existe no Unreal. Este ranking mantém o padrão Submit.ServerOnly, então a plataforma só aceita envios de uma cloud function ou do host de uma sala com a chave de host dele. Declare Submit.Players, e a mesma chamada funciona no cliente. Veja Access & Roles.
A chamada existe no Unity. Este ranking mantém o padrão Submit.ServerOnly, então a plataforma só aceita envios de uma cloud function ou do host de uma sala com a chave de host dele. Declare Submit.Players, e a mesma chamada funciona no cliente. Veja Access & Roles.
Vale ler separadamente três coisas dessa chamada:
| Na chamada | O que é |
|---|---|
PlayServ · playserv | o handle da cloud function e a instância do cliente que o SDK entrega na inicialização. A mesma API com dois chamadores: em Go são ps e psv, e cada trecho usa o que o seu chamador tem |
| quem envia | a função dona do resultado da partida. No Tanks, é o hook on dispose da sala (room), descrito em Rooms, que roda com o estado final em mãos |
playerId | o id de jogador da plataforma, vindo de Auth & Players, nunca um nome escolhido por você: um hook o lê do próprio payload (e.By.PlayerId na lição), e o host de uma sala envia o id da vaga (seat) que ele controla |
Os valores são os campos que a declaração nomeou: um campo não declarado é recusado, não armazenado.
As leituras de que todo jogo precisa, e a assinatura (subscription) que as mantém atualizadas:
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 closesAinda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
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) é uma janela por posição, não uma página: cinco linhas acima de você, cinco abaixo, mais a sua, onze linhas no total, cortadas de forma simétrica onde a tabela termina. Assim, quem está na posição 2 recebe uma janela mais curta dos dois lados, e não uma janela deslocada. Top é paginado: devolve as primeiras N linhas e um cursor, e after: percorre o resto.
ForOwners é como funciona um ranking de amigos. A plataforma não guarda um grafo de amizades; você passa os donos que o seu jogo já tem (os membros de um grupo ou uma lista de ids dos seus próprios dados), e cada linha volta com a posição na tabela inteira, não a posição dentro da lista.
OnRankChanged entrega a posição do jogador local e nada mais: um ranking com cinquenta mil participantes não empurra cada reordenação para cada cliente. O callback recebe a linha que mudou (posição, campos de ordenação, campos de exibição), e Cancel() encerra a assinatura. A posição em si é um snapshot: duas leituras com um segundo de diferença podem divergir enquanto chegam envios, mas o seu próprio envio sempre aparece na sua próxima leitura.
O modelo
O que um ranking declara.
| Eixo | Valores | Como definir |
|---|---|---|
| Dono | jogador · grupo | Owner = Owner.Player; um ranking de guildas é o mesmo ranking com Owner.Group |
| Chave de ordenação | um ou mais campos declarados, cada um crescente ou decrescente | [Rank(1, Sort.Descending)] int Score |
| Agregação | set · best · increment · decrement | Agg = Aggregation.Best |
| Reinício | uma agenda em UTC; um ciclo expira, nunca é apagado | Reset = Reset.Weekly(DayOfWeek.Monday) |
| Quem pode enviar | só o servidor (padrão) · jogadores | Submit = Submit.ServerOnly |
| Campos de exibição | declarados e tipados; nunca entram na ordenação | [Display] string Map |
| Lista de donos | escolhida na leitura, não declarada | ForOwners("weekly-score", ids): amigos, uma guilda, um lobby |
| Regras de torneio | período de inscrição · máximo de participantes · tentativas por ciclo · inscrição obrigatória | Rules = Tournament.Define(…), na tabela em Torneios |
Não existe eixo de escopo: um ranking por região, por sala ou por temporada é um ranking por chave, e é a chave que o seu código referencia.
O que vale para todo ranking.
| Sempre | O que é |
|---|---|
a direção e o operador de agregação | não mudam depois da primeira escrita: mudar os dois reordenaria o histórico em silêncio. Para mudar uma mecânica, crie uma nova geração, em vez de editar |
exatamente um registro por dono em cada geração | um segundo envio não vira uma segunda linha |
um registro | não é uma entidade: não tem ciclo de vida próprio nem máquina de estados. Ele é criado pelo primeiro envio e alterado pelo operador de agregação que o ranking declarou |
campos fora da chave de ordenação nunca afetam a ordem | eles são de exibição, e é por isso que são declarados à parte |
uma geração | expira, não é apagada: open → expired → evicted from retention, e as gerações expiradas continuam legíveis durante o período de retenção declarado |
um ranking | é conteúdo criado pela equipe: declarado no código, identificado por uma key e levado ao console de administração no modo de posse seed, para que as edições de agenda de um designer sobrevivam à próxima publicação |
O que é um ciclo e o que acontece quando ele fecha.
| O que é | |
|---|---|
um reinício | fecha um ciclo, em vez de apagá-lo |
um ciclo fechado | para de aceitar envios e continua legível pela etiqueta: Top("weekly-score", 100, cycle: label), um parâmetro de leitura e não um job de exportação |
o evento de fechamento | leva essa etiqueta, então um handler lê exatamente a tabela que fechou, e não a vazia que acabou de abrir |
Os dois hooks de um ranking são de tipos diferentes.
| Hook | O que pode fazer |
|---|---|
pre-submit | um filtro de entrada (gatekeeper), chamado antes da escrita, e a plataforma espera por ele. Devolve um de três veredictos: aceitar, aceitar um envio corrigido ou rejeitar. Pode corrigir os valores com base nas suas próprias entidades ou limitá-los; não pode mudar o dono do registro nem o ranking dele, que já estão definidos. Bloqueia em caso de falha, e a rejeição chega a quem chamou como um Problem tipado (Core) |
cycle-closed | um observador: disparado depois do fato, não pode vetar, e uma falha nele deixa o ciclo fechado mesmo assim |
weekly-score: pre-submit rejeita uma pontuação impossível, cycle-closed premia o top 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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
Um hook é uma cloud function: ele roda na plataforma, não na engine. Escreva em C#, TypeScript ou Python; o Unreal assina o evento de ciclo fechado. Estão planejadas funções escritas na engine: grafos de Blueprint e C++ (traduzido para um formato de script validado pela plataforma). As duas são analisadas e publicadas como qualquer declaração, e a execução continua na plataforma.
Um hook é uma cloud function: ele roda na plataforma, não na engine. Escreva em C#, TypeScript ou Python; o Unity assina o evento de ciclo fechado.
A recompensa é uma concessão de Catalog & Commerce, não uma mecânica deste módulo: chest.gold é um id do catálogo, e a origem reward é o que separa essa concessão de uma compra. Reembolsos, revogação e o evento de mudança de direito de uso (entitlement) funcionam nela exatamente como num item comprado.
Torneios. Um torneio é este mesmo ranking mais restrições de participação: não existe um segundo mecanismo nem uma entidade separada. As quatro restrições, com as unidades e o comportamento no limite:
| Restrição | Como é declarada | No limite |
|---|---|---|
| período de inscrição | entryWindow: TimeSpan: por quanto tempo a inscrição fica aberta depois que o ciclo abre | uma inscrição depois do fechamento é recusada; o ciclo continua até o reinício |
| máximo de participantes | maxEntrants: int: registros num ciclo | o participante 65 de 64 é recusado como conflito, e ninguém é removido: um ranking que descartasse as piores linhas premiaria quem chegou primeiro |
| tentativas por ciclo | attemptsPerCycle: int: envios por dono | o próximo envio responde “tentativas esgotadas”, um conflito e não um erro de permissão, e o contador reinicia com o ciclo |
| inscrição obrigatória | joinRequired: true: os participantes são uma lista de inscritos, não todo mundo que joga | o envio de quem não está inscrito é recusado |
O Torneio diário declara as quatro de ponta a ponta.
Erros
Quando uma sessão de player chama uma operação que este ranking reserva para fn (um envio a um ranking só do servidor, um fechamento antecipado de ciclo), a chamada é recusada como erro de permissão antes de qualquer escrita; a mesma chamada a partir de uma cloud function passa. Já um envio a um ciclo que já fechou é um conflito; para tentar de novo, envie ao ciclo atual.
Limites
Cada limite e o que acontece quando ele é atingido.
| Limite | No limite | Número |
|---|---|---|
| linhas por leitura | a página é cortada, “há mais” continua verdadeiro, after: continua | teto de página definido por projeto |
| janela ao redor de um dono | cortada de forma simétrica | teto de janela definido por projeto |
| registros num ciclo | o envio é recusado como conflito; ninguém é removido | maxEntrants por ranking; ilimitado quando não definido |
| tentativas por dono em cada ciclo | conflito “tentativas esgotadas”, zerado pelo reinício | attemptsPerCycle por ranking; ilimitado quando não definido |
| frequência de envios por dono | recusa por limite de frequência, com o momento em que se pode tentar de novo | frequência definida por projeto |
| rankings por projeto | uma nova declaração é recusada no deploy | limite definido por projeto |
| retenção de ciclos fechados | o ciclo sai do armazenamento com um evento; a partir daí, as leituras respondem not-found | janela de retenção definida por projeto |