Files
template/docs/advanced/replay-consistency.md
T
AncTe 68e1f6f72c docs(10-20): 高级 I 改为库文档风格(示例+注释)
- 删除七篇中的六步实操结构,改为叙述 + 带注释示例
- 移除 [模板] / [需改] 代码标记,验证段改为叙述
- 记录用户第 1 轮修订意见与构建结论
2026-10-20 17:33:49 +08:00

17 KiB
Raw Blame History

录像一致性

录像验证的用法是:把玩家的操作录下来,之后只跑数据端、按顺序重放,看结果与原始游玩是否一致。要让这件事成立,前提是该记的行为一条都不能少。少记一条,重放时那一步就没有发生,之后每一步都建立在错误的状态上,而第一次出现差异的位置,往往离真正的原因很远。

中级教程的录像指令讲了录像是什么、主动与被动的区别、以及怎么新增一条指令。这一篇往下一层:@shouldReplay 与 @ignoreReplay 这两个装饰器该写在什么方法上,它们能替你查出什么、查不出什么。

阅读前需要会基础语法:class、new、import,以及装饰器写法 @名字(...)。装饰器的基础写法见代码基础。

录像一致性要解决什么

同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。

核心原则在中级已经给过,这里再抄一遍,因为后面所有判断都从它出发:

:::info 核心原则 玩家直接产生、会影响最终成绩的行为,必须计入录像。 :::

两个条件缺一不可:由玩家亲手做出(移动、使用道具、装备、触发事件),并且会改变游戏结果。代码自己跑出来的、只影响画面的,都不算。

问题在于:这条原则好背,难在执行。一个方法到底该不该记,作者常常是「先跑一遍,看回放对不对」——这正是录像一致性检查想替你省掉的试错。

两个装饰器各做什么

两个装饰器都写在类的方法上方,作用对象是「录像安全检查」,不是录像本身。

装饰器 写在哪 做什么 会不会记录录像
@shouldReplay(message) 会改变游戏状态的方法上 收集区间开启时,把「这个方法被调用过」登记成一条路径 不会
@ignoreReplay(reason) 确实不该计入录像的被动 / 延迟路径 把整段检查标记为忽略 不会

@shouldReplay:登记一条路径

@shouldReplay(message) 把方法标记为「应该计入录像」。它的实际行为比名字朴素:

  • 收集区间没开启时,它什么也不做,方法与普通方法完全一样;
  • 收集区间开启时,它把这次调用登记成一条路径,名字形如 方法名: message,然后照常执行方法;
  • 它不写录像。真正记录仍要靠 state.replaySystem.record(code, ...params),或者走内置的录制入口。

内置的录制入口就在会改状态的方法里,例如勇士道具的 useItem:

const replay = this.state.replaySystem;
replay.array.add(ReplayCode.UseItem, [raw.num]);

也就是说,@shouldReplay 声明的是「这段路径执行了,就该有对应的录像步」,而录像步本身要另外写。

@ignoreReplay:忽略整段检查,不是忽略一个方法

@ignoreReplay(reason) 容易被误解成「只忽略这个方法」。实际不是:它只是把一个全局标记置为 true,而这个标记在整段收集区间内都不会被清掉。于是只要区间内有一次调用走到带 @ignoreReplay 的方法,整段区间的核对就会被跳过——包括同一区间里其它正常登记的 @shouldReplay 路径。

这带来两个直接后果:

  • 它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过;
  • 它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。

reason 参数是写给人看的说明,目前不会出现在告警输出里。写它的目的是让后来的人知道这里为什么可以忽略,而不是让它生效。

该写在哪

判断标准只有一条:这个方法是不是「玩家动作能走到、并且真的会改变游戏状态」。是,就加 @shouldReplay;不是,就不加。

内置代码里已经有大量先例,可以照着找感觉:

位置 方法
Flag FlagSystem.setField / deleteField、FlagCommonField.set / add
勇士道具 HeroItems.addItem / useItem、HeroEquipsStore.add / delete
勇士装备 HeroEquipment.equip / unequip
勇士属性 HeroAttribute 的 set / add / mul / div,以及修饰器的注册与移除
勇士位置 HeroLocation.setFloor / setPos
地图图层 MapLayer.setBlock / removeBlock / openDoor / 动态图块相关方法

不要加在这三类地方:

  • 只读查询:getField、getBlock、getItemState、getCategory 这类方法不改变状态,不可能产生录像步;
  • 纯计算 / 试算:最终属性、临界值、伤害试算、比较器,这些都只是「算一遍看看」,不是游戏状态变化;
  • 会被反复调用的展示或搜索路径:临界搜索、寻路试算、排序比较会被调用很多次,登记它们只会让告警列表变长,把真正的漏记埋进噪声里。

