Files
template/docs/advanced/replay-consistency.md
T

139 lines
11 KiB
Markdown
Raw Blame History

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