本页尚未翻译,当前显示英文版本。
Extensibility
**每个平台场景都是一条由注册函数组成的链:替换其中一环,或把它包起来。**要改变平台的行为,你替换它的步骤,而不是 fork 它的源码。
何时使用
- 某个平台步骤必须运行你的逻辑:用
[Override(…)]为指定的一环声明替换实现。 - 你需要在某个步骤前后加检查或副作用:有序的
Before/After中间件(middleware),可以否决或发出通知。 - 代码需要按计划、在事件发生时或由 webhook 触发运行:触发器(trigger)交给你一个已解析的、类型化的上下文。
- 部署前你必须知道实际会运行什么 :对链做一次试运行,读出解析后的顺序。
- 无需使用:如果规则只涉及单个实体(entity)的写入。Data & Subscriptions 的 hook 是更轻量的方式。
分工
| 主体 | 在本页中的职责 |
|---|---|
backend-service | 覆盖(override)链中的环节,用中间件包裹步骤,编写触发器处理器 |
operator | 查看链,设定顺序,读取密钥,试运行解析 |
概览
SignIn、用中间件包裹 grant、按 cron 运行代码// gate one named step of the auth scenario — a before hook may refuse, fail-closed
[Before(Auth.SignIn)]
public static Task<Verdict> GateRegion(SignInAttempt a) =>
a.Region == "sanctioned"
? Hook.Reject(Problem.Forbidden, "region not served")
: Hook.Continue(a);
// wrap a step with ordered middleware
PlayServ.Extend.Scenario("commerce.purchase")
.Before("grant", LogPurchaseIntent)
.After("grant", NotifySquad, order: 10);
// customer code on a trigger
[OnSchedule("0 4 * * *")]
public static async Task NightlyCleanup() { ... }// gate one named step of the auth scenario — a before hook may refuse, fail-closed
export const gateRegion = before(Auth.signIn, (a: SignInAttempt) =>
a.region === 'sanctioned'
? Hook.reject(Problem.forbidden, 'region not served')
: Hook.continue(a));
// wrap a step with ordered middleware
playserv.extend.scenario('commerce.purchase')
.before('grant', logPurchaseIntent)
.after('grant', notifySquad, { order: 10 });
// customer code on a trigger
export const nightlyCleanup = onSchedule('0 4 * * *', async () => { /* ... */ });# gate one named step of the auth scenario — a before hook may refuse, fail-closed
@before(auth.sign_in)
async def gate_region(a):
if a.region == "sanctioned":
return hook.reject(problem.FORBIDDEN, "region not served")
return hook.cont(a)
# wrap a step with ordered middleware
playserv.extend.scenario("commerce.purchase") \
.before("grant", log_purchase_intent) \
.after("grant", notify_squad, order=10)
# customer code on a trigger
@on_schedule("0 4 * * *")
async def nightly_cleanup(): ...Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
覆盖、中间件和触发器都作为云函数在平台上执行,而不 是在引擎里。用 C#、TypeScript 或 Python 编写——Unreal 订阅它们产生的事件。在引擎中编写函数的能力已在计划中:Blueprint 图表,以及 C++(翻译为经平台校验的脚本目标)。两者都会像其他声明一样经过分析并推送,执行仍在平台上。
覆盖、中间件和触发器都作为云函数在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写——Unity 订阅它们产生的事件。
模型
平台提供了哪些挂接点。
| 术语 | 含义 |
|---|---|
注册函数 | 一个可覆盖的平台步骤——“创建玩家资料”“解析价格” |
scenario | 平台流程运行的有序链:认证、加入、购买 |
overridability | 某一环可以替换、只能包裹,还是固定不变 |
middleware | 围绕某一环、按顺序执行的前置/后置处理器 |
trigger | 启动你代码的东西:事件、计划任务、webhook |
secret | 你的处理器可以读取的值 |
invocation | 一次运行,连同它的追踪记录 |
hook 声明哪些内容。
| 声明项 | 含义 |
|---|---|
position | 它挂接的具名步骤 |
kind | gatekeeper——准入检查或校验,失败即拦截(fail-closed):hook 自身出故障时,步骤不会运行;或 observer——日志、通知、计数器,失败即放行(fail-open):步骤照常运行,失败仍会上报而不会被吞掉。没有默认值 |
moment | before——在校验之前,接收类型化的 payload,可以修改或拒绝;或 after——在步骤提交之后,接收请求和结果,只能产生副作用,永远不能让操作失败或改变响应 |
effect | hook 做什么,而不只是它挂在哪里。这样顺序就能按工作内容而不是数字来表述,别人在你的 hook 旁边再加一个时也不会被打乱 |
版本与条件 | 只适用于某个环境或某类受众的处理器,是该 hook 的一个声明版本,而不是函数体里的一个分支;面板上的开关切换的正是它 |
挂接代码的三种 方式。
| 方式 | 适用场景 |
|---|---|
[Before(Step)] / [After(Step)] 特性 | 在一个具名步骤上加一条规则——大多数 hook 都是这样 |
[Override(Link)] 特性 | 彻底替换某一环的实现 |
Extend.Scenario("…").Before("link", fn, order: n) | 在链内部包裹某一环,适用于与其他中间件的先后顺序很重要的情况 |
| 始终成立 | 含义 |
|---|---|
三种方式 | 都用 playserv push 部署 |
两种特性方式 | 会在面板中显示,因为声明把步骤名或环节名带进了推送的模型 |
中间件方式 | 携带的是顺序,这正是链所需要的 |
启动时赋值 | (Scenario.OnX = fn)仍然可用,适合不需要出现在管理树中的处理器 |
替换一环 | 两侧的环节不受影响,它们也都不知道做出响应的是哪个实现——平台自己的步骤还是你的 |
调用去向何处,以及所有路由都满足的几点。
| 始终成立 | 含义 |
|---|---|
四个方向 | 云函数(cloud function)· 使用方自己的外部 backend · 游戏服务器 · 另一个已声明的方向 |
路由器 | 由消息/信号驱动;request-response 只是接在它上面的一种适配器,而不是它的本质 |
匹配 | 只按操作或信号的声明名称进行,不看其他任何东西:不看 payload 的形状,不看调用方,也不看负载 |
方向 | 不属于操作契约的一部分:把处理器从一个方向移到另一个方向,不算破坏性变更 |
“游戏服务器” | 由它是什么来定义,而不是由谁托管——我们的服务器集群和工作室自己的托管是同一个方向,声明中也没有任何标记说明基础设施归谁所有 |
游戏服务器 RPC | 注册在同一个路由器中:声明它就等于注册它,没有第二种方式 |
顺序 | 中间件从上到下运行;一个步骤有多个实现时,路由器按条件从左到右挑选,都不匹配时由标记为默认的版本响应 |
带有覆盖和中间件的链,可以在任何东西运行之前解析并读出。解析后的顺序可以在面板中查看,也可以通过代码查看。
hook 之间的约束是什么,何时检查。
| 含义 | |
|---|---|
具名约束 | 扩展点可以列出它所约束的效果——反作弊检查必须先于分配,收据要求此处已经扣款——除此之外不约束任何东西 |
未列出的效果 | 不受约束,而不是被拒绝 |
违反约束 | 属于配置缺陷,拒绝信息会写明两个 hook 以及它们违反的约束——不是警告,也不会悄悄调整顺序 |
检查时机 | 每次可能改变某个点上运行内容的操作都会检查:注册、部署、调整编排。所以能走到执行阶段的编排都已经通过了准入 |
运行时不再检查 | 已准入的编排在执行时不会再次检查 |
| 始终成立 | 含义 |
|---|---|
处理器 | 输入和输出都有类型:没有 dynamic,没有上下文大杂烩。平台句柄隐式可用,调用上下文——调用方、触发器、追踪——送达时已经解析好 |
引擎 build 看到什么 | 场景之后发出的事件,因为覆盖和中间件都在平台上运行,引擎运行时不是托管它们的地方。本页示例中的引擎标签页展示的就是这一点 |
[Rpc("resolve_price", Default = true)]
public static Price ResolvePrice(Sku sku) => Pricing.Base(sku);
[Rpc("resolve_price", When = "env == 'staging'")]
public static Price ResolvePriceStaging(Sku sku) => Pricing.WithDiscount(sku, 0.5f);
// a hook can carry a version too, gated by its own condition
[After("grant", When = "audience == 'beta'")]
public static void NotifySquadBeta(GrantResult r) => Messaging.PingBeta(r.Squad);export const resolvePrice = rpc('resolve_price', { default: true },
(sku: Sku) => Pricing.base(sku));
export const resolvePriceStaging = rpc('resolve_price', { when: "env == 'staging'" },
(sku: Sku) => Pricing.withDiscount(sku, 0.5));
// a hook can carry a version too, gated by its own condition
export const notifySquadBeta = after('grant', { when: "audience == 'beta'" },
(r: GrantResult) => Messaging.pingBeta(r.squad));@rpc("resolve_price", default=True)
def resolve_price(sku: Sku) -> Price:
return pricing.base(sku)
@rpc("resolve_price", when="env == 'staging'")
def resolve_price_staging(sku: Sku) -> Price:
return pricing.with_discount(sku, 0.5)
# a hook can carry a version too, gated by its own condition
@after("grant", when="audience == 'beta'")
def notify_squad_beta(r: GrantResult):
messaging.ping_beta(r.squad)Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
覆盖、中间件和触发器都作为云函数在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写——Unreal 订阅它们产生的事件。在引擎中编写函数的能力已在计划中:Blueprint 图表,以及 C++(翻译为经平台校验的脚本目标)。两者都会像其他声明一样经过分析并推送,执行仍在平台上。
覆盖、中间件和触发器都作为云函数在平台上执行,而不是在引擎里。用 C#、TypeScript 或 Python 编写——Unity 订阅它们产生的事件。
**平台自己的逻辑也可以拉取、编辑并重新部署。**它和你的代码一样,都是函数:
playserv functions pull matchmaking.match # the platform's implementation, as source
# edit: widen the skill window for the weekend event
playserv push # your version registers; the default stays as fallback
你在这里定制的是逻辑:也就是本页讲的覆盖、中间件和版本。
不能给平台实体添加字段
玩家、房间、排行榜记录和订单都是系统状态,有各自的状态机,不是你数据模型的起点。你的数据是你自己的实体,在 Schema 即代码中声明,并通过谓词与平台状态关联:拥有者是这个玩家,作用范围是这个房间。这样即使平台自己的模型变了,数据也仍然归你。
错误
- 名称匹配不到任何已注册的处理器时,返回
not found——调用永远不会因为没人监听而被悄悄丢弃。 - 重复注册的名称,以及有两个实现声明同一条件的集合,会在集合声明时被拒绝:在部署时,而不是在调用时。
- hook 的拒绝带有 hook 自己的错误码和原因,所以“游戏规则说不”永远不会看起来像传输故障。
- hook 失败时,按它声明的种类行事——
fail-open(失败即放行)或fail-closed(失败即拦截)——具体是哪一种由声明决定,而不是根据发生的情况推断。 - hook 不能改变已经确定的内容:不能改拥有者,不能改目标,也不能改调用所指向的排行榜或会话。它只修正输入并返回裁决。
限制
具体数值会随平台限制一章给出;达到每个上限时会发生什么,现在已经确定。
- hook 的执行时限:超时后按其种类声明的失败行为处理。
- 同一位置上的 hook 数量和同一方法的实现数量:超出后再注册会被拒绝。
- “hook 调用了带 hook 的操作”的嵌套深度:返回声明好的拒绝,绝不会耗尽资源。
- 传给 hook 的上下文大小:不允许截断,所以直接拒绝注册,而不是让处理器拿到半个上下文。
课程与示例:Tanks 中的排行榜使用了本模块的 hook;每日锦标赛完全基于本模块实现。