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é
| Actor | Qué hace en esta página |
|---|---|
player | crea 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-owner | las reglas de lugares de la sala funcionan sobre esta primitiva (se configuran en Rooms) |
backend-service | declara los tipos de grupo y sus reglas; hooks de entrada y salida |
En resumen
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 groupTodaví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 groupEl 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.
[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: ...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() };[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.
| Declara | Qué es |
|---|---|
name | el nombre propio del tipo |
modo de participación | explicit: 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 |
rule | en 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 |
capacity | y el comportamiento al alcanzarla |
regla de entrada | un predicado que puede rechazar la entrada, aparte de un hook que también puede rechazarla |
comportamiento del ciclo de vida | en 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 |
lifetime | opcional: cuando vence, el grupo se cierra con un evento |
Lo que vale para cualquier grupo.
| Siempre | Qué es |
|---|---|
miembro | un actor, nunca una entidad: un conjunto de entidades es una selección sobre Data & Subscriptions. Un grupo es un solo oyente masivo |
estados | created → active → closed, y closed es final. Una instancia de grupo tiene máquina de estados; el tipo no la declara |
destino de eventos | emites 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 grupo | son 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 |
destinatarios | el 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 grupo | no 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 salida | se 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álculo | lleva 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 salir | son 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 interfaz | es la de un grupo concreto, no solo la del tipo: te diriges a este escuadrón |
la primitiva | queda 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.
AddoRemovesobre 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 = 4má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.