Saltar al contenido principal
Servicios alrededor del juego

Esta página aún no está traducida: se muestra la versión en inglés.

Leaderboards

Un solo modelo de tabla cuyos ejes se combinan para formar cualquier tipo de tabla de clasificación: posiciones del día, mejores vueltas, totales de gremio, temporadas, torneos.

Cuándo usarlo

  • Las puntuaciones deben ordenar a los jugadores (posiciones del día, mejores vueltas, totales de gremio) con un único modelo declarado, no con un sistema distinto para cada tabla de clasificación (leaderboard).
  • Necesitas las lecturas estándar (top-N, alrededor de mí, una lista concreta de dueños) sin modelar datos adicionales.
  • Los ciclos deben cerrarse según el horario, archivarse (nunca borrarse) y disparar un hook de recompensas con la tabla final.
  • Las puntuaciones sospechosas nunca deben entrar en la tabla: un hook previo al envío las valida, las limita o las rechaza con un motivo tipado.
  • Un torneo es la misma tabla con una ventana de inscripción, un máximo de participantes y un número de intentos por ciclo.
  • No lo necesitas si el número nunca se compara entre jugadores. Un contador personal o un total de carrera son datos comunes. El módulo ordena resultados, nunca los calcula, y no maneja llaves de eliminación.

Quién hace qué

ActorQué hace en esta página
playerlee el top-N, la ventana a su alrededor y su propia posición, y se suscribe a los cambios de posición
backend-serviceenvía resultados; los corrige o los rechaza en el hook previo al envío; otorga recompensas cuando se cierra un ciclo
operatordeclara las tablas; cierra un ciclo antes de tiempo, corrige registros (con auditoría) y vigila la frecuencia de envíos

En resumen

Declarar weekly-score: dueño, agregación, reinicio los lunes, envíos desde el servidor y la clave de orden
[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
Próximamente — Go

Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.

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 — la misma clase de plantilla compila en Unity sobre la versión base 2021.3; aquí no hay sintaxis de 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
}

La declaración vive junto al resto de tu esquema —en el proyecto del servidor o en el proyecto de UE o Unity—, y playserv push la compila y la sube: la tabla aparece vacía en el panel, con su próximo reinicio ya programado. Los horarios están en UTC, así que esta tabla cierra el lunes a las 00:00 UTC; Reset.Weekly(DayOfWeek.Monday, at: "03:00") cambia la hora. La hora local de cada jugador no es una opción de reinicio: una misma tabla no puede cerrarse en veinticuatro momentos distintos.

La clave de orden es una lista, no una puntuación más un criterio de desempate. Los campos ordenan en el orden en que los numeras, cada uno con su propia dirección, y el último nivel lo pone la plataforma: si las claves son iguales, el envío más antiguo queda más arriba, así que dos partidas idénticas nunca intercambian lugares entre una lectura y otra. Un campo fuera de la clave —aquí, Map— viaja solo para mostrarse y nunca mueve una fila.

Agg define qué le hace un segundo envío al único registro que tiene un dueño en el ciclo actual:

AggUn segundo envíoIdempotente
Setreemplaza el registro con los valores enviadossí
Bestlo reemplaza solo si los valores nuevos quedan más arriba según la clave de ordensí
Incrementsuma los valores enviados al registro: bajas, vueltas, aporte al gremiono: pasa una clave de idempotencia
Decrementlos restano: pasa una clave de idempotencia

Un envío que no supera un registro Best no es un error: vuelve como aceptado y el orden no cambia. Increment y Decrement son los dos que una llamada reintentada aplicaría dos veces, así que llevan la misma clave de idempotencia que cualquier otra escritura que se pueda reintentar.

Enviar es una sola llamada, y en esta tabla sale del código del servidor porque así lo dice la declaración:

Un solo Submit: los dos campos que ordenan y el campo que se muestra, desde la función dueña del 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")
Próximamente — Go

Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.

Esto lo llama otro actor

La llamada existe en Unreal. Esta tabla conserva el valor por defecto Submit.ServerOnly, así que la plataforma acepta un envío solo desde una función en la nube o desde el host de una sala (room) con su clave de host. Declara Submit.Players y la misma llamada funciona desde el cliente. Consulta Access & Roles.

Esto lo llama otro actor

La llamada existe en Unity. Esta tabla conserva el valor por defecto Submit.ServerOnly, así que la plataforma acepta un envío solo desde una función en la nube o desde el host de una sala con su clave de host. Declara Submit.Players y la misma llamada funciona desde el cliente. Consulta Access & Roles.

