Перейти до основного вмісту
Будівельні блоки

Цю сторінку ще не перекладено — показано англійську версію.

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

Як оголошувати через атрибути в 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

Як оголошувати через атрибути в 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() };
Unity C# is the same C# API here — працює в Unity без змін, зі згенерованими типами
[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 вище); скільки груп може мати проєкт і окремий актор, визначить розділ про обмеження платформи.
  • Завеликий груповий виклик відхиляють цілком, ще до надсилання. Розсилання ніколи не доходить наполовину, тож тому, хто викликає, не треба відстежувати такий випадок. Межу розміру визначить розділ про обмеження платформи.