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`。 +- 读档期间录像被临时禁用,执行器不在保护范围内。 +- 不懂代码时优先改内置加一行标记,不要急着写新类。