Vale la pena mirar por separado tres cosas de esa llamada:

En la llamadaQué es
PlayServ · playservel handle de la función en la nube y la instancia de cliente que el SDK te entrega al arrancar. La misma API con dos llamadores: en Go son ps y psv, y cada fragmento usa el que tiene quien llama
quien envíala función dueña del resultado de la partida. En Tanks es el hook on dispose de la sala (Rooms), que se ejecuta con el estado final en la mano
playerIdel id de jugador de la plataforma que viene de Auth & Players, nunca un nombre que elegiste tú: un hook lo lee de su payload (e.By.PlayerId en la lección), y el host de una sala envía el id del lugar (seat) que le pertenece

Los valores son los campos que nombró la declaración: un campo no declarado se rechaza, no se guarda.

Las lecturas que necesita todo juego y la suscripción que las mantiene al día:

El top 100, la ventana a mi alrededor, las filas de un gremio por lista de dueños y una suscripción en vivo a la posición
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
Próximamente — Go

Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.

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 — funciona tal cual en Unity con los tipos generados
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) es una ventana por posición, no una página: cinco filas por encima de ti, cinco por debajo y la tuya, once filas en total. Donde termina la tabla se recorta de forma simétrica, así que la posición 2 recibe una ventana más corta por ambos lados en lugar de una desplazada. Top es paginado: devuelve las primeras N filas y un cursor, y after: recorre el resto.

Con ForOwners funciona una tabla de amigos. La plataforma no guarda un grafo de amistades: le pasas los dueños que tu juego ya conoce —los miembros de un grupo o una lista de ids de tus propios datos— y cada fila vuelve con su posición en la tabla completa, no con su posición dentro de la lista.

OnRankChanged entrega solo la posición del jugador local: una tabla con cincuenta mil participantes no le envía cada reacomodo a cada cliente. El callback recibe la fila que cambió —posición, campos que ordenan, campos que se muestran— y Cancel() termina la suscripción. La posición en sí es un snapshot: dos lecturas con un segundo de diferencia pueden dar resultados distintos mientras llegan envíos, aunque tu propio envío siempre se ve en tu siguiente lectura.

El modelo

Qué declara una tabla.

EjeValoresCómo se define
Dueñojugador · grupoOwner = Owner.Player; una tabla de gremios es la misma tabla con Owner.Group
Clave de ordenuno o más campos declarados, cada uno ascendente o descendente[Rank(1, Sort.Descending)] int Score
Agregaciónset · best · increment · decrementAgg = Aggregation.Best
Reinicioun horario en UTC; un ciclo expira, nunca se borraReset = Reset.Weekly(DayOfWeek.Monday)
Quién puede enviarsolo el servidor (por defecto) · los jugadoresSubmit = Submit.ServerOnly
Campos que se muestrandeclarados y tipados; nunca forman parte del orden[Display] string Map
Lista de dueñosse elige al leer, no se declaraForOwners("weekly-score", ids): amigos, un gremio, un lobby
Reglas del torneoventana de inscripción · máximo de participantes · intentos por ciclo · inscripción obligatoriaRules = Tournament.Define(…), en la tabla de Torneos

No hay un eje de alcance: una tabla por región, por sala o por temporada es una tabla por clave, y la clave es lo que referencia tu código.

Qué se cumple para toda tabla.

SiempreQué es
la dirección y el operador de agregaciónno cambian después de la primera escritura: cambiarlos reordenaría el historial en silencio. Para cambiar una mecánica se crea una generación nueva, no se edita la existente
exactamente un registro por dueño en cada generaciónun segundo envío no crea una segunda fila
un registrono es una entidad: no tiene ciclo de vida propio ni máquina de estados. Lo crea el primer envío y lo cambia el operador de agregación que declaró la tabla
los campos fuera de la clave de orden nunca afectan el ordensolo se muestran, y por eso se declaran aparte
una generaciónexpira, no se borra: open → expired → evicted from retention, y las generaciones expiradas siguen legibles durante el período de retención declarado
una tablaes contenido creado por el equipo: se declara en el código, se identifica por una key y llega a la consola de administración en el modo de propiedad seed, de modo que los cambios de horario de un diseñador sobreviven a la siguiente subida
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
Un ciclo: los envíos pasan por tu filtro de entrada, el reinicio cierra la tabla y el hook de cierre de ciclo la lee por su etiqueta mientras el siguiente ciclo empieza vacío

