Pular para o conteúdo principal
Blocos de construção

Esta página ainda não foi traduzida: mostrando a versão em inglês.

Groups

Um grupo é um conjunto nomeado de atores que recebe como se fosse um só. Você endereça o grupo, e todos os membros ouvem. Uma sala, um chat, um pool de matchmaking e uma lista de envio são a mesma primitiva, com regras de entrada e saída e tempos de vida diferentes, sobre a mesma lista.

Quando usar

  • Você precisa de parties, esquadrões ou guildas: conjuntos nomeados de jogadores com capacidade declarada e, quando o tipo declara, um tempo de vida.
  • A participação deve seguir uma regra declarada que a plataforma avalia: novos veteranos entram sem um cron job nem uma chamada de reavaliação feita por você.
  • Você quer falar com muitos jogadores de uma vez: um evento declarado é distribuído com send.*, e um RPC declarado chega a todos os membros, com cada resposta voltando identificada.
  • Você precisa de um único modelo de participação reaproveitado como público: um escopo de visibilidade, uma conversa de mensagens, uma party de matchmaking.
  • Não é preciso criar um quando o conjunto são os membros de uma sessão. Rooms é esta primitiva com as regras de sala (room) e já endereça esses membros.

Quem faz o quê

AtorO que faz nesta página
playercria grupos a partir de tipos declarados, entra e sai, adiciona ou remove membros, envia eventos, invoca RPCs distribuídos; com o direito de administrar a participação num grupo, remove membros e encerra o grupo
room-owneras regras de vaga (seat) da sala se apoiam nesta primitiva (configuradas em Rooms)
backend-servicedeclara os tipos de grupo e as regras deles; hooks na entrada e na saída

Resumo

Um grupo declarado por regra, um esquadrão com capacidade e tempo de vida declarados, distribuição com send.* e uma resposta por membro
// 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
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.

// 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

O chat de uma sala é esta primitiva com a semântica de mensagens por cima: a sala declara o próprio tipo de grupo, monta esse grupo no namespace da sala e deixa a participação da própria sala decidir quem está dentro — assim a lista do chat e a lista da sala nunca divergem.

O chat de uma sala é um tipo de grupo declarado — a sala cuida da entrada, o chat cuida da 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: ...
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(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 — roda sem alterações no Unity com os tipos gerados
[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 do que é chat está na primitiva. O público e a interface send.* vêm daqui; autor, thread e histórico vêm de Messaging, e quem pode administrar a participação é um direito à parte (Access & Roles), que fica com a sala.

O modelo

O que um tipo de grupo declara.

DeclaraO que é
nameo nome do próprio tipo
modo de participaçãoexplicit — um membro é adicionado e removido por uma ação; ou dynamic — a participação é derivada de uma regra, e quem satisfaz o predicado é membro. Um grupo é um dos dois, nunca ambos
rulenum grupo dinâmico: o predicado, na mesma linguagem de predicados das permissões de acesso e das condições de transição. Quem recalcula é a plataforma; ninguém faz polling
capacitye o comportamento ao atingi-la
regra de entradaum predicado que pode rejeitar a entrada, separado de um hook que também pode rejeitá-la
comportamento do ciclo de vidana primeira entrada — created on first entry ou created in advance; e na última saída — closed on last exit ou kept while empty. Declarado, nunca deduzido a partir da observação
lifetimeopcional: quando expira, o grupo é encerrado com um evento

O que vale para todo grupo.

SempreO que é
membroum ator, nunca uma entidade: um conjunto de entidades é uma seleção sobre Data & Subscriptions. Um grupo é um único ouvinte coletivo
estadoscreated → active → closed, e closed é terminal. Uma instância de grupo tem uma máquina de estados; o tipo não a declara
destino de eventosemita nele, e os membros recebem; é isso que faz da entrega em massa um único sinal, e não um loop
uma chamada a um gruposão N chamadas, não uma, porque um RPC tem exatamente um handler lógico. O grupo fornece o endereçamento, e cada resultado chega vinculado ao membro de onde veio
destinatáriosnunca são listados por quem envia: a participação decide, então quem envia não precisa conhecer a composição do público
papéis dentro do gruponão existem: um “dono do grupo” é um ator que tem um direito (Access & Roles), não uma posição guardada na lista de membros
primeira entrada e última saídase distinguem das entradas e saídas no meio do caminho — e é disso que dependem o comportamento do ciclo de vida declarado e a inicialização da rodada
recálculoleva o delta: o evento de composição de um grupo declarado por regra diz quem entrou e quem saiu, nunca a lista inteira. A lista inteira é uma leitura, então quem assina e só quer a mudança nunca paga pela lista de participantes
entrar e sairsão idempotentes: um cliente que reconecta repete a entrada, recebe a mesma participação e nenhum erro — o código do cliente nunca precisa distinguir “já estou dentro” de “não tenho permissão para entrar”
a interfaceé a de um grupo concreto, não só a do tipo: você endereça este esquadrão
a primitivafica vazia: regras de entrada, inicialização da rodada no primeiro membro e interceptação de eventos ficam com os módulos construídos sobre ela, como salas, mensagens e matchmaking

Uma chamada a um grupo pode falhar em parte

Um membro que falhou, estourou o timeout ou recusou é uma resposta própria, com o seu Problem, ao lado dos membros que responderam. Trate cada resposta; um sucesso parcial nunca é devolvido como sucesso total.

Erros

  • Uma regra que diz não e um hook que diz não são respostas diferentes. Uma regra de entrada falsa significa “entrada impossível”; uma rejeição de hook traz o motivo e o código do próprio hook. Um hook de entrada que não pode ser alcançado recusa a entrada — a verificação bloqueia em caso de falha, em vez de deixar o ator passar.
  • A participação dinâmica não aceita edição manual. Add ou Remove num grupo declarado por regra é uma recusa de validação: o predicado é a única coisa que altera essa lista, e a plataforma o recalcula quando os dados por trás dele mudam.
  • Quatro direitos, nenhum implica outro — entrar, administrar a participação, publicar no grupo, ler a composição (Access & Roles). Um ator sem um deles recebe uma recusa, nunca uma operação que silenciosamente não faz nada; um grupo que um predicado de visibilidade esconde dele responde not found, e um membro sempre vê a própria participação, mesmo quando a composição está fechada para ele.

Limites

  • Lotado é um conflito, não uma questão de direito. Com capacidade + 1, a entrada é rejeitada como conflito, e a mesma chamada funciona assim que uma vaga se libera. A capacidade em si fica no tipo (Capacity = 4 acima); quantos grupos um projeto e um único ator podem ter é definido no capítulo de limites da plataforma.
  • Uma chamada a um grupo grande demais é recusada por inteiro, antes de qualquer envio — uma distribuição nunca é entregue pela metade, então ninguém que chama precisa detectar esse caso. O teto de tamanho chega com o capítulo de limites da plataforma.