Перейти к основному содержимому
Строительные блоки

Эта страница ещё не переведена — показана английская версия.

Groups

Группа — именованный набор акторов, который получает сообщения как единое целое. Вы обращаетесь к группе, и слышит каждый участник. Комната, чат, пул подбора игроков и список рассылки — один и тот же примитив над тем же списком, только с разными правилами входа и выхода и разным временем жизни.

Когда это нужно

  • Нужны пати, отряды или гильдии — именованные наборы игроков (player) с объявленной вместимостью и, если тип её объявляет, временем жизни.
  • Состав должен определяться объявленным правилом, которое вычисляет платформа. Новые ветераны попадают в группу сами, без cron-задачи и без вашего вызова пересчёта.
  • Нужно обратиться сразу ко многим игрокам. Объявленное событие рассылается через send.*, объявленный RPC доходит до каждого участника, и каждый ответ возвращается с именем отправителя.
  • Одну модель участия (membership) нужно переиспользовать как аудиторию: область видимости, беседу в сообщениях, пати для подбора игроков.
  • Не создавайте группу (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для динамической группы — предикат на том же языке, что предикаты доступа и условия переходов. Пересчитывает его платформа, опрашивать никому не нужно
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), а не ранг в списке участников
первый вход и последний выходих можно отличить от входов и выходов между ними. На этом и держатся объявленное поведение жизненного цикла и инициализация раунда
пересчётпередаёт дельту: событие о составе группы по правилу сообщает, кто вошёл и кто выбыл, а не весь список. Весь список получают отдельным чтением, поэтому подписчик, которому нужно только изменение, не платит за полный состав
вход и выходидемпотентны. Переподключившийся клиент повторяет вход и без ошибки остаётся участником, так что клиентскому коду не нужно отличать «я уже внутри» от «меня не пускают»
интерфейспринадлежит конкретной группе, а не только типу: вы обращаетесь к этому отряду
примитивостаётся пустым. Правила входа, инициализация раунда на первом участнике и перехват событий принадлежат модулям, построенным поверх него: Rooms, Messaging и Matchmaking

Групповой вызов может завершиться частично

Участник, у которого произошёл сбой, истёк таймаут или который отказал, даёт свой собственный ответ со своим Problem — рядом с ответами тех, кто ответил успешно. Обрабатывайте каждый ответ: частичный успех никогда не выдаётся за полный.

Ошибки

  • Отказ правила и отказ хука — разные ответы. Если правило входа ложно, ответ будет «вход невозможен», а отказ хука несёт собственную причину и код хука. Если до хука на вход не удалось достучаться, вход отклоняется: при сбое проверка закрывает дверь, а не пропускает актора.
  • Динамический состав нельзя править вручную. Add или Remove для группы по правилу получают отказ при валидации. Этот список меняет только предикат, а платформа пересчитывает его, когда меняются данные, на которых он основан.
  • Четыре права, и ни одно не следует из другого: входить, администрировать состав, отправлять в группу и читать состав (Access & Roles). Актор без нужного права получает отказ, а не вызов, который молча ничего не делает. Если группа скрыта от актора предикатом видимости, ответ будет not found. А своё участие в группе актор видит всегда, даже когда состав группы ему закрыт.

Ограничения

  • Заполненная группа — это конфликт, а не вопрос прав. Вход сверх вместимости отклоняется как конфликт, а тот же вызов пройдёт, как только освободится место (seat). Сама вместимость задаётся в типе (Capacity = 4 в примере выше), а сколько групп может быть у проекта и у одного актора, определит глава об ограничениях платформы.
  • Слишком большой групповой вызов отклоняется целиком, ещё до отправки. Рассылка никогда не доставляется наполовину, и вызывающему не нужно отслеживать такой случай. Предельный размер появится вместе с главой об ограничениях платформы.