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
| Acteur | Ce qu’il fait sur cette page |
|---|---|
player | cré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-owner | les règles de places (seat) de la salle reposent sur cette primitive (configurées dans Rooms) |
backend-service | déclare les types de groupe et leurs règles ; hooks à l’entrée et à la sortie |
En bref
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 groupLa 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 groupLe 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.
[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: ...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() };[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éclare | Ce que c’est |
|---|---|
name | le nom propre du type |
mode d'appartenance | explicit — 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 |
rule | pour 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 |
capacity | et le comportement quand elle est atteinte |
règle d'entrée | un 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 |
lifetime | facultative : à son expiration, le groupe se ferme avec un événement |
Ce qui vaut pour tout groupe.
| Toujours | Ce que c’est |
|---|---|
membre | un acteur, jamais une entité : un ensemble d’entités est une sélection dans Data & Subscriptions. Un groupe est un seul destinataire collectif |
états | created → active → closed, et closed est terminal. Une instance de groupe a une machine à états ; le type ne la déclare pas |
cible d'événements | vous é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 groupe | repré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 |
destinataires | jamais é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 groupe | n’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 sortie | se 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 |
recalcul | transporte 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 sortir | sont 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'interface | est celle d’un groupe concret, pas seulement celle du type : vous vous adressez à cette escouade |
la primitive | reste 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.
AddouRemovesur 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 = 4plus 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.