Qué es un ciclo y qué pasa al cerrarlo.

Qué es
un reiniciocierra un ciclo en lugar de borrarlo
un ciclo cerradodeja de aceptar envíos y sigue legible con su etiqueta: Top("weekly-score", 100, cycle: label) es un parámetro de lectura, no un trabajo de exportación
el evento de cierrelleva esa etiqueta, así que un handler lee justo la tabla que se cerró y no la vacía que se acaba de abrir

Los dos hooks de una tabla, y son de tipos distintos.

HookQué puede hacer
pre-submitun filtro de entrada (gatekeeper): se llama antes de la escritura y la plataforma espera su respuesta. Devuelve uno de tres veredictos: aceptar, aceptar un envío corregido o rechazar. Puede corregir los valores contra tus propias entidades o limitarlos; no puede cambiar el dueño del registro ni su tabla, que ya están asignados. Si falla, rechaza, y el rechazo le llega a quien llama como un Problem tipado (Core)
cycle-closedun observador: se dispara después del hecho, no puede vetar nada, y si falla, el ciclo queda cerrado igual
Los dos hooks de weekly-score: el previo al envío rechaza una puntuación imposible y el de cierre de ciclo premia al 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)
Próximamente — Go

Todavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.

Se ejecuta fuera del motor

Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unreal se suscribe al evento de cierre de ciclo. Están planeadas funciones escritas desde el motor: grafos de Blueprint y C++ (traducido a un formato de script que valida la plataforma). Ambas se analizan y se suben como cualquier declaración, y la ejecución sigue ocurriendo en la plataforma.

Se ejecuta fuera del motor

Un hook es una función en la nube: se ejecuta en la plataforma, no en el motor. Escríbelo en C#, TypeScript o Python; Unity se suscribe al evento de cierre de ciclo.

La recompensa es un otorgamiento de Catalog & Commerce, no una mecánica de este módulo: chest.gold es un id del catálogo, y el origen reward es lo que distingue ese otorgamiento de una compra. Los reembolsos, la revocación y el evento de cambio de derecho de uso funcionan sobre él exactamente igual que sobre un artículo comprado.

Torneos. Un torneo es esta misma tabla más restricciones de participación: no hay un segundo mecanismo ni una entidad aparte. Estas son las cuatro restricciones, con sus unidades y su comportamiento en el límite:

RestricciónCómo se declaraEn el límite
ventana de inscripciónentryWindow: TimeSpan: cuánto tiempo sigue abierta la inscripción después de que se abre el ciclouna inscripción después del cierre se rechaza; el ciclo sigue hasta su reinicio
máximo de participantesmaxEntrants: int: registros en un cicloel participante 65 de 64 se rechaza como conflicto, y no se saca a nadie: una tabla que descartara sus peores filas premiaría a quien llegó primero
intentos por cicloattemptsPerCycle: int: envíos por dueñoel siguiente envío responde “intentos agotados”: un conflicto, no un error de permisos, y el contador se reinicia con el ciclo
inscripción obligatoriajoinRequired: true: los participantes son una lista de inscritos, no todos los que jueganse rechaza el envío de alguien que no está inscrito

La receta Torneo diario declara las cuatro de principio a fin.

Errores

Si una sesión de player llama a una operación que esta tabla reserva para fn —un envío a una tabla solo para el servidor, un cierre anticipado de ciclo—, se rechaza como error de permisos antes de escribir nada; la misma llamada desde una función en la nube pasa. En cambio, un envío a un ciclo que ya se cerró es un conflicto; para reintentar, envía al ciclo actual.

Límites

Cada límite y lo que pasa al alcanzarlo.

LímiteAl alcanzarloCifra
filas por lecturala página se recorta, “hay más” sigue en verdadero y after: continúatope de página definido por proyecto
ventana alrededor de un dueñose recorta de forma simétricatope de ventana definido por proyecto
registros en un cicloel envío se rechaza como conflicto; no se saca a nadiemaxEntrants por tabla; sin límite si no se define
intentos por dueño en cada cicloconflicto “intentos agotados”, que se borra con el reinicioattemptsPerCycle por tabla; sin límite si no se define
frecuencia de envíos por dueñorechazo por límite de frecuencia que indica cuándo se permite reintentarfrecuencia definida por proyecto
tablas por proyectouna declaración nueva se rechaza al desplegarlímite definido por proyecto
retención de ciclos cerradosel ciclo sale del almacenamiento con un evento; desde entonces las lecturas responden not-foundventana de retención definida por proyecto