Aller au contenu principal
Briques de base

Cette page n’est pas encore traduite : la version anglaise est affichée.

Groups

Un groupe est un ensemble nommé d’acteurs qui reçoit comme un seul. Vous vous adressez au groupe, et tous ses membres entendent. Une salle, un chat, un pool de matchmaking et une liste de diffusion sont la même primitive sur la même liste, avec des règles d’entrée et de sortie et des durées de vie différentes.

Quand l’utiliser

  • Il vous faut des groupes de joueurs, des escouades ou des guildes : des ensembles nommés de joueurs, avec une capacité déclarée et, quand le type en déclare une, une durée de vie.
  • L’appartenance doit suivre une règle déclarée que la plateforme évalue : les nouveaux vétérans y entrent d’eux-mêmes, sans tâche cron ni appel de réévaluation de votre part.
  • Vous voulez vous adresser à de nombreux joueurs à la fois : un événement déclaré est diffusé avec send.*, un RPC déclaré atteint chaque membre et chaque réponse revient nominative.
  • Vous voulez réutiliser un même modèle d’appartenance comme audience : une portée de visibilité, une conversation de messagerie, un groupe de matchmaking.
  • Inutile d’en créer un si l’ensemble correspond aux membres d’une session. Rooms, c’est cette primitive avec les règles d’une salle (room), et elle s’adresse déjà à eux.

Qui fait quoi

ActeurCe qu’il fait sur cette page
playercrée des groupes à partir de types déclarés, entre et sort, ajoute ou retire des membres, envoie des événements, invoque des RPC diffusés ; s’il détient le droit d’administrer l’appartenance d’un groupe, retire des membres et ferme le groupe
room-ownerles règles de places (seat) de la salle reposent sur cette primitive (configurées dans Rooms)
backend-servicedéclare les types de groupe et leurs règles ; hooks à l’entrée et à la sortie

En bref

Un groupe déclaré par règle, une escouade avec une capacité et une durée de vie déclarées, la diffusion avec send.* et une réponse par membre
// 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
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

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

Le chat d’une salle, c’est cette primitive avec une sémantique de messages par-dessus : la salle déclare son propre type de groupe, le monte dans son espace de noms et laisse sa propre appartenance décider qui en fait partie — la liste du chat et celle de la salle ne peuvent donc jamais diverger.

Le chat d’une salle est un type de groupe déclaré — la salle gère l’entrée, le chat gère la livraison
[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: ...
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

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 — fonctionne tel quel dans Unity avec les types générés
[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 { }

Rien de ce qui fait un chat ne se trouve dans la primitive. L’audience et l’interface send.* viennent d’ici ; l’auteur, le fil et l’historique viennent de Messaging, et le droit d’administrer l’appartenance est un droit à part entière (Access & Roles), détenu par la salle.

Le modèle

Ce que déclare un type de groupe.

DéclareCe que c’est
namele nom propre du type
mode d'appartenanceexplicit — un membre est ajouté et retiré par une action ; ou dynamic — l’appartenance est déduite d’une règle, et quiconque satisfait le prédicat est membre. Un groupe est l’un des deux, jamais les deux
rulepour un groupe dynamique : le prédicat, dans le même langage de prédicats que les prédicats d’accès et les conditions de transition (guard). C’est la plateforme qui le recalcule ; personne n’interroge en boucle
capacityet le comportement quand elle est atteinte
règle d'entréeun prédicat qui peut refuser l’entrée, indépendamment d’un hook qui peut aussi la refuser
comportement du cycle de vieà la première entrée — created on first entry ou created in advance ; à la dernière sortie — closed on last exit ou kept while empty. Déclaré, jamais déduit de l’observation
lifetimefacultative : à son expiration, le groupe se ferme avec un événement

Ce qui vaut pour tout groupe.

ToujoursCe que c’est
membreun acteur, jamais une entité : un ensemble d’entités est une sélection dans Data & Subscriptions. Un groupe est un seul destinataire collectif
étatscreated → active → closed, et closed est terminal. Une instance de groupe a une machine à états ; le type ne la déclare pas
cible d'événementsvous émettez dessus et ses membres reçoivent ; c’est ce qui fait d’une livraison de masse un seul signal plutôt qu’une boucle
un appel de groupereprésente N appels, pas un seul, car un RPC a exactement un handler logique. Le groupe fournit l’adressage, et chaque résultat arrive rattaché au membre dont il vient
destinatairesjamais énumérés par l’émetteur : c’est l’appartenance qui décide, et l’émetteur n’a pas à connaître la composition de l’audience
rôles internes au groupen’existent pas : un « propriétaire de groupe » est un acteur qui détient un droit (Access & Roles), pas un rang stocké dans la liste des membres
première entrée et dernière sortiese distinguent des entrées et sorties intermédiaires — c’est sur elles que reposent le comportement de cycle de vie déclaré et l’initialisation d’une manche
recalcultransporte le delta : l’événement de composition d’un groupe déclaré par règle indique qui est entré et qui est sorti, jamais la liste entière. La liste entière est une lecture : un abonné qui ne veut que le changement ne paie jamais pour la composition complète
entrer et sortirsont idempotents : un client qui se reconnecte répète son entrée, obtient la même appartenance et aucune erreur — le code client n’a jamais à distinguer « j’y suis déjà » de « je n’ai pas le droit d’entrer »
l'interfaceest celle d’un groupe concret, pas seulement celle du type : vous vous adressez à cette escouade
la primitivereste vide : les règles d’entrée, l’initialisation d’une manche au premier membre et l’interception des événements appartiennent aux modules construits dessus, comme les salles, la messagerie et le matchmaking

Un appel de groupe peut échouer en partie

Un membre qui a échoué, expiré ou refusé constitue une réponse à part entière, avec son Problem, à côté des membres qui ont répondu. Traitez chaque réponse ; un succès partiel n’est jamais renvoyé comme un succès complet.

Erreurs

  • Une règle qui dit non et un hook qui dit non sont deux réponses différentes. Une règle d’entrée fausse signifie « entrée impossible » ; un rejet par hook porte le motif et le code propres au hook. Un hook d’entrée injoignable refuse l’entrée — la vérification bloque en cas d’échec au lieu de laisser passer l’acteur.
  • L’appartenance dynamique refuse les modifications manuelles. Add ou Remove sur un groupe déclaré par règle est un refus de validation : seul le prédicat fait évoluer cette liste, et la plateforme le recalcule quand les données sous-jacentes changent.
  • Quatre droits, aucun n’impliquant l’autre — entrer, administrer l’appartenance, publier dans le groupe, lire la composition (Access & Roles). Un acteur à qui il en manque un reçoit un refus, jamais une opération silencieusement sans effet ; un groupe qu’un prédicat de visibilité lui cache répond not found à la place, et un membre voit toujours sa propre appartenance, même quand la composition lui est fermée.

Limites

  • Plein, c’est un conflit, pas une question de droits. À capacité + 1, l’entrée est rejetée comme conflit, et le même appel réussit dès qu’une place se libère. La capacité elle-même figure sur le type (Capacity = 4 plus haut) ; le nombre de groupes que peuvent détenir un projet et un acteur sera fixé avec le chapitre sur les limites de la plateforme.
  • Un appel de groupe trop grand est refusé en bloc, avant tout envoi — une diffusion n’est jamais livrée à moitié, et aucun appelant n’a à détecter ce cas. Le plafond de taille arrivera avec le chapitre sur les limites de la plateforme.