Цю сторінку ще не перекладено — показано англійську версію.
Groups
Група — це іменований набір акторів, який отримує повідомлення як одне ціле. Ви звертаєтеся до групи, і чує кожен учасник. Кімната, чат, пул підбору гравців і список розсилання — це той самий примітив над тим самим списком, лише з різними правилами входу й виходу та різним часом життя.
Коли це потрібно
- Потрібні паті, загони чи гільдії: іменовані набори гравців з оголошеною місткістю і, якщо тип це оголошує, часом життя.
- Участь (membership) має визначати оголошене правило, яке обчислює платформа: нові ветерани потрапляють у групу самі, без cron-задачі чи власного виклику перерахунку.
- Треба звернутися до багатьох гравців одразу: оголошена подія розходиться всім через
send.*, оголошений RPC доходить до кожного учасника (member), і кожна відповідь повертається з іменем того, хто її дав. - Потрібна одна модель участі, яку можна знову використати як аудиторію: зона видимості, розмова в обміні повідомленнями, паті для підбору гравців.
- Не створюйте групу (group), якщо набір — це учасники однієї сесії. Rooms — це той самий примітив із правилами кімнати, і до цих учасників він уже звертається.
Хто що робить
| Актор | Що робить на цій сторінці |
|---|---|
player | створює групи оголошених типів, входить і виходить, додає й видаляє учасників, надсилає події, викликає RPC на всю групу; якщо має право адмініструвати участь у групі — видаляє учасників і закриває її |
room-owner | правила місць у кімнаті працюють поверх цього примітива (їх налаштовують у Rooms) |
backend-service | оголошує типи груп і їхні правила; пише хуки на вхід і вихід |
Коротко
send.* і відповідь від кожного учасника// 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Як о голошувати через атрибути в Go, поки не вирішено (O-1). Приклади з’являться, коли оберуть спосіб запису.
// 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Чат кімнати — це той самий примітив, над яким додано семантику повідомлень. Кімната оголошує власний тип групи, монтує його у своєму просторі імен і доручає вирішувати, хто в чаті, власній участі кімнати. Тож список чату і список кімнати ніколи не розійдуться.
[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: ...Як оголошувати через атрибути в Go, поки не вирішено (O-1). Приклади з’являться, коли оберуть спосіб запису.
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 { }Сам примітив нічого не знає про чат. Звідси беруться аудиторія та send.*; автор, гілка й історія — з Messaging, а хто може адмініструвати участь, визначає окреме право (Access & Roles), яке має кімната.
Модель
Що оголошує тип групи.
| Оголошує | Що це |
|---|---|
name | власна назва типу |
режим участі | explicit — учасника додають і видаляють окремою дією; або dynamic — участь виводять із правила, і учасником є кожен, хто задовольняє предикат. Група буває лише одного з двох видів, ніколи обох одразу |
rule | для динамічної групи — предикат тією самою мовою, що й предикати доступу та умови переходу (guard). Перераховує його платформа, опитувати нікому не треба |
capacity | місткість і поведінка, коли її досягнуто |
правило входу | предикат, який може відхилити вхід, — окремо від хука, який теж може його відхилити |
поведінка життєвого циклу | під час першого входу — created on first entry або created in advance; під час останнього виходу — closed on last exit або kept while empty. Її оголошують, а не виводять зі спостережень |
lifetime | необов’язковий; коли він спливає, група закривається з подією |
Що справджується для будь-якої групи.
| Завжди | Що це |
|---|---|
учасник | актор, а не сутність (entity): набір сутностей — це вибірка в Data & Subscriptions. Група — один колективний слухач |
стани | created → active → closed; closed — кінцевий. Машина станів є в екземпляра групи, тип її не оголошує |
ціль події | надішліть подію (event) групі, і її отримають учасники; тому масова доставка — це один сигнал, а не цикл |
груповий виклик | це N викликів, а не один, бо в RPC рівно один логічний обробник. Група дає адреси, а кожен результат приходить прив’язаним до учасника, від якого він надійшов |
одержувачі | відправник їх не перелічує: їх визначає участь, тож відправникові не треба знати склад аудиторії |
ролі всередині групи | їх немає. «Власник групи» — це актор, що має право (Access & Roles), а не звання, записане в списку учасників |
перший вхід і останній вихід | їх можна відрізнити від проміжних входів і виходів — на цьому тримаються оголошена пове дінка життєвого циклу й ініціалізація раунду |
перерахунок | передає дельту: подія про склад групи за правилом каже, хто ввійшов і хто випав, а не весь список. Увесь список — це окреме читання, тож підписник, якому потрібна лише зміна, ніколи не платить за склад (roster) |
вхід і вихід | ідемпотентні. Клієнт, що перепідключився, повторює вхід, отримує ту саму участь і жодної помилки — клієнтському кодові не треба відрізняти «я вже в групі» від «мене не пускають» |
інтерфейс | належить конкретній групі, а не лише типові: ви звертаєтеся саме до цього загону |
примітив | лишається порожнім. Правила входу, ініціалізація раунду на першому учасникові й перехоплення подій належать модулям, побудованим на ньому: кімнатам, обміну повідомленнями і підбору гравців |
Груповий виклик може частково не вдатися
Якщо учасник зазнав збою, не встиг за таймаутом чи відмовив, це окрема відповідь зі своїм Problem поруч із відповідями інших учасників. Обробляйте кожну відповідь: частковий успіх ніколи не повертається як повний.
Помилки
- Відмова правила і відмова хука — різні відповіді. Хибне правило входу означає «вхід неможливий», а відмова хука несе власні причину й код хука. Якщо до хука входу не вдається достукатися, вхід відхиляють: недоступна перевірка блокує актора, а не пропускає його.
- Динамічну участь не можна правити вручну.
AddчиRemoveдля групи за правилом — це відмова валідації: цей список змінює лише предикат, а платформа перераховує його, коли змінюються дані, на яких він ґрунтується. - Чотири права, і жодне не випливає з іншого: входити, адмініструвати участь, надсилати в групу, читати склад (Access & Roles). Актор без потрібного права отримує відмову, а не мовчазне «нічого не сталося». Група, яку предикат видимості від нього приховує, натомість відповідає not found, а свою участь учасник бачить завжди, навіть якщо склад для нього закритий.
Обмеження
- Заповнена група — це конфлікт, а не питання прав. Вхід понад місткість відхиляють як конфлікт, а той самий виклик пройде, щойно звільниться місце. Саму місткість задає тип (
Capacity = 4вище); скільки груп може мати проєкт і окремий актор, визначить розділ про обмеження платформи. - Завеликий груповий виклик відхиляють цілком, ще до надсилання. Розсилання ніколи не доходить наполовину, тож тому, хто викликає, не треба відстежувати такий випадок. Межу розміру визначить розділ про обмеження платформи.