docs(10-16): 编写录像一致性文档

- 讲清 @shouldReplay / @ignoreReplay 的落点:状态变更入口 vs 延迟被动路径
- 说明 @ignoreReplay 作用于整段收集区间,不是单个方法
- 说明能保证什么、不能保证什么,以及检测机制与边界
- 给出「模板:给方法加录像标记」与六步实操「给一个内置方法补录像标记」
This commit is contained in:
unanmed 2026-10-20 14:26:51 +08:00
parent 9142e75e64
commit 65801ea74b

View File

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