跳到主要内容
基础构件

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

Events

**事件就是“某件事发生了”这一事实,会送达每个应当知道它的人。**只发生一次、无法从当前值补看的事情,就用事件:一次射击、一笔购买、一次进入房间。

何时使用

  • 某件事发生了,其他人必须做出反应:开了一枪、锁了一扇门、一局比赛结束。
  • 受众不固定。同一次发送可以送达一个小队、一个房间(room)或一个主体(actor),取决于类型声明的目标。
  • 你想要带自动补全的类型化处理器。声明过的事件(event)会在所属接口上变成 send.* 和 on.*,各自有自己的契约。
  • 一小时后仍要能读到这个事实:把类型声明为保留(retention),再按时间段读回。

分工

主体在本页中的职责
schema-author用 [Event] 声明事件,推送 schema
任意主体通过 send.* 发送,通过 on.* 订阅

概览

RallyCall 只声明一次;用 send.* 发送,用 on.* 响应
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public record RallyCall(Vector3 Position);

// emitting: the declaration generated the method — and its contract
squad.Send.RallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.On.RallyCall(call => ShowRallyMarker(call.Position));
@Event('rally_call', { clock: Clock.SimTime, retention: Retention.Transient })
export class RallyCall { constructor(public position: Vector3) {} }

// emitting: the declaration generated the method — and its contract
squad.send.rallyCall(position);

// subscribing: typed handler, autocompleted beside every other declared event
squad.on.rallyCall((call) => showRallyMarker(call.position));
@event("rally_call", clock=Clock.SIM_TIME, retention=Retention.TRANSIENT)
class RallyCall:
    position: Vector3

# emitting: the declaration generated the method — and its contract
squad.send.rally_call(position)

# subscribing: typed handler, autocompleted beside every other declared event
squad.on.rally_call(lambda call: show_rally_marker(call.position))
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// declarations compile into the same pushed model — playserv push from the UE project or CI
USTRUCT(PSEvent = (Name = "rally_call", Clock = "SimTime", Retention = "Transient"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector Position;
};

// emitting and subscribing — generated, typed
Squad->Publish->RallyCall({ Position });
TPSSubscription RallyMarkers = Squad->Subscribe->RallyCall(
    [this](const FRallyCall& Call) { ShowRallyMarker(Call.Position); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// the calls are the C# ones; the payload is not — Unity's floor is C# 9 and the generated
// source may carry no records, so a declared payload is a plain serializable type
[Event("rally_call", Clock = Clock.SimTime, Retention = Retention.Transient)]
public sealed class RallyCall
{
    public Vector3 Position;      // converts to and from UnityEngine.Vector3
}

squad.Send.RallyCall(new RallyCall { Position = position });
squad.On.RallyCall(call => ShowRallyMarker(call.Position.ToUnity()));

在模块或群组(group)内部声明的事件只在那里出现:squad.send.rallyCall 之所以存在,是因为 rally_call 是为小队声明的,发送也只送达小队成员。模块向外发送的事件属于它声明的契约,调用方永远看不到未声明的事件。

模型

事件类型声明哪些内容。

声明项含义
name显式的传输名称,由声明给出,而不是从符号推导
payload每次发送所携带内容的 schema
target发送的去向:实体(entity)实例、群组、房间或全局上下文。以群组为目标时,一个信号送达多个接收者。会变化的收件方要表达为群组,而不是在发送时传入地址
clocksim_time 或 timestamp,二者只选其一。sim_time 用于模拟内部的事实,这些事实参与预测、延迟补偿和 rollback;timestamp 用于模拟之外的事实,比如购买或登录
retentiontransient 或 retained,见下文
term用于 retained 类型:保留多久、到期后怎样。不存在“永久”
delivery至多一次、至少一次或恰好一次,声明在类型上,订阅方无需再问。“恰好一次”会写明它在什么范围内成立
context全局或局部。全局声明的名称在局部上下文中可见,局部声明的名称在上层不可见

每次发送携带什么。

字段含义
type声明的事件。发送永远不会合并:两次射击就是两个事件。这正是事件与 Data & Subscriptions 中 [Sync] 字段的区别
payload符合该类型的 schema
source发送方主体;由实体发送时还包括该实体实例
stamp按类型声明的时钟记录
去重键每种投递模式下都有
原因键平台因另一个事件而发送的事件带有此项,值为那个事件的 id,这样可以按键重建因果链,而不必比较时间戳

客户端发来的事件只是一种声称

在任何逻辑依赖客户端发送的事件之前,权威的一方都要先检查它。不要把 source 当作证明。

处理重复投递

每种投递模式都可能重复投递:传输层的重复包,或对保留事件的再次读取。按 去重键 去重。

订阅包含什么。

包含项含义
event订阅绑定的声明类型
surface订阅所在的节点,位于类型声明的 target 之内
handler按 payload 类型化的处理器
position重连后从哪里继续,免得悄悄从“现在”重新开始。断开期间错过的事件不会重放:瞬时事件就此丢失,只有 retained 事件可以读回

所有事件都满足的几点。

始终成立含义
受众从不由发送方列出:它就是声明的 target,先收窄到已订阅的人,再经过 Access & Roles 把关。即使类型可见,某个流(stream)也可能被谓词关闭
阶段先发送,后投递。事件没有状态机:它只发生一次
顺序只在一个流内保证,一个流就是一个发送实例:同一实例的两个事件按发送顺序到达
缺口检测投递模式允许丢失时,订阅方会得知出现了缺口,而不是悄悄跳过

跨流没有顺序保证

两个实例之间不保证顺序,同一变更的 delta 和事件之间也不保证。需要跨流有序时,把消息放进同一个流,或在 payload 里携带因果时间戳。

保留是类型的一部分

事件投递后是否保留,声明在类型上,而不是在发送时选择,因此同一类型的保留方式始终相同。保留的事件按类型和时间段读回,不走 Data & Subscriptions 的查询接口。

瞬时保留
送达当时已订阅的人同左,外加之后才订阅的人
之后消失在声明的期限内保留
可读回否是,在期限内
超过期限—选择集直接拒绝,而不是返回空结果
保留事件:声明时带上期限,按时间段读回
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public record ObjectiveTaken(string Objective, PlayerId By);

// a member who joined late reads what it missed — by type and period, nothing wider
var taken = await squad.Retained.ObjectiveTaken(since: matchStart);
@Event('objective_taken', { clock: Clock.SimTime, retention: Retention.Retained, keep: '7d' })
export class ObjectiveTaken { constructor(public objective: string, public by: PlayerId) {} }

// a member who joined late reads what it missed — by type and period, nothing wider
const taken = await squad.retained.objectiveTaken({ since: matchStart });
@event("objective_taken", clock=Clock.SIM_TIME, retention=Retention.RETAINED, keep="7d")
class ObjectiveTaken:
    objective: str
    by: PlayerId

# a member who joined late reads what it missed — by type and period, nothing wider
taken = await squad.retained.objective_taken(since=match_start)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

USTRUCT(PSEvent = (Name = "objective_taken", Clock = "SimTime", Retention = "Retained", Keep = "7d"))
struct FObjectiveTaken
{
    GENERATED_BODY()

    UPROPERTY() FString     Objective;
    UPROPERTY() FPSPlayerId By;
};

// a member who joined late reads what it missed — by type and period, nothing wider
Squad->Retained->ObjectiveTaken->Select(FPSTimeWindow{ .From = MatchStart })
    .Then(TPSOnResult<TArray<FObjectiveTaken>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FObjectiveTaken>>& Result)
    {
        if (!Result.HasValue()) { return; }
        for (const FObjectiveTaken& Taken : Result.Value()) { Timeline->Add(Taken); }
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
// same attribute, same read — the payload is a plain serializable type on the C# 9 floor
[Event("objective_taken", Clock = Clock.SimTime, Retention = Retention.Retained, Keep = "7d")]
public sealed class ObjectiveTaken
{
    public string   Objective;
    public PlayerId By;
}

var taken = await squad.Retained.ObjectiveTaken(since: matchStart);

错误

  • 发布和订阅是两项独立的权限,拥有其中一项不代表拥有另一项。没有权限的订阅返回 forbidden,而不是 not-found:该类型本来就在模块声明的契约里,没什么可隐藏的。
  • 订阅模块未声明的类型属于契约错误,返回类型化的 Problem,而不是悄无声息地什么都不做。
  • 发送会被拒绝,如果类型未声明、payload 不符合 schema,或目标不在类型允许的范围内。
  • 超过保留类型期限的选择集会直接拒绝,而不是返回空页。

限制

具体数值会随平台限制一章给出;达到每个上限时会发生什么,现在已经确定。

  • payload 大小:超过上限时发布失败,事件不会发生。payload 永远不会被截断。
  • 每个来源的发布频率:返回限流拒绝,并带上可以重试的时间点。
  • 每个主体的订阅数:拒绝新订阅,保留已有订阅。
  • 每种类型的保留量:按声明的策略和期限淘汰。