From 65801ea74b77657e8e8cfd6e359e12a4a79215e5 Mon Sep 17 00:00:00 2001
From: unanmed <1319491857@qq.com>
Date: Tue, 20 Oct 2026 14:26:51 +0800
Subject: [PATCH] =?UTF-8?q?docs(10-16):=20=E7=BC=96=E5=86=99=E5=BD=95?=
=?UTF-8?q?=E5=83=8F=E4=B8=80=E8=87=B4=E6=80=A7=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 讲清 @shouldReplay / @ignoreReplay 的落点:状态变更入口 vs 延迟被动路径
- 说明 @ignoreReplay 作用于整段收集区间,不是单个方法
- 说明能保证什么、不能保证什么,以及检测机制与边界
- 给出「模板:给方法加录像标记」与六步实操「给一个内置方法补录像标记」
---
docs/advanced/replay-consistency.md | 264 ++++++++++++++++++++++++++++
1 file changed, 264 insertions(+)
create mode 100644 docs/advanced/replay-consistency.md
diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md
new file mode 100644
index 0000000..1f142c6
--- /dev/null
+++ b/docs/advanced/replay-consistency.md
@@ -0,0 +1,264 @@
+# 录像一致性
+
+录像验证的用法是:把玩家的操作录下来,之后只跑数据端、按顺序重放,看结果与原始游玩是否一致。要让这件事成立,前提是该记的行为一条都不能少。少记一条,重放时那一步就没有发生,之后每一步都建立在错误的状态上,而第一次出现差异的位置,往往离真正的原因很远。
+
+中级教程的[录像指令](../intermediate/replay-commands.md)讲了录像是什么、主动与被动的区别、以及怎么新增一条指令。这一篇往下一层:`@shouldReplay` 与 `@ignoreReplay` 这两个装饰器该写在什么方法上,它们能替你查出什么、查不出什么。
+
+阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。
+
+## 录像一致性要解决什么
+
+同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。
+
+核心原则在中级已经给过,这里再抄一遍,因为后面所有判断都从它出发:
+
+:::info 核心原则
+玩家直接产生、会影响最终成绩的行为,必须计入录像。
+:::
+
+两个条件缺一不可:由玩家亲手做出(移动、使用道具、装备、触发事件),并且会改变游戏结果。代码自己跑出来的、只影响画面的,都不算。
+
+问题在于:这条原则好背,难在执行。一个方法到底该不该记,作者常常是「先跑一遍,看回放对不对」——这正是录像一致性检查想替你省掉的试错。
+
+## 两个装饰器各做什么
+
+两个装饰器都写在类的方法上方,作用对象是「录像安全检查」,不是录像本身。
+
+| 装饰器 | 写在哪 | 做什么 | 会不会记录录像 |
+| ------------------ | ---------------------------------- | -------------------------------------------------------- | -------------- |
+| `@shouldReplay()` | 会改变游戏状态的方法上 | 收集区间开启时,把「这个方法被调用过」登记成一条路径 | 不会 |
+| `@ignoreReplay()` | 确实不该计入录像的被动 / 延迟路径 | 把整段检查标记为忽略 | 不会 |
+
+### @shouldReplay:登记一条路径
+
+`@shouldReplay(message)` 把方法标记为「应该计入录像」。它的实际行为比名字朴素:
+
+- 收集区间没开启时,它什么也不做,方法与普通方法完全一样;
+- 收集区间开启时,它把这次调用登记成一条路径,名字形如 `方法名: message`,然后照常执行方法;
+- 它不写录像。真正记录仍要靠 `state.replaySystem.record(code, ...params)`,或者走内置的录制入口。
+
+内置的录制入口就在会改状态的方法里,例如[勇士道具](../intermediate/hero-items.md)的 `useItem`:
+
+```ts
+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`),列出登记过的路径。告警里同时会给出一条可直接粘进控制台的命令:
+
+```ts
+Mota.require('@user/data-common').logReplaySafetyDetail(0);
+```
+
+把 `0` 换成告警里给出的编号,就能打印出完整的嵌套路径树。明细只保留最近的一批(源码注释写的是 50 个),更早的编号再查询会告警(错误码 `162`)。
+
+### 边界一:收集区间由谁开启
+
+这是当前必须知道的事实:`beginReplaySafetyCollection` 与 `endReplaySafetyCollection` 在仓库里目前只有测试在调用(`packages-user/data-common/src/replay/__test__/func.test.ts`),`logReplaySafetyDetail` 也一样。
+
+所以本篇描述的是「收集区间开启时」的检测口径:区间开着,两个装饰器才有意义;区间没开,`@shouldReplay` 只是原样调用方法。不要指望启动游戏就能看到漏记告警——需要时按「怎么验证」里的方式手动开一次区间。
+
+### 边界二:读档期间录像被禁用
+
+`CoreState.loadState` 在恢复每个可存档对象之前会调用 `replaySystem.disable()`,恢复之后调用 `revert()`,所以读档本身不会被记进录像。读档开始时还会先清掉残留的禁用标记,避免上一次的 `disable` 层数漏回去。
+
+但执行器不在包裹范围内:`bindSaveableExecuter` 绑定的 `afterLoad` 在该对象的 `loadState` 之后立即运行,此时录像已经恢复可用,因为它可能确实要做需要记录的行为。这意味着在执行器里操作会影响成绩的状态要格外小心。细节见[可存档内容](./saveable-content.md)。
+
+### 边界三:装饰器只认类的方法
+
+两个装饰器都按「类方法装饰器」实现,只能写在 `class` 的方法上方。写在普通函数、对象属性、箭头函数上都不会生效。
+
+## 模板:给方法加录像标记
+
+这一节给的是录像标记这类机制的通用骨架,不是某条具体录像指令的示例。某条指令怎么实现、主动录像与被动录像怎么写,属于自定义录像行为,见 `高级 II · 自定义录像行为`;录像格式与沙盒的深层原理见 `高级 III · 录像深层原理`。
+
+```ts
+// [模板] 给类的方法加录像标记:复制后按 [需改] 修改
+export class MySystem {
+ // [需改] 方法名换成你真正会改变游戏状态的方法
+ @shouldReplay('[需改] 这个方法改变了什么状态')
+ changeSomething(value: number): void {
+ // [需改] 真正修改状态的内容
+ }
+
+ // [需改] 只在确实不该计入录像的被动路径上加,并把理由写清楚
+ @ignoreReplay('[需改] 为什么这段不该计入录像')
+ async onDelayedSomething(): Promise {
+ // [需改] 延迟触发、不由玩家动作直接产生的逻辑
+ }
+}
+```
+
+改什么:
+
+1. 标记类型:会改状态的用 `@shouldReplay`;确实被动、不该计入的才用 `@ignoreReplay`。只读方法两个都不要。
+2. 方法名:换成你自己的方法。装饰器必须紧贴方法声明,中间不要留空行。
+3. 消息文本:写清「这个方法改变了什么状态」。它会出现在告警里(形如 `方法名: 消息`),是给人排查的线索,不要照抄示例里的占位文字。
+4. 引入:顶部需要 `import { shouldReplay, ignoreReplay } from '@user/data-common';`。若编辑器报缺少 `shouldReplay`,再补这一行;已经引入过就不要重复粘。
+
+注意:一个方法只需要一个标记,两个一起写含义互相矛盾。`@ignoreReplay` 不到万不得已不要用——它的作用范围是整段区间,不是这一个方法。
+
+## 给一个内置方法补录像标记
+
+这一节按六步给出可直接照做的改法。
+
+目标:让一个会改变状态的玩家动作入口,在收集区间里能被登记出来,并亲眼看到「有标记」与「没标记」的差别。
+
+改哪里:`packages-user/data-base/src/flag/system.ts`,`FlagSystem.setField` 的上方。这个文件里 `setField` 与 `deleteField` 都已经有标记,照着它们的位置改最直观。
+
+复制什么:只复制装饰器那一行,粘在方法声明的上一行:
+
+```ts
+// [需改] 只新增这一行
+@shouldReplay('[需改] 这个方法改变了什么状态')
+```
+
+这里不含 `import`:`system.ts` 顶部已经有 `import { shouldReplay } from '@user/data-common';`,不要再粘一次。若你换到别的文件后报缺少 `shouldReplay`,再在顶部补这一行。
+
+改什么:把消息文本改成这个方法实际改变了什么。内置用的都是英文短语,例如 `Flag field set should be replayed.`;语言本身不影响行为,写清意思即可。
+
+怎么验证:手动开一次收集区间,看告警里有没有这条路径。这几个函数不在 `state` 上,需要在文件顶部引入(只用于调试,平时不需要):
+
+```ts
+import {
+ beginReplaySafetyCollection,
+ endReplaySafetyCollection
+} from '@user/data-common';
+
+beginReplaySafetyCollection(state.replaySystem);
+state.flags.setField('replay-check', 1);
+endReplaySafetyCollection();
+```
+
+- 区间内没有产生任何新录像步,`setField` 又带着标记,所以控制台应出现告警(错误码 `161`),列表里能看到 `setField: ...` 这一条;
+- 把 `setField` 上的标记注释掉再跑一次,告警会消失。这不是修好了,而是检查器看不见这个入口了——这正是「漏标记比漏记录更难查」的原因。
+
+注意:只加在真正会改状态的方法上。入口方法与它内部的方法可以都标(嵌套路径就是为此设计的),但把标记集中在真正改状态的那一层,告警会更短、更好读。
+
+## 改内置还是自己写
+
+绝大多数情况不需要新写类:
+
+- 给一个会改状态的入口补标记——直接在已有实现文件里改,例如 `packages-user/data-base/src/flag/`、`hero/`、`map/` 下对应的文件;
+- 只有在确实需要新的录像入口(新的指令、新的主动或被动行为)时,才考虑新写类,而这已经超出「标记」的范围,属于自定义录像行为(`高级 II · 自定义录像行为`)。
+
+补一行装饰器的成本极低,而且出问题时对照原代码就能看出差异;自己写新类则要同时处理录制时机、参数编码与顺序,排查面大得多。
+
+如果你不怎么懂代码,建议直接改内置,而不是自己写新的类。
+
+## 常见错误
+
+- 给只读方法或计算器加 `@shouldReplay`。它们不产生录像步,只会让告警列表变长,把真正的漏记埋掉。
+- 用 `@ignoreReplay` 压告警。它忽略的是整段区间,真正的漏记会一起被放过。
+- 以为加了装饰器就会自动记录。装饰器只登记路径,录像步仍要 `record` 或走内置录制入口。
+- 在异步回调里记录却没处理顺序。录像步会落在错误的时点,检查也看不出来。
+- 在读档过程中记录。`loadState` 被 `disable` 包裹,记录不会生效;执行器不在这层保护里,反而要更谨慎。
+- 把装饰器写在普通函数上。它只支持类的方法。
+- 以为没告警就是没问题。收集区间没开、或者入口压根没标记,都不会有告警。
+
+## 怎么验证
+
+第一项:开一次收集区间,看登记路径。
+
+用上一节的三行代码,`beginReplaySafetyCollection` → 调用一个带标记的方法 → `endReplaySafetyCollection`。控制台应给出告警(`161`)和路径列表;粘上告警里给出的 `logReplaySafetyDetail(n)` 命令,应打印出完整嵌套路径。
+
+第二项:去掉标记,确认告警消失。
+
+把同一个方法上的标记注释掉再跑一次。告警消失说明检查器「看不见」这个入口,而不是问题解决了——这也是为什么标记必须加在正确的层。
+
+第三项:让区间内产生一条录像步,确认检查通过。
+
+在区间内调用一个真正会记录录像的方法(例如走一步、使用一个道具),再结束收集。此时不应告警,因为检查只关心「有没有新录像步」,不关心是哪一条。
+
+第四项:自己核对参数与顺序。
+
+装饰器不会替你检查这些。录一段包含目标操作的录像,回放后对比关键数据:位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
+
+第五项:确认读档不产生录像。
+
+存档、读档,然后检查录像长度有没有变化。`loadState` 期间的记录被禁用,长度应当不变;若有变化,检查是不是在绑定的执行器里做了会记录的操作。
+
+## 小结
+
+- 录像一致性只要求一件事:玩家直接产生、会影响最终成绩的行为,一条都不能少记。
+- `@shouldReplay` 在收集区间内登记路径,`@ignoreReplay` 把整段区间标记为忽略;两者都不写录像。
+- 标记写在会改状态的玩家动作入口上,不写在只读、试算、展示与搜索路径上。
+- 检查能发现「区间内一条录像步都没有」,但不管逻辑、参数、顺序与一一对应。
+- 收集区间目前只有测试在开启;需要时手动 `beginReplaySafetyCollection` / `endReplaySafetyCollection`。
+- 读档期间录像被临时禁用,执行器不在保护范围内。
+- 不懂代码时优先改内置加一行标记,不要急着写新类。