# 录像一致性
录像验证的用法是:把玩家的操作录下来,之后只跑数据端、按顺序重放,看结果与原始游玩是否一致。要让这件事成立,前提是该记的行为一条都不能少。少记一条,重放时那一步就没有发生,之后每一步都建立在错误的状态上,而第一次出现差异的位置,往往离真正的原因很远。
中级教程的[录像指令](../intermediate/replay-commands.md)讲了录像是什么、主动与被动的区别、以及怎么新增一条指令。这一篇往下一层:`@shouldReplay` 与 `@ignoreReplay` 这两个装饰器该写在什么方法上,它们能替你查出什么、查不出什么。
阅读前需要会基础语法:`class`、`new`、`import`,以及装饰器写法 `@名字(...)`。装饰器的基础写法见[代码基础](../intermediate/code-basics.md)。
## 什么该记
同一条录像,重放两次必须得到同样的数据端结果;否则录像验证就失去了意义。要做到这一点,只能靠「录像里记下的操作,恰好覆盖了所有会改变游戏状态的行为」。
核心原则在中级已经给过,这里再抄一遍,因为后面所有判断都从它出发:
:::info 核心原则
玩家直接产生、会影响最终成绩的行为,必须计入录像。
:::
两个条件缺一不可:由玩家亲手做出(移动、使用道具、装备、触发事件),并且会改变游戏结果。代码自己跑出来的、只影响画面的,都不算。
一个方法该不该记,通常是分析出来的,不是试出来的。先问三件事:
- 这个方法能不能被玩家直接触发?
- 它会不会改变游戏状态?
- 这个改变有没有可能影响最终结果?
三个答案都是「是」,就加 `@shouldReplay`。如果你暂时判断不了,最稳妥的做法是:把所有「修改了游戏状态、且该状态可能导致结果改变」的方法都加上标记。漏标的代价是录像悄悄错掉;多标的代价只是告警里多一条路径,前者难查得多。
只读查询、纯计算、展示与搜索路径不要标。它们不改变游戏状态,标了只会让告警列表变长,把真正的漏记埋进噪声里。
## 给会改状态的方法加标记
标记加在你自己的、会改状态的方法上。如果你不确定该把标记写在哪里,可以参考下面的模板:它把一个会改状态的公开方法标成「应计入录像」,其余成员用注释省略。商店插件是现成的例子,它的 `ShopSystem` 在 `src/data/plugin/shop/system.ts` 里给开店、关店、购买、出售都加了标记:
```ts
// shouldReplay 从数据端公共包引入
import { shouldReplay } from '@user/data-common';
import { IShopSystem, ShopTradeResult } from './types';
// 商店插件节选:完整类结构,未展示的成员用注释省略
export class ShopSystem implements IShopSystem {
// ... 其他成员
// 购买会改变金币与商店库存,必须计入录像
// 装饰器参数是写给人看的说明,出现在告警里
@shouldReplay('Buying items should be replayed.')
buyItem(item: number | string, count: number): ShopTradeResult {
// ... 原有的购买逻辑
}
}
```
这个模板声明了 `ShopSystem` 类,实现 `IShopSystem` 接口;`@shouldReplay` 紧贴在 `buyItem` 方法上方,装饰的是这个方法。你需要改的是:把类与方法换成你真正会改状态的那一个,把 `message` 写成这个方法改变了什么状态;同一个类里还有其它会改状态的入口时,按同样方式逐个标注。
装饰器紧贴方法声明上方,中间不要隔别的成员。`shouldReplay` 从 `@user/data-common` 引入;商店插件的文件顶部已经有这行 `import`,新文件才需要自己补。同一个插件里,`openShop`、`closeShop`、`sellItem` 也都按同样方式标注。
`@shouldReplay` 与 `@ignoreReplay` 只在数据端使用。录像验证只跑数据端,渲染端不参与重放,也不在安全检查的覆盖范围内;不要在渲染端代码里加这两个装饰器。
## 两个装饰器
两个装饰器都写在类的方法上方,作用对象是「录像安全检查」,不是录像本身。
| 装饰器 | 写在哪 | 做什么 |
| ------------------------ | --------------------------------- | ------------------------------------- |
| `@shouldReplay(message)` | 会改变游戏状态的方法上 | 把「这个方法被调用过」登记成一条路径 |
| `@ignoreReplay(reason)` | 确实不该计入录像的被动 / 延迟路径 | 把整段检查标记为忽略 |
:::warning 录像记录缺失
如果带 `@shouldReplay` 的方法执行了,而这次执行没有产生任何录像记录,控制台会输出一条「录像记录缺失」的警告,并列出这次执行经过的标记方法。看到它,说明这条路径该记却没记。检测的内部原理留到 `高级 III · 录像深层原理`。
警告里还会附一条可直接复制的调用,形如 `Mota.require('@user/data-common').logReplaySafetyDetail(0);`。把它粘到控制台执行,就能展开这次检查的完整调用路径,逐层看到经过的标记方法。这个调用形式之后可能会调整,以控制台实际打印的那条为准。
:::
### @shouldReplay:只登记路径,不写录像
`@shouldReplay(message)` 把方法标记为「应该计入录像」。它不写录像:真正记录仍要靠 `state.replaySystem.record(code, ...params)`,或者走内置的录制入口。它做的是让安全检查知道「这条路径执行过」,以便和录像里实际产生的步数对照。
`message` 是写给人看的说明,用来在警告里指认方法;语言和措辞不影响行为,写清这个方法改变了什么状态即可。
### @ignoreReplay:忽略整段检查
`@ignoreReplay(reason)` 容易被误解成「只忽略这个方法」。实际不是:它把整段安全检查标记为忽略,包括同一段里其它正常登记的 `@shouldReplay` 路径。
所以它不能用来「消掉某条告警」。你消掉的是整段检查,真正的漏记会被一起放过。它只适合确实不该计入录像的路径,例如异步内容、延迟回调这类不会立刻改变状态的方法。源码注释也明确写了这层意思,并提醒不要用它规避告警。`reason` 目前不会出现在告警输出里,写它是为了后来的人知道这里为什么可以忽略。
同一个插件里,入口方法与被它调用的内部方法可以都标:安全检查会按调用嵌套组织路径,越靠里越接近真正改变状态的位置。但把标记集中在真正改状态的那一层,告警会更短、更好读。
## 能保证什么,不能保证什么
检查能做的事很有限,不要把它当成正确性证明。
能保证的:带标记的路径执行了却没有留下录像记录时,控制台会告警,并列出这段执行经过的标记方法;路径按调用嵌套组织,能帮你定位是哪条调用链没有记。
不能保证的:
| 它不检查 | 说明 |
| -------------- | ------------------------------------------------------------ |
| 逻辑是否正确 | 方法算错了,只要产生了录像记录,检查照样通过 |
| 参数是否正确 | 录像步的参数写错了、写少了,检查不看内容 |
| 顺序是否正确 | 它只关心有没有记录,不比较先后 |
| 是否一一对应 | 一条录像步可以覆盖多个状态修改,一次动作也可能产生多条录像步 |
| 是否漏了入口 | 没有标记的方法不会被登记,自然也不会出现在告警里 |
| 是否会自动记录 | `@shouldReplay` 只登记路径,记录仍要 `record` 或内置录制入口 |
最后两条是实践中最容易踩的:没标记的方法,检查器根本不知道它存在;而标了标记的方法,也不会因此自动写录像。
## 验证
录像一致性最终要靠回放对比:录一段包含目标操作的录像,回放后检查关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。想看更细的调用路径,就执行警告里附带的 `logReplaySafetyDetail(...)` 调用;命令形式若有变化,以控制台输出为准。
参数与顺序需要自己核对,装饰器不会替你检查这些。
## 边界与注意
- 在渲染端加标记。`@shouldReplay` 只用于数据端;渲染端不参与录像验证,加了也不会进入检查。
- 读档期间录像被禁用。`CoreState.loadState` 在恢复每个可存档对象之前调用 `replaySystem.disable()`,之后调用 `revert()`,所以读档本身不会被记进录像。
- 执行器不在保护范围内。`bindSaveableExecuter` 绑定的 `afterLoad` 在该对象 `loadState` 之后立即运行,此时录像已经恢复可用。在执行器里操作会影响成绩的状态要格外小心,细节见[可存档内容](./saveable-content.md)。
- 装饰器只认类的方法。写在普通函数、对象属性、箭头函数上都不会生效。
- 不要给只读方法或计算器加标记。它们不产生录像记录,只会让告警列表变长。
- 不要用 @ignoreReplay 压告警。它忽略的是整段检查,真正的漏记会一起被放过。
- 同一个方法只写一个标记。`@shouldReplay` 与 `@ignoreReplay` 同时写含义互相矛盾。
- 异步回调里的记录要处理顺序。录像步会落在错误的时点,检查也看不出来。
- 没有告警不等于没问题。入口没标记、或者检查没有覆盖到这段执行,都不会有告警。
需要自定义录像指令或新的录制入口时,属于 `高级 II · 自定义录像行为`;录像格式与沙盒原理见 `高级 III · 录像深层原理`。
## 小结
- 录像一致性只要求一件事:玩家直接产生、会影响最终成绩的行为,一条都不能少记。
- 该不该记通常靠分析;判断不了时,把所有会改状态、可能影响结果的方法都加上 `@shouldReplay`。
- `@shouldReplay` 只用于数据端,只登记路径;`@ignoreReplay` 把整段检查标记为忽略;两者都不写录像。
- 带标记的方法执行了却没有录像记录时,控制台会输出「录像记录缺失」警告。
- 检查不管逻辑、参数、顺序与一一对应,这些要自己核对。
- 读档期间录像被临时禁用,执行器不在保护范围内。
返回[高级 I 导览](./index.md)。