@ignoreReplay 的落点更窄:只用于确实不该计入录像的异步 / 被动路径,并且要写清理由。它是「这块我确认过,不该记」,不是「这块我不想看」。

能保证什么,不能保证什么

这是本篇最需要记住的一节。检查能做的事很有限,不要把它当成正确性证明。

能保证的:

  • 收集区间内如果一条新录像步都没有产生,控制台会告警,并列出这段区间里登记过的 @shouldReplay 路径;
  • 告警里给出的路径能帮你定位「哪条调用链走了,但没有留下录像步」;
  • 路径按调用嵌套组织,越靠里的路径越接近真正该记录的位置。

不能保证的:

它不检查 说明
逻辑是否正确 方法算错了,只要区间里有新录像步,检查照样通过
参数是否正确 录像步的参数写错了、写少了,检查不看内容
顺序是否正确 它只比较录像长度,不比较先后
是否一一对应 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步
是否漏了入口 没有标记的方法不会被登记,自然也不会出现在告警里
是否会自动记录 @shouldReplay 只登记路径,记录仍要 record 或内置录制入口

最后两条是实践中最容易踩的:没标记的方法,检查器根本不知道它存在;而标了标记的方法,也不会因此自动写录像。

检测机制与边界

检查由两个函数控制,它们在收集区间的两端各做一半工作。

beginReplaySafetyCollection(system) 开始收集:

  • 记下当前录像长度 beforeLength,作为「区间内有没有新增」的基准;
  • 清空上一次登记的路径,把当前收集对象设为根;
  • 如果已经在收集中,告警(错误码 159)并直接返回。

endReplaySafetyCollection() 结束收集并核对,按顺序判断三件事,任一成立就提前返回:

  1. 区间内出现过 @ignoreReplay(整段忽略);
  2. 录像长度比 beforeLength 大(区间内确实记录了新录像步);
  3. 没有任何登记路径(没有可报告的内容)。

三条都不成立时,才会输出告警(错误码 161),列出登记过的路径。告警里同时会给出一条可直接粘进控制台的命令:

Mota.require('@user/data-common').logReplaySafetyDetail(0);

把 0 换成告警里给出的编号,就能打印出完整的嵌套路径树。明细只保留最近的一批(源码注释写的是 50 个),更早的编号再查询会告警(错误码 162)。

边界一:收集区间由谁开启

这是当前必须知道的事实:beginReplaySafetyCollection 与 endReplaySafetyCollection 在仓库里目前只有测试在调用(packages-user/data-common/src/replay/__test__/func.test.ts),logReplaySafetyDetail 也一样。

所以本篇描述的是「收集区间开启时」的检测口径:区间开着,两个装饰器才有意义;区间没开,@shouldReplay 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「验证」一节里的方式手动开一次区间。生产环境目前也没有自动开启收集区间的开关;如果要在实际游玩中检查,只能在自定义的调试流程或录像入口里,自己把一段操作包在 beginReplaySafetyCollection / endReplaySafetyCollection 之间。

边界二:读档期间录像被禁用

CoreState.loadState 在恢复每个可存档对象之前会调用 replaySystem.disable(),恢复之后调用 revert(),所以读档本身不会被记进录像。读档开始时还会先清掉残留的禁用标记,避免上一次的 disable 层数漏回去。

但执行器不在包裹范围内:bindSaveableExecuter 绑定的 afterLoad 在该对象的 loadState 之后立即运行,此时录像已经恢复可用,因为它可能确实要做需要记录的行为。这意味着在执行器里操作会影响成绩的状态要格外小心。细节见可存档内容。

边界三:装饰器只认类的方法

两个装饰器都按「类方法装饰器」实现,只能写在 class 的方法上方。写在普通函数、对象属性、箭头函数上都不会生效。

给一个内置方法补录像标记

假设要让 FlagSystem.getOrInsert 这个会改变状态的入口在收集区间里被登记出来。它位于 packages-user/data-base/src/flag/system.ts;同一个文件里的 setField 与 deleteField 都已经有标记,getOrInsert 还没有——它在字段不存在时会新建一条 Flag 字段,同样会改变状态,正好用来观察「加标记 / 去掉标记」的差别。

标记就是方法声明上方的一行装饰器。system.ts 顶部已经有 import { shouldReplay } from '@user/data-common';,不需要再引入:

// 加在 FlagSystem.getOrInsert 的上一行;消息写清这个方法改变了什么状态
@shouldReplay('Flag field inserted should be replayed.')

若你换到别的文件、编辑器报缺少 shouldReplay,再在顶部补这一行 import。消息文本只写给人看,语言不影响行为,写清意思即可。

手动开一次收集区间,就能看到这条路径有没有被登记。这几个函数不在 state 上,需要从 @user/data-common 引入(只用于调试,平时不需要):

import {
    beginReplaySafetyCollection,
    endReplaySafetyCollection
} from '@user/data-common';

beginReplaySafetyCollection(state.replaySystem);
// 调用一个会改状态、但不会产生录像步的方法
state.flags.getOrInsert('replay-check', 1);
endReplaySafetyCollection();

区间内没有产生任何新录像步,getOrInsert 又带着标记,所以控制台应出现告警(错误码 161),列表里能看到 getOrInsert: ... 这一条。把 getOrInsert 上的标记注释掉再跑一次,告警会消失——这不是修好了,而是检查器看不见这个入口了,这也正是「漏标记比漏记录更难查」的原因。两个过程都不会产生录像步:标记只登记路径,真正的记录仍要 record 或内置录制入口。

标记只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。做这个练习时不要选一个内部已经调用了带标记方法的方法——那样即使去掉外层标记,里层仍会登记路径,告警不会消失。

改内置还是自己写

绝大多数情况不需要新写类:

  • 给一个会改状态的入口补标记——直接在已有实现文件里改,例如 packages-user/data-base/src/flag/、hero/、map/ 下对应的文件;
  • 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类。这已经超出「标记」的范围,属于自定义录像行为(高级 II · 自定义录像行为)。

补一行装饰器的成本极低,对照原代码就能看出差异。需要一整套新系统时,优先用插件组织,把录像入口收敛在插件自己的方法上,见插件系统。录像格式与沙盒的深层原理留到 高级 III · 录像深层原理。

常见错误

  • 给只读方法或计算器加 @shouldReplay。它们不产生录像步,只会让告警列表变长,把真正的漏记埋掉。
  • 用 @ignoreReplay 压告警。它忽略的是整段区间,真正的漏记会一起被放过。
  • 以为加了装饰器就会自动记录。装饰器只登记路径,录像步仍要 record 或走内置录制入口。
  • 同一个方法写两个标记。@shouldReplay 与 @ignoreReplay 同时写含义互相矛盾;一个方法只需要一个标记,装饰器要紧贴方法声明。
  • 在异步回调里记录却没处理顺序。录像步会落在错误的时点,检查也看不出来。
  • 在读档过程中记录。loadState 被 disable 包裹,记录不会生效;执行器不在这层保护里,反而要更谨慎。
  • 把装饰器写在普通函数上。它只支持类的方法。
  • 以为没告警就是没问题。收集区间没开、或者入口压根没标记,都不会有告警。

验证

开一次收集区间,就能看到登记路径:beginReplaySafetyCollection → 调用一个带标记的方法 → endReplaySafetyCollection。控制台应给出告警(161)和路径列表;粘上告警里给出的 logReplaySafetyDetail(n) 命令,应打印出完整嵌套路径。

随后把同一个方法上的标记注释掉再跑一次。告警消失说明检查器「看不见」这个入口,而不是问题解决了——这也是为什么标记必须加在正确的层。

再让区间内产生一条录像步:在区间内调用一个真正会记录录像的方法(例如走一步、使用一个道具),再结束收集。此时不应告警,因为检查只关心「有没有新录像步」,不关心是哪一条。

参数与顺序需要自己核对。装饰器不会替你检查这些:录一段包含目标操作的录像,回放后对比关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。

最后确认读档不产生录像:存档、读档,然后检查录像长度有没有变化。loadState 期间的记录被禁用,长度应当不变;若有变化,检查是不是在绑定的执行器里做了会记录的操作。

小结

  • 录像一致性只要求一件事:玩家直接产生、会影响最终成绩的行为,一条都不能少记。
  • @shouldReplay 在收集区间内登记路径,@ignoreReplay 把整段区间标记为忽略;两者都不写录像。
  • 标记写在会改状态的玩家动作入口上,不写在只读、试算、展示与搜索路径上。
  • 检查能发现「区间内一条录像步都没有」,但不管逻辑、参数、顺序与一一对应。
  • 收集区间目前只有测试在开启;需要时手动 beginReplaySafetyCollection / endReplaySafetyCollection。
  • 读档期间录像被临时禁用,执行器不在保护范围内。
  • 需要新的录像入口时再扩展;一整套新系统优先用插件组织。

返回高级 I 导览。