Pular para o conteúdo principal
Serviços em torno do jogo

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ê

AtorO que faz nesta página
playerlê o top N, a janela ao redor de si e a própria posição, e assina as mudanças de posição
backend-serviceenvia resultados; corrige ou rejeita esses resultados no hook antes do envio; concede recompensas quando um ciclo fecha
operatordeclara os rankings; fecha um ciclo antes da hora, corrige registros (com auditoria), acompanha a frequência de envios

Resumo

Declaração de 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 it
Em breve — Go

Ainda 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
Unity C# is the same C# API here — a mesma classe de template compila no Unity, na base 2021.3; nada de sintaxe do C# 12 aqui
[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:

AggUm segundo envioIdempotente
Setsubstitui o registro pelos valores enviadossim
Bestsubstitui só quando os valores novos ficam mais bem posicionados pela chave de ordenaçãosim
Incrementsoma os valores enviados ao registro: abates, voltas, contribuição para a guildanão: passe uma chave de idempotência
Decrementsubtrai esses valoresnã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:

Um único Submit: os dois campos de ordenação e o campo de exibição, a partir da função dona do resultado
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")
Em breve — Go

Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.

Outro ator faz esta chamada

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.

Outro ator faz esta chamada

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 chamadaO que é
PlayServ · playservo 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 enviaa 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
playerIdo 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:

Top 100, a janela ao meu redor, as linhas de uma guilda pela lista de donos e a assinatura da posição ao vivo
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
Em breve — Go

Ainda 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.
Unity C# is the same C# API here — roda sem mudanças no Unity com os tipos gerados
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) é 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.

EixoValoresComo definir
Donojogador · grupoOwner = Owner.Player; um ranking de guildas é o mesmo ranking com Owner.Group
Chave de ordenaçãoum ou mais campos declarados, cada um crescente ou decrescente[Rank(1, Sort.Descending)] int Score
Agregaçãoset · best · increment · decrementAgg = Aggregation.Best
Reiníciouma agenda em UTC; um ciclo expira, nunca é apagadoReset = Reset.Weekly(DayOfWeek.Monday)
Quem pode enviarsó o servidor (padrão) · jogadoresSubmit = Submit.ServerOnly
Campos de exibiçãodeclarados e tipados; nunca entram na ordenação[Display] string Map
Lista de donosescolhida na leitura, não declaradaForOwners("weekly-score", ids): amigos, uma guilda, um lobby
Regras de torneioperíodo de inscrição · máximo de participantes · tentativas por ciclo · inscrição obrigatóriaRules = 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.

SempreO que é
a direção e o operador de agregaçãonã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çãoum segundo envio não vira uma segunda linha
um registronã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 ordemeles são de exibição, e é por isso que são declarados à parte
uma geraçãoexpira, 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
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
Um ciclo: os envios passam pelo seu filtro de entrada, o reinício fecha a tabela, e o hook de ciclo fechado a lê pela etiqueta enquanto o ciclo seguinte começa vazio

O que é um ciclo e o que acontece quando ele fecha.

O que é
um reiníciofecha um ciclo, em vez de apagá-lo
um ciclo fechadopara 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 fechamentoleva 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.

HookO que pode fazer
pre-submitum 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-closedum observador: disparado depois do fato, não pode vetar, e uma falha nele deixa o ciclo fechado mesmo assim
Os dois hooks de 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)
Em breve — Go

Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.

Executa fora da engine

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.

Executa fora da engine

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çãoComo é declaradaNo limite
período de inscriçãoentryWindow: TimeSpan: por quanto tempo a inscrição fica aberta depois que o ciclo abreuma inscrição depois do fechamento é recusada; o ciclo continua até o reinício
máximo de participantesmaxEntrants: int: registros num cicloo participante 65 de 64 é recusado como conflito, e ninguém é removido: um ranking que descartasse as piores linhas premiaria quem chegou primeiro
tentativas por cicloattemptsPerCycle: int: envios por donoo 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óriajoinRequired: true: os participantes são uma lista de inscritos, não todo mundo que jogao 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.

LimiteNo limiteNúmero
linhas por leituraa página é cortada, “há mais” continua verdadeiro, after: continuateto de página definido por projeto
janela ao redor de um donocortada de forma simétricateto de janela definido por projeto
registros num cicloo envio é recusado como conflito; ninguém é removidomaxEntrants por ranking; ilimitado quando não definido
tentativas por dono em cada cicloconflito “tentativas esgotadas”, zerado pelo reinícioattemptsPerCycle por ranking; ilimitado quando não definido
frequência de envios por donorecusa por limite de frequência, com o momento em que se pode tentar de novofrequência definida por projeto
rankings por projetouma nova declaração é recusada no deploylimite definido por projeto
retenção de ciclos fechadoso ciclo sai do armazenamento com um evento; a partir daí, as leituras respondem not-foundjanela de retenção definida por projeto