跳到主要内容
基础构件

本页尚未翻译,当前显示英文版本。

Groups

**群组是一个有名字的主体集合,作为一个整体接收消息。**你面向群组发送,每个成员都能收到。房间、聊天、匹配池和发送列表都是同一个原语,只是进出规则和生命周期不同,底下是同一份名单。

何时使用

  • 你需要小队、战队或公会:有名字的玩家集合,带有声明的容量;类型声明了生命周期的,还带有生命周期。
  • 成员资格(membership)应当遵循由平台求值的声明规则:新晋老兵自动加入,不需要你自己写定时任务或调用重新求值。
  • 你想一次面向很多玩家:声明的事件通过 send.* 扇出,声明的 RPC 送达每个成员,每个答复回来时都带着成员名。
  • 你需要把同一套成员资格模型复用为受众:一个可见性范围、一段消息会话、一个匹配小队。
  • 无需自己创建:如果这个集合就是一个会话的成员。Rooms 就是加上了房间规则的这个原语,已经能面向这些成员发送。

分工

主体在本页中的职责
player按声明的类型创建群组(group),加入和离开,添加或移除成员,发送事件,调用扇出 RPC;持有群组成员资格管理权限时,可以移除成员并关闭群组
room-owner房间的席位规则建立在这个原语之上(在 Rooms 中配置)
backend-service声明群组类型及其规则;在进入和退出时挂 hook

概览

由规则声明的群组、声明了容量和生命周期的小队、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容量,以及达到容量时的行为
进入规则可以拒绝进入的谓词,与同样可以拒绝进入的 hook 相互独立
生命周期行为首次进入时——created on first entry 或 created in advance;最后退出时——closed on last exit 或 kept while empty。由声明给出,从不根据观察推断
lifetime可选:到期后群组关闭,并发出一个事件

所有群组都满足的几点。

始终成立含义
成员是主体(actor),绝不是实体(entity):实体的集合是 Data & Subscriptions 上的选择集。群组是一个整体接收者
状态created → active → closed,其中 closed 是终态。状态机属于群组实例,类型并不声明它
事件目标向群组发送,成员就会收到;正因如此,批量投递是一个信号,而不是一个循环
群组调用是 N 次调用而不是一次,因为一个 RPC 恰好只有一个逻辑处理器。群组提供寻址,每个结果到达时都绑定着它来自的成员
接收者从不由发送方列举:由成员资格决定,所以发送方无需知道受众的构成
群组内角色不存在:“群主”是持有某项权限的主体(Access & Roles),而不是存放在成员名单里的等级
首次进入和最后退出可以与中间的加入和离开区分开——声明的生命周期行为和回合初始化都依赖这一点
重新计算只携带 delta:由规则声明的群组,其构成事件只说明谁进来了、谁掉出去了,从不给出整份名单。整份名单需要单独读取,所以只关心变化的订阅方永远不必为名单(roster)付出代价
加入和离开都是幂等的:重连的客户端重复加入,得到相同的成员资格,不会报错——客户端代码永远不必区分“我已经在里面了”和“我不允许进入”
接口属于具体的群组,而不只属于类型:你面向的是这一个小队
原语本身保持为空:进入规则、首个成员到来时的回合初始化和事件拦截,都属于建立在它之上的模块,比如房间、消息和匹配

群组调用可能部分失败

失败、超时或拒绝的成员也各自构成一个答复,带着自己的 Problem,与正常答复的成员并列返回。逐个处理每个答复;部分成功永远不会被当作完全成功返回。

错误

  • 规则说不和 hook 说不,是两种不同的答复。进入规则为假,结果是“无法进入”;hook 拒绝则带有 hook 自己的原因和错误码。无法连通的进入 hook 会拒绝进入——检查是失败即拦截(fail-closed),而不是直接放主体进去。
  • 动态成员资格不接受手动修改。对由规则声明的群组调用 Add 或 Remove 会得到校验拒绝:只有谓词能改变这份名单,背后的数据变化时,平台会重新计算。
  • 四项权限,彼此互不蕴含——进入、管理成员资格、向群组发布、读取构成(Access & Roles)。缺少其中一项的主体会收到拒绝,而不是悄无声息地什么都不做;被可见性谓词隐藏的群组则返回 “not found”。即使构成对某个成员不可见,它也总能看到自己的成员资格。

限制

  • 满员是冲突,与权限无关。达到容量 + 1 时,进入会作为冲突被拒绝;一旦有席位(seat)空出,同样的调用就会成功。容量本身写在类型上(上文的 Capacity = 4);一个项目和单个主体最多能持有多少群组,会随平台限制一章给出。
  • 超出规模的群组调用会被整体拒绝,在发送任何内容之前——扇出永远不会只送达一半,所以调用方无需检测这种情况。规模上限会随平台限制一章给出。