Saltar al contenido principal
Bloques de construcción

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

Groups

Un grupo es un conjunto de actores con nombre que recibe como uno solo. Te diriges al grupo y todos sus miembros se enteran. Una sala, un chat, un pool de emparejamiento y una lista de envío son la misma primitiva sobre la misma lista, con distintas reglas de entrada y salida y distintos tiempos de vida.

Cuándo usarlo

  • Necesitas parties, escuadrones o clanes: conjuntos de jugadores con nombre, con una capacidad declarada y, si el tipo lo declara, un tiempo de vida.
  • La participación (membership) debe seguir una regla declarada que evalúa la plataforma: los nuevos veteranos entran solos, sin un cron job ni una llamada de reevaluación propia.
  • Quieres dirigirte a muchos jugadores a la vez: un evento declarado se difunde con send.*, y un RPC declarado llega a cada miembro y cada respuesta vuelve con su nombre.
  • Necesitas reutilizar un mismo modelo de participación como audiencia: un ámbito de visibilidad, una conversación de mensajería, una party de emparejamiento.
  • No crees uno si el conjunto son los miembros de una sesión. Rooms es esta primitiva con las reglas de una sala (room), y ya se dirige a ellos.

Quién hace qué

ActorQué hace en esta página
playercrea grupos a partir de tipos declarados, entra y sale, agrega o quita miembros, envía eventos, invoca RPC en abanico; si tiene el derecho de administrar la participación de un grupo, quita miembros y lo cierra
room-ownerlas reglas de lugares de la sala funcionan sobre esta primitiva (se configuran en Rooms)
backend-servicedeclara los tipos de grupo y sus reglas; hooks de entrada y salida

En resumen

Un grupo declarado por regla, un escuadrón con capacidad y tiempo de vida declarados, difusión con send.* y una respuesta por miembro
// dynamic: the predicate decides membership, and the platform keeps the list current
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

// the event a squad can carry — declared once, surfaced as send.* / on.*
[Event("rally_call")]
public record RallyCall(Vector3 Position);

// an instance of a declared type — a runtime act, so a call
var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

// the group is an address
squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group
// dynamic: the predicate decides membership, and the platform keeps the list current
@Group('veterans', { capacity: 500 })
@GroupRule('player.stats.matches >= 100')
export class Veterans {}

// explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@Group('squad', { capacity: 4, lifetime: '2h',
                  create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
export class Squad {}

// the event a squad can carry — declared once, surfaced as send.* / on.*
@Event('rally_call')
export class RallyCall { constructor(public position: Vector3) {} }

// an instance of a declared type — a runtime act, so a call
const squad = await playserv.groups.squad.create('squad-7');
await squad.add(friendId);

// the group is an address
squad.send.rallyCall(position);                   // declared event → generated method
const members = await squad.getMembers();         // declared data → typed, subscribable

for await (const answer of squad.invoke.readyCheck())   // N calls, one per member
  hud.mark(answer.member, answer.ready);                // each answer names who sent it

const game = playserv.group('game');              // addressing sugar for one group
# dynamic: the predicate decides membership, and the platform keeps the list current
@group("veterans", capacity=500)
@group_rule("player.stats.matches >= 100")
class Veterans: ...

# explicit: members are added by an act — capacity, lifetime and lifecycle ride the type
@group("squad", capacity=4, lifetime="2h",
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
class Squad: ...

# the event a squad can carry — declared once, surfaced as send.* / on.*
@event("rally_call")
class RallyCall:
    position: Vector3

# an instance of a declared type — a runtime act, so a call
squad = await playserv.groups.squad.create("squad-7")
await squad.add(friend_id)

# the group is an address
squad.send.rally_call(position)                   # declared event → generated method
members = await squad.get_members()               # declared data → typed, subscribable

async for answer in squad.invoke.ready_check():   # N calls, one per member
    hud.mark(answer.member, answer.ready)         # each answer names who sent it

game = playserv.group("game")                     # addressing sugar for one group
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.

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSGroup = (Name = "veterans", Capacity = 500, Rule = "player.stats.matches >= 100"))
struct FVeterans { GENERATED_BODY() };

USTRUCT(PSGroup = (Name = "squad", Capacity = 4, Lifetime = "2h",
                  Create = "Ahead", Close = "OnLastExit"))
struct FSquad { GENERATED_BODY() };

USTRUCT(PSEvent = (Name = "rally_call"))
struct FRallyCall { GENERATED_BODY() UPROPERTY() FVector Position; };

// an instance of a declared type — a runtime act, so a call
Client->Groups->Of<FSquad>()->Create(FPSIdempotencyKey(TEXT("squad-7")),
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSGroup* Squad = Result.Value();
        Squad->Members->Admit(FriendId);

        // the group is an address
        Squad->Publish->RallyCall({ Position });               // declared event → generated member
        Squad->Members->Select().Then(
            TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
            {
                if (!Members.HasValue()) { return; }
                Roster->Show(Members.Value());
            }));

        Squad->Call->ReadyCheck(TPSOnResult<FReadyAnswer>::CreateWeakLambda(this,
            [this](const TPSResult<FReadyAnswer>& Answer)
            {
                if (!Answer.HasValue()) { return; }
                Hud->Mark(Answer.Value().Member, Answer.Value().Ready);   // fires once per member
            }));
    }));

// addressing sugar for one well-known group
Client->Groups->Get(PSKeys::Groups::Game,
    TPSOnResult<FPSGroup*>::CreateWeakLambda(this, [this](const TPSResult<FPSGroup*>& GameResult)
    {
        if (!GameResult.HasValue()) { return; }
        Announce(GameResult.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the same C# declarations push from the Unity project; the client creates, addresses and subscribes
[Group("veterans", Capacity = 500)]
[GroupRule("player.stats.matches >= 100")]
public static class Veterans { }

[Group("squad", Capacity = 4, Lifetime = "2h",
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
public static class Squad { }

[Event("rally_call")]
public record RallyCall(Vector3 Position);

var squad = await PlayServ.Groups.Squad.Create("squad-7");
await squad.Add(friendId);

squad.Send.RallyCall(position);                   // declared event → generated method
var members = await squad.GetMembers();           // declared data → typed, subscribable

await foreach (var answer in squad.Invoke.ReadyCheck())   // N calls, one per member
    Hud.Mark(answer.Member, answer.Ready);                // each answer names who sent it

var game = PlayServ.Group("game");                // addressing sugar for one group

El chat de una sala es esta primitiva con semántica de mensajes encima: la sala declara su propio tipo de grupo, lo monta en el espacio de nombres de la sala y deja que la participación de la propia sala decida quién está dentro. Así, la lista del chat y la de la sala nunca pueden diferir.

El chat de una sala es un tipo de grupo declarado: la sala controla la entrada y el chat, la entrega
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }
@Group('room-chat', { in: Rooms.namespace, capacity: 64,
                      create: GroupCreate.Ahead, close: GroupClose.OnLastExit })
@EntryRule('actor in room.members')
export class RoomChat {}
@group("room-chat", ns=rooms.namespace, capacity=64,
       create=GroupCreate.AHEAD, close=GroupClose.ON_LAST_EXIT)
@entry_rule("actor in room.members")
class RoomChat: ...
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(PSGroup = (Name = "room-chat", In = "rooms", Capacity = 64,
                  Create = "Ahead", Close = "OnLastExit"),
        PSEntryRule = "actor in room.members")
struct FRoomChat { GENERATED_BODY() };
Unity C# is the same C# API here — funciona tal cual en Unity con los tipos generados
[Group("room-chat", In = Rooms.Namespace, Capacity = 64,
       Create = GroupCreate.Ahead, Close = GroupClose.OnLastExit)]
[EntryRule("actor in room.members")]        // the room decides who is in
public static class RoomChat { }

Nada de lo que hace a un chat está en la primitiva. De aquí salen la audiencia y la interfaz send.*; el autor, el hilo y el historial vienen de Messaging, y quién puede administrar la participación es un derecho aparte (Access & Roles) que tiene la sala.

El modelo

Qué declara un tipo de grupo.

DeclaraQué es
nameel nombre propio del tipo
modo de participaciónexplicit: un miembro se agrega y se quita con una acción; o dynamic: la participación se deriva de una regla, y es miembro quien cumpla el predicado. Un grupo es uno de los dos, nunca ambos
ruleen un grupo dinámico: el predicado, en el mismo lenguaje de predicados que los predicados de acceso y las condiciones de transición. Lo recalcula la plataforma; nadie hace polling
capacityy el comportamiento al alcanzarla
regla de entradaun predicado que puede rechazar la entrada, aparte de un hook que también puede rechazarla
comportamiento del ciclo de vidaen la primera entrada: created on first entry o created in advance; y en la última salida: closed on last exit o kept while empty. Se declara, nunca se deduce observando
lifetimeopcional: cuando vence, el grupo se cierra con un evento

Lo que vale para cualquier grupo.

SiempreQué es
miembroun actor, nunca una entidad: un conjunto de entidades es una selección sobre Data & Subscriptions. Un grupo es un solo oyente masivo
estadoscreated → active → closed, y closed es final. Una instancia de grupo tiene máquina de estados; el tipo no la declara
destino de eventosemites sobre el grupo y lo reciben sus miembros; por eso la entrega masiva es una sola señal y no un bucle
una llamada a un gruposon N llamadas, no una, porque un RPC tiene exactamente un handler lógico. El grupo aporta el direccionamiento, y cada resultado llega vinculado al miembro del que vino
destinatariosel emisor nunca los enumera: decide la participación, así que quien envía no tiene que conocer la composición de la audiencia
roles dentro del grupono existen: un “dueño del grupo” es un actor que tiene un derecho (Access & Roles), no un rango guardado en la lista de miembros
primera entrada y última salidase distinguen de las entradas y salidas intermedias, y de eso dependen el comportamiento del ciclo de vida declarado y la inicialización de la ronda
recálculolleva el delta: el evento de composición de un grupo declarado por regla dice quién entró y quién salió, nunca la lista completa. La lista completa es una lectura, así que un suscriptor que solo quiere el cambio nunca paga por la lista de participantes
entrar y salirson idempotentes: un cliente que se reconecta repite su entrada, obtiene la misma participación y ningún error; el código del cliente nunca tiene que distinguir “ya estoy dentro” de “no me dejan entrar”
la interfazes la de un grupo concreto, no solo la del tipo: te diriges a este escuadrón
la primitivaqueda vacía: las reglas de entrada, la inicialización de la ronda con el primer miembro y la intercepción de eventos pertenecen a los módulos construidos sobre ella, como las salas, la mensajería y el emparejamiento

Una llamada a un grupo puede fallar en parte

Un miembro que falló, agotó el tiempo de espera o se negó es una respuesta propia, con su Problem, junto a los miembros que sí respondieron. Maneja cada respuesta; un éxito parcial nunca se devuelve como uno completo.

Errores

  • Una regla que dice que no y un hook que dice que no son respuestas distintas. Una regla de entrada falsa se lee como “entrada imposible”; el rechazo de un hook lleva el motivo y el código del propio hook. Si no se puede llegar a un hook de entrada, la entrada se rechaza: la verificación falla cerrada en lugar de dejar pasar al actor.
  • La participación dinámica no admite cambios a mano. Add o Remove sobre un grupo declarado por regla es un rechazo de validación: el predicado es lo único que mueve esa lista, y la plataforma la recalcula cuando cambian los datos que hay detrás.
  • Cuatro derechos, ninguno implica otro: entrar, administrar la participación, publicar en el grupo y leer la composición (Access & Roles). Un actor al que le falta uno recibe un rechazo, nunca algo que se ignora en silencio; un grupo que un predicado de visibilidad le oculta responde not found, y un miembro siempre ve su propia participación aunque la composición esté cerrada para él.

Límites

  • Lleno es un conflicto, no una cuestión de derechos. Con capacidad + 1, la entrada se rechaza como conflicto, y la misma llamada funciona en cuanto se libera un lugar (seat). La capacidad en sí está en el tipo (Capacity = 4 más arriba); cuántos grupos pueden tener un proyecto y un solo actor se fija con el capítulo sobre los límites de la plataforma.
  • Una llamada a un grupo demasiado grande se rechaza entera, antes de enviar nada: una difusión nunca se entrega a medias, así que nadie que llame tiene que detectar ese caso. El tope de tamaño llegará con el capítulo sobre los límites de la plataforma.