From 6351cfea6e6b8d0fc95b3a08835f4061610b1aa4 Mon Sep 17 00:00:00 2001
From: unanmed <1319491857@qq.com>
Date: Wed, 21 Oct 2026 10:46:55 +0800
Subject: [PATCH] =?UTF-8?q?docs(10-20):=20=E4=BF=AE=E8=AE=A2=E6=8F=92?=
=?UTF-8?q?=E4=BB=B6=E6=B8=B2=E6=9F=93=E7=AB=AF/Store=E8=BE=B9=E7=95=8C/?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=EF=BC=88=E7=94=A8=E6=88=B7=E7=AC=AC?=
=?UTF-8?q?3=E8=BD=AE=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- plugin-system 补充渲染端插件接口与完整示例,删除排序细节
- data-stores 删除 fromRaw 与缓存更新等阶段性深层说明,边界改为「极少需要自己补定义」
- 全部七篇代码块补成完整类/函数结构
- replay-consistency 保留 logReplaySafetyDetail 查详细信息的调用说明
- index 说明高级教程同时覆盖数据端与渲染端
---
.../10-docs-restructure/10-20-USER-REVIEW.md | 32 ++++
docs/advanced/data-stores.md | 40 +++--
docs/advanced/declarative-vs-imperative.md | 53 +++++-
docs/advanced/hooks-in-depth.md | 96 +++++++---
docs/advanced/index.md | 8 +-
docs/advanced/plugin-system.md | 165 ++++++++++++------
docs/advanced/replay-consistency.md | 4 +-
docs/advanced/saveable-content.md | 140 ++++++++++-----
8 files changed, 385 insertions(+), 153 deletions(-)
diff --git a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md
index 97d5b5b..1b70da8 100644
--- a/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md
+++ b/.planning/phases/10-docs-restructure/10-20-USER-REVIEW.md
@@ -98,3 +98,35 @@
**构建:** `pnpm exec prettier --write` 后 `--check` 四篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
**状态:已处理**
+
+---
+
+## 第 3 轮
+
+**时间:** 2026-10-20
+**用户意见(5 条):**
+
+| # | 意见 | 落点 |
+| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
+| U3-01 | 插件系统应同时讲**渲染端插件**(渲染端虽未完成,但渲染端插件系统已几乎定稿);不要讲排序细节(作者根本不需要知道,用到该接口的可能性几乎为零) | `plugin-system.md` |
+| U3-02 | 明确写出「需要自己补充 Store 定义非常少见」,放进「边界与注意」,并指向后续高级教程;不要写「登记不是创建运行时对象」这类当前阶段不必了解的内容 | `data-stores.md` |
+| U3-03 | `MainDataPlugin` 的 `afterLoad` 代码未包裹 class,导致高亮出错;应改成完整结构。之后统一注意:代码块必须是完整结构,不要单独写某个类或成员 | `plugin-system.md`(及全部) |
+| U3-04 | 录像一致性里指向 logger 的调用可以保留——教读者如何查看详细信息是必要的;函数调用方式可能会变,之后可能要改 | `replay-consistency.md` |
+| U3-05 | 高级教程不只包含数据端,也包含渲染端(只是渲染端未完成、暂时无法写);导览里**不要写「不要求懂渲染端」** | `index.md` |
+
+### 第 3 轮处置
+
+**修改方式:** 内联执行,仅修改 `docs/advanced/*.md` 与本职记录(本轮执行因断电中断后继续完成,未重做前两轮内容)。
+
+| # | 处置 |
+| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| U3-01 | `plugin-system` 新增「渲染端插件接口」小节:按 `packages-user/client-modules/src/types.ts` 的 `IClientPluginDefine` 列出 `id` / `dataDeps` / `clientDeps` 与三个时机参数,给出完整的 `MyClientPlugin` 类与 `src/client/plugin/list.ts`、`src/client/entry.ts`、`state.getClientPlugin` 的注册取用方式;删除「dataDeps 与排序」整节(`orderPlugins` 的 `order` / `cyclic` / `missing` 字段),依赖执行语义并入「状态与顺序」。 |
+| U3-02 | `data-stores` 在「边界与注意」首条写明需要自己补充 Store 定义的情况非常少见,并指向 `高级 II · 加载与插件生命周期`;删除 `state.maps.fromRaw(raw)` 与「从原始数据到运行时对象只有这一次转换」整段、以及「它不保证更新已经存在的对象……静态图块 / 动态图块缓存」一条(列表重新编号),小结中同源的 `fromRaw` 一句一并删除。 |
+| U3-03 | `plugin-system` 的 `MainDataPlugin` 片段补成完整类(`implements IDataMainPlugin`、字段、构造器、`afterLoad` 方法体,其余成员用注释省略);复查七篇代码块,把 `hooks-in-depth` 中两段脱离上下文的注册 / 卸载语句包成完整函数,其余代码块均以 `import`、完整类 / 接口或完整声明开头。 |
+| U3-04 | `replay-consistency` 在「录像记录缺失」警告框中保留并说明警告附带的 `Mota.require('@user/data-common').logReplaySafetyDetail(0);` 调用,注明可粘到控制台展开调用路径、调用形式之后可能调整;验证节同步指向该调用。未恢复底层收集机制。 |
+| U3-05 | `index.md` 删去「不要求你懂渲染端实现」,改为高级教程同时覆盖数据端与渲染端、渲染端尚未完工所以当前只收录数据端内容;篇目表第 3 行与「本层不覆盖什么」同步该说法。 |
+| D-62 | 七篇代码块统一复查:不再出现单独的方法成员、顶层 `return` 或缺失类 / 函数外壳的语句。 |
+
+**构建:** `pnpm exec prettier --write docs/advanced/*.md` 后 `--check` 七篇通过;`pnpm exec tsx docs/.vitepress/init.ts && pnpm docs:build` 输出 `build complete`,无 dead link。
+
+**状态:已处理**
diff --git a/docs/advanced/data-stores.md b/docs/advanced/data-stores.md
index c4667c0..4c08d04 100644
--- a/docs/advanced/data-stores.md
+++ b/docs/advanced/data-stores.md
@@ -72,8 +72,6 @@ Store 在加载 / 初始化阶段被填一次,之后整个游
- `getMap(floorId)`:原始地图数据,查不到返回 `null`;
- `addMap(map)`:登记一份原始数据,同一个 `floorId` 直接覆盖,不告警。
-这份原始数据不会自己变成可玩的地图。运行时楼层由 `state.maps.fromRaw(raw)` 构造:它按 `width` 与 `map` 算出高度、建图层、铺图块、挂事件。从原始数据到运行时对象只有这一次转换。
-
### enemyStore:怪物原始数据
以怪物图块数字为键,存 `IEnemyRawData`:`num`、`attribute`(怪物属性)、`special`(特殊属性,键为特殊属性编号、值为参数)。
@@ -96,45 +94,49 @@ Store 在加载 / 初始化阶段被填一次,之后整个游
1. 它不进存档。五个 Store 都不是可存档对象——`addSaveableContent` 注册的是 `hero`、`flags`、`maps`、`enemyManager`、`replaySystem`,没有 Store。读档不会恢复你的改动,也不会把它退回原样。
2. 它不进录像。`add*` 没有 `@shouldReplay` 标记,录像里不会有「登记这条定义」这一步。重放时这条定义从什么时候开始生效,与原始游玩并不一致。
-3. 它不保证更新已经存在的对象。有的运行时对象每次都现查 Store(静态图块的 `raw()`、事件派发),有的在构造时就把定义缓存下来(动态图块、背包道具、运行时楼层)。改完之后谁变了、谁没变,取决于取用方式。
-4. 它绕过了检查。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
+3. 它绕过了检查。定义改了,存档、录像、内存里的对象三者可能各是各的;差异不会立刻报错,而是在某个不确定的时机表现出来。
所以规则很简单:登记只在加载期做一次,运行期只读。
至于登记这件事本身,绝大多数情况也不用你动手:`add*` 是给数据端自己用的接口——内容加载还没写完,写完后由数据端统一登记,不在你的代码里。日常你只需要按下面这样读:
```ts
-// 先按图块 id 查数字,再取完整定义;没有登记时 num 为 undefined
-const num = state.tileStore.num('my-tile');
-if (num !== undefined) {
- // raw 为 null 表示这条定义不存在
- const raw = state.tileStore.getData(num);
- console.log(raw, state.tileStore.getType(num));
-}
+import { ICoreState } from '@user/data-state';
-// 道具、地图、怪物同样按各自的键查
-const item = state.itemStore.getData('my-item');
-const map = state.mapStore.getMap('my-floor');
-const enemy = state.enemyStore.getEnemy('my-enemy');
+// 读示例:放进能拿到 state 的方法里
+export function readStores(state: ICoreState): void {
+ // 先按图块 id 查数字,再取完整定义;没有登记时 num 为 undefined
+ const num = state.tileStore.num('my-tile');
+ if (num !== undefined) {
+ // raw 为 null 表示这条定义不存在
+ const raw = state.tileStore.getData(num);
+ console.log(raw, state.tileStore.getType(num));
+ }
+
+ // 道具、地图、怪物同样按各自的键查
+ const item = state.itemStore.getData('my-item');
+ const map = state.mapStore.getMap('my-floor');
+ const enemy = state.enemyStore.getEnemy('my-enemy');
+ console.log(item, map, enemy);
+}
```
读 Store 随时可以:查表、算数、拼提示文本、给界面提供数据,都是正常用法。查表不会改变任何东西,也不产生录像步。唯一要注意的是不要就地修改返回的对象:`getData` / `getMap` / `getEvent` 返回的多是 Store 里存着的那个实例(`tileStore.getEvent` 例外,它每次新建一个 `Map`),就地改它等于绕过 `add*` 改了注册表。
## 边界与注意
-- 确实要补几条定义。把调用集中放在插件的一个时机方法里(`awake` / `initialize` / `afterLoad`,见[插件系统](./plugin-system.md)),只在启动流程里执行一次;不要放进玩家动作、事件回调、每帧逻辑或战斗结算。
-- 登记不会创建运行时对象。`addMap` 只是把原始数据放进注册表,要可玩还得自己调用 `state.maps.fromRaw(raw)`;`addTile` 也只影响之后构造的图层,图层已经建好就不会自动补进去。
-- 重复登记同一个 `num` 会覆盖。图块告警 `133` / `134`,道具与怪物告警 `181`,事件告警 `170`;不撞键才是安全的登记。
+- 需要自己补充 Store 定义的情况非常少见。`add*` 是数据端自用的登记接口:内容加载还没有写完,写完后由数据端统一登记。绝大多数情况下你只需要读,不需要自己补定义。确实需要补时,属于后续高级教程的内容(`高级 II · 加载与插件生命周期`,待编写),并且要把调用集中放在插件的一个时机方法里(见[插件系统](./plugin-system.md)),只在启动流程里执行一次。
- 返回值不是副本。改 `getData` / `getMap` / `getEvent` 的返回值等于改注册表;需要不同的定义就登记新的,不要改旧的。
- 读档不会恢复 Store。改过的定义不会随存档保存,读档也不会把它退回去。
- `eventStore.getEvent` 给的是实例本身,不要在上面做缓存并当成快照。
+
+
## 小结
- 五个 Store 是 Layer 0 的内容层注册表,存定义,不存游戏状态,也不进存档。
- 键分别是图块数字、道具图块数字、`floorId`、怪物图块数字、事件 id。
- 读随时可以;返回的通常是注册表里的实例,不要就地修改。
- 登记由数据端负责,绝大多数情况不需要自己调用 `add*`,运行期尤其不要写。
-- 运行时楼层由 `state.maps.fromRaw(raw)` 一次性构造;改 Store 不会重建它。
返回[高级 I 导览](./index.md)。
diff --git a/docs/advanced/declarative-vs-imperative.md b/docs/advanced/declarative-vs-imperative.md
index 4ee1ce5..8109ab9 100644
--- a/docs/advanced/declarative-vs-imperative.md
+++ b/docs/advanced/declarative-vs-imperative.md
@@ -24,12 +24,16 @@
```ts
// 旧样板示意(伪代码,不可照抄)
// 事件脚本执行到这里时,直接遍历周围怪物,改写它们“当下”的攻击力
-for (const enemy of nearbyEnemies) {
- enemy.atk = enemy.atk + 10;
+function strengthenNearbyEnemies(nearbyEnemies: IEnemyState[]): void {
+ for (const enemy of nearbyEnemies) {
+ enemy.atk = enemy.atk + 10;
+ }
}
// 另一段脚本里,勇士回血也是直接改当前数值
-hero.hp = hero.hp + 50;
+function healHero(hero: IHeroState): void {
+ hero.hp = hero.hp + 50;
+}
```
这种写法的执行时机非常直观:代码走到哪,效果就发生到哪。但它会带来几个后果:
@@ -97,8 +101,13 @@ const aura: ISpecial23Value = {
引擎在哪个时机执行:对应动作发生时。主对象会通过 `forEachHook` 依次通知挂在自己身上的钩子。你只需要注册一次:
```ts
+import { ICoreState } from '@user/data-state';
+import { MyHeroAttributeHooks } from './hero-attribute-hooks';
+
// 注册钩子:new 一个钩子实例,挂到关心的主对象上
-state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
+export function registerHeroAttributeHooks(state: ICoreState): void {
+ state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
+}
```
不需要时可以用 `removeHook` 摘掉。内置钩子已经在初始化时注册好,你自己写的钩子才需要 `addHook`。
@@ -136,12 +145,38 @@ state.hero.attribute.addHook(new MyHeroAttributeHooks(state));
看一个反例:
```ts
+import { ICoreState } from '@user/data-state';
+import { IEnemyAttr, IHeroAttr } from '@user/data-common';
+import {
+ CriticalableHeroStatus,
+ IDamageCalculator,
+ IEnemyDamageInfoBase,
+ IReadonlyDamageHandler
+} from '@user/data-system';
+
// [反例] 有副作用的计算器:算出结果的同时,还改了外部状态
-calculate(handler) {
- const damage = 100; // 这里只是示意,真实公式会读 handler
- // 副作用:把这次结果写进 flag
- this.state.flags.setField('lastDamage', damage);
- return { damage, turn: 1 };
+export class BadDamageCalculator implements IDamageCalculator<
+ IEnemyAttr,
+ IHeroAttr
+> {
+ constructor(private readonly state: ICoreState) {}
+
+ calculate(
+ handler: IReadonlyDamageHandler
+ ): IEnemyDamageInfoBase {
+ const damage = 100; // 这里只是示意,真实公式会读 handler
+ // 副作用:把这次结果写进 flag
+ this.state.flags.setField('lastDamage', damage);
+ return { damage, turn: 1 };
+ }
+
+ // 临界计算同样会被反复调用,这里只保留结构
+ getCriticalLimit(
+ handler: IReadonlyDamageHandler,
+ attribute: CriticalableHeroStatus
+ ): number {
+ return 0;
+ }
}
```
diff --git a/docs/advanced/hooks-in-depth.md b/docs/advanced/hooks-in-depth.md
index 83a456d..8fe478b 100644
--- a/docs/advanced/hooks-in-depth.md
+++ b/docs/advanced/hooks-in-depth.md
@@ -44,20 +44,26 @@ export class GroundBlockHooks implements IMapLayerHooks {
注册时找到目标图层,把实例挂到它上面:
```ts
-const map = state.maps.getMap('floor-1');
-if (!map) {
- // afterLoad 时楼层未必已经构造出来,不要静默跳过
- state.emitTip('floor-1 尚未构造,钩子未注册');
- return;
-}
-const layer = [...map.layerList].find(l => l.alias === 'ground');
-if (!layer) {
- state.emitTip('ground 图层不存在');
- return;
-}
+import { ICoreState } from '@user/data-state';
+import { GroundBlockHooks } from './ground-block-hooks';
-// 注册到 layer,不是 map;保存控制器以便之后卸载
-const controller = layer.addHook(new GroundBlockHooks(state, layer));
+// 找到目标图层后注册;通常放在插件的 afterLoad 里
+export function registerGroundHooks(state: ICoreState) {
+ const map = state.maps.getMap('floor-1');
+ if (!map) {
+ // afterLoad 时楼层未必已经构造出来,不要静默跳过
+ state.emitTip('floor-1 尚未构造,钩子未注册');
+ return null;
+ }
+ const layer = [...map.layerList].find(l => l.alias === 'ground');
+ if (!layer) {
+ state.emitTip('ground 图层不存在');
+ return null;
+ }
+
+ // 注册到 layer,不是 map;返回控制器以便之后卸载
+ return layer.addHook(new GroundBlockHooks(state, layer));
+}
```
`afterLoad` 时楼层未必已经由加载流程构造出来(取决于内容加载方式与插件顺序),`state.maps.getMap('floor-1')` 可能返回 `null`;这时至少要把原因报出来,或者把注册推迟到楼层创建之后。另外,`onUpdateBlock` 只在图块数字真的变化时触发,设置成相同数字不会触发。
@@ -65,8 +71,21 @@ const controller = layer.addHook(new GroundBlockHooks(state, layer));
如果钩子要在构造时保持通用、注册时才确定图层,可以在 `awake` 里取控制器。`MapLayer` 的控制器是 `IMapLayerHookController`,带 `layer` 字段与 `getMapData()`:
```ts
-awake(controller: IMapLayerHookController): void {
- // controller.layer 就是注册这个钩子的图层
+import { ICoreState } from '@user/data-state';
+import { IMapLayerHookController, IMapLayerHooks } from '@user/data-base';
+
+// 节选:awake 同样写在类里,通过控制器拿到注册它的图层
+export class UniversalBlockHooks implements IMapLayerHooks {
+ constructor(readonly state: ICoreState) {}
+
+ awake(controller: IMapLayerHookController): void {
+ // controller.layer 就是注册这个钩子的图层
+ console.log(controller.layer.alias);
+ }
+
+ onUpdateBlock(block: number, x: number, y: number): void {
+ this.state.emitTip(`图层更新:(${x}, ${y}) -> ${block}`);
+ }
}
```
@@ -92,18 +111,24 @@ export class TargetLayerResizeHooks implements IGameMapHooks {
注册到 `map`,不是 `layer`:
```ts
-const map = state.maps.getMap('floor-1');
-if (!map) {
- state.emitTip('floor-1 尚未构造,钩子未注册');
- return;
-}
-const targetLayer = [...map.layerList].find(l => l.alias === 'ground');
-if (!targetLayer) {
- state.emitTip('ground 图层不存在');
- return;
-}
+import { ICoreState } from '@user/data-state';
+import { TargetLayerResizeHooks } from './target-layer-resize-hooks';
-map.addHook(new TargetLayerResizeHooks(state, targetLayer));
+// 注册到 map,不是 layer
+export function registerTargetLayerHooks(state: ICoreState): void {
+ const map = state.maps.getMap('floor-1');
+ if (!map) {
+ state.emitTip('floor-1 尚未构造,钩子未注册');
+ return;
+ }
+ const targetLayer = [...map.layerList].find(l => l.alias === 'ground');
+ if (!targetLayer) {
+ state.emitTip('ground 图层不存在');
+ return;
+ }
+
+ map.addHook(new TargetLayerResizeHooks(state, targetLayer));
+}
```
这里的比较用对象引用(`===`)。`onResizeLayer` 能拿到 layer,是因为 `GameMap.addLayer` 给每个图层装了内部转发器,把图层的 `onResize` 转发到地图级钩子并带上具体图层。调用 `map.resizeLayer(30, 30, true)` 时每个图层都会触发一次,你的处理只应在目标图层上生效。
@@ -113,11 +138,24 @@ map.addHook(new TargetLayerResizeHooks(state, targetLayer));
`addHook` 会创建控制器、把钩子写入内部映射与列表、调用 `awake`,最后返回控制器。控制器最重要的方法是 `unload()`:
```ts
-const hook = new GroundBlockHooks(state, layer);
-const controller = layer.addHook(hook);
+import { ICoreState } from '@user/data-state';
+import { IMapLayer, IMapLayerHookController } from '@user/data-base';
+import { GroundBlockHooks } from './ground-block-hooks';
+
+// 注册,保存返回的控制器
+export function addGroundBlockHooks(
+ state: ICoreState,
+ layer: IMapLayer
+): IMapLayerHookController {
+ return layer.addHook(new GroundBlockHooks(state, layer));
+}
// 之后需要撤掉时
-controller.unload();
+export function removeGroundBlockHooks(
+ controller: IMapLayerHookController
+): void {
+ controller.unload();
+}
```
几条接口事实:
diff --git a/docs/advanced/index.md b/docs/advanced/index.md
index ff17733..566c40c 100644
--- a/docs/advanced/index.md
+++ b/docs/advanced/index.md
@@ -6,13 +6,13 @@
## 高级教程是给谁的
-高级教程面向有高度定制需求的数据端作者:
+高级教程面向有高度定制需求的作者:
- 你要新增一套有自己状态、生命周期和存档内容的系统,而不只是改一个数值;
- 你要理解扩展机制本身,知道声明式接口、插件、存档与录像之间怎么配合;
- 你愿意读一点源码,但不需要成为引擎的编写者。
-它不要求你懂渲染端实现,也不要求你改造引擎内部。高级 I 的所有例子都落在数据端:你写的内容、你注册的对象、你声明的规则。
+它不要求你改造引擎内部。高级教程同时覆盖数据端与渲染端;渲染端尚未完工,暂时只能写数据端的内容,所以高级 I 的例子都落在数据端:你写的内容、你注册的对象、你声明的规则。
## 高级教程的三层
@@ -34,7 +34,7 @@
| --- | -------------------- | ------------------------------------------------------ |
| 1 | 高级 I 导览 | 看清分层、阅读路径与取舍原则,决定先读哪篇 |
| 2 | 声明式 vs 命令式接口 | 分清两种思路,知道持续生效的规则与一次性动作各该写在哪 |
-| 3 | 插件系统 | 独立写出插件,处理依赖、生命周期、id 稳定性与版本迁移 |
+| 3 | 插件系统 | 独立写出插件,分清数据端与渲染端的依赖和生命周期 |
| 4 | 可存档内容 | 让自定义状态正确进入存档,并在读档后恢复到存档时的样子 |
| 5 | 录像一致性 | 判断哪些方法该登记、哪些路径不该记,以及检查能保证什么 |
| 6 | data-common 的 Store | 知道内容注册表存什么、接口怎么用,以及为什么运行时只读 |
@@ -70,7 +70,7 @@
## 本层不覆盖什么
-- 渲染端内容:渲染端尚未完工,高级 I 只写数据端。
+- 渲染端内容:渲染端尚未完工,高级教程当前只收录数据端内容,渲染端专题待完工后补上。
- 高级 II 的系统级实现:战斗上下文、光环 / 查询 / 优先级、伤害与地图伤害、渲染层与布局、加载与插件生命周期、自定义录像行为。
- 高级 III 的原理与架构:双端分离约束、数据端 L0–L3、编辑器识别协议、录像深层原理、性能与调试。
- API 参考:接口签名与类型清单不属于教程正文。
diff --git a/docs/advanced/plugin-system.md b/docs/advanced/plugin-system.md
index a65fcf4..e2eaf52 100644
--- a/docs/advanced/plugin-system.md
+++ b/docs/advanced/plugin-system.md
@@ -40,14 +40,67 @@
## 数据端与渲染端
-插件分数据端与渲染端两套注册表。数据端插件实现 `IDataPluginDefine`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),生命周期相同,但多一份依赖声明:
+插件分数据端与渲染端两套注册表:数据端插件实现 `IDataPluginDefine`,注册在 `dataPlugins`;渲染端插件实现 `IClientPluginDefine`(来自 `@user/client-modules`),注册在 `clientPlugins`。两套的生命周期时机相同,区别在依赖声明与生命周期参数。
| 插件类型 | 依赖声明 | 能依赖 |
| -------- | ------------------------- | ----------------------- |
| 数据端 | `dataDeps` | 数据端插件 |
| 渲染端 | `dataDeps` + `clientDeps` | 数据端插件 + 渲染端插件 |
-依赖方向是单向的:渲染端可以依赖数据端,数据端不能依赖渲染端。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。写数据端插件时只需要关心 `dataDeps`;渲染端插件不在本篇展开。
+依赖方向是单向的:渲染端可以依赖数据端,数据端不能依赖渲染端。数据端不引用任何渲染端内容,才能在没有渲染端的环境里单独加载、存档与回放。
+
+### 渲染端插件接口
+
+`IClientPluginDefine` 在 `id`、`dataDeps` 之上多了一份 `clientDeps`,生命周期方法也因此多一个依赖参数:
+
+| 成员 | 说明 |
+| ------------------------------- | ---------------------------------------- |
+| `id: string` | 插件标识符,全局唯一 |
+| `dataDeps: readonly string[]` | 依赖的数据端插件 id |
+| `clientDeps: readonly string[]` | 依赖的渲染端插件 id |
+| `awake(...)` | 同步初始化,渲染端主对象构造期调用 |
+| `initialize(...)` | 异步初始化,加载开始前调用,会被 `await` |
+| `afterLoad(...)` | 加载完毕后调用,会被 `await` |
+
+三个时机方法的第一个参数是渲染端主对象 `IClientCore`,后两个参数依次是数据端依赖 `IDataPlugin[]` 与渲染端依赖 `IClientPlugin[]`。它们直接以 `dataDeps` / `clientDeps` 命名,不再统一叫 `dependencies`,所以不必按顺序猜是哪一类。
+
+```ts
+import {
+ IClientCore,
+ IClientPlugin,
+ IClientPluginDefine
+} from '@user/client-modules';
+import { IDataPlugin } from '@user/data-state';
+import { IShopPlugin } from '../../data/plugin/shop';
+import { IClientShopPlugin } from './client-shop';
+
+// 渲染端插件:同时依赖一个数据端插件与一个渲染端插件
+export class MyClientPlugin implements IClientPluginDefine {
+ readonly id = 'my-client-plugin';
+ readonly dataDeps: string[] = ['data-shop-plugin'];
+ readonly clientDeps: string[] = ['client-shop-plugin'];
+
+ awake(
+ state: IClientCore,
+ dataDeps: IDataPlugin[],
+ clientDeps: IClientPlugin[]
+ ): void {
+ // 依赖对象已经按声明解析好,直接取用即可
+ const dataShop = dataDeps[0]?.plugin as IShopPlugin | undefined;
+ const clientShop = clientDeps[0]?.plugin as
+ | IClientShopPlugin
+ | undefined;
+ }
+
+ async initialize(): Promise {}
+
+ async afterLoad(): Promise {}
+}
+```
+
+两个依赖数组都要声明,没有依赖就留空。渲染端插件的注册方式与数据端对应:类写在自己的目录里,追加到 `src/client/plugin/list.ts` 的 `clientPlugins` 数组末尾;入口 `src/client/entry.ts` 同样把主插件放在数组前面。需要取回渲染端插件时用 `state.getClientPlugin(id)`,返回 `Readonly> | null`,判空方式与 `getDataPlugin` 一致。
+
+渲染端尚未完工,渲染端对象具体有哪些、插件能拿它们做什么,要等完工后再写。上面这些是当前已经定稿的接口。
## 注册到 dataPlugins
@@ -119,31 +172,17 @@ load() 开始
| `PluginStatus.Initialized` | 已执行 `initialize`,未加载完 |
| `PluginStatus.Loaded` | 三个时机都已完成 |
-三个阶段都走同一份依赖排序结果:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。
+三个阶段都按同一套依赖关系执行:被依赖的插件先完成同一阶段,依赖它的一方才会开始。也就是说,插件 B 的 `awake` 开始时,它声明的依赖插件 A 一定已经 `awake` 完毕。不构成依赖关系的插件之间,不要假定谁先谁后;需要顺序就显式声明依赖。
-## dataDeps 与排序
+依赖关系在启动前定好,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变执行顺序,也没有接口在运行时追加插件。
-引擎内部用 `orderPlugins` 计算顺序,结果包含三样东西:
+依赖写错时,整条链路会被阻断:
-| 字段 | 含义 |
-| --------- | ------------------------------------------------------- |
-| `order` | 可以初始化的插件拓扑序,被依赖者在前 |
-| `cyclic` | 处于依赖环中的插件 id |
-| `missing` | 直接引用了不存在依赖的插件 id,以及它缺失的依赖 id 列表 |
+- 循环依赖:环上的插件全部被排除,控制台报一条错误(错误码 `76`),列出环上的 id;
+- 缺失依赖:`dataDeps` 里写了一个没有注册的 id,该插件被排除,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
+- 下游阻断:依赖上述插件的插件也会一起被排除,三个时机一个都不执行。
-排序只保证一件事:依赖关系成立时,被依赖的插件先完成,依赖它的后完成。不构成依赖关系的插件之间,引擎按登记顺序扫描,但这不是可以依赖的契约——需要谁先谁后,就显式声明依赖。
-
-这份顺序在主对象构造时算一次,之后不会重算。启动后修改某个插件的 `dataDeps` 数组不会改变顺序,也没有接口在运行时追加插件。依赖在启动前定好。
-
-### 失败语义:循环依赖与缺失依赖
-
-依赖写错不会「部分生效」,而是整条链路被阻断:
-
-- 循环依赖:能沿依赖边绕回自身的插件都会被记进 `cyclic`,控制台报一条错误(错误码 `76`),列出环上的 id;
-- 缺失依赖:`dataDeps` 里写了一个没有注册的 id,该插件会被记进 `missing`,控制台按「插件 id + 缺失的依赖 id」逐条报错(错误码 `77`);
-- 下游阻断:依赖了上述插件的插件,也会一起被排除。被排除的插件不会进入 `order`,三个时机一个都不会执行。
-
-一处依赖写错,受影响的不只是那一个插件,而是依赖它的整条链。排查时先看控制台里的错误码和 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。
+排查时先看控制台里的错误码与 id,再顺链往上找。依赖尽量少:每多一条依赖,就多一个「对方改名或失败会拖住自己」的机会。渲染端插件也按同样方式处理 `clientDeps`。
## 插件之间怎么调用
@@ -179,10 +218,15 @@ export class PluginB implements IDataPluginDefine {
没有声明依赖、或者在启动之后才按需访问时,用 `state.getDataPlugin(id)`。它返回 `Readonly> | null`。下面这段放在能拿到 `state` 的方法里:
```ts
+import { ICoreState, PluginStatus } from '@user/data-state';
+import { PluginA } from './plugin-a';
+
// 取回插件,先判空,再确认状态
-const holder = state.getDataPlugin('plugin-a');
-if (holder && holder.status === PluginStatus.Loaded) {
- holder.plugin.doSomething();
+export function callPluginA(state: ICoreState): void {
+ const holder = state.getDataPlugin('plugin-a');
+ if (holder && holder.status === PluginStatus.Loaded) {
+ holder.plugin.doSomething();
+ }
}
```
@@ -324,25 +368,41 @@ export class DataShopPlugin implements IShopPlugin {
主插件 `src/data/initialize.ts` 的 `MainDataPlugin` 是 `afterLoad` 的典型用法。它的 `awake` 负责装配朝向、寻路、加载钩子与各系统;创建勇士则放到 `afterLoad`:
```ts
-async afterLoad(state: ICoreState): Promise {
- // 核心配置只有在加载完成后才拿得到
- const coreConfig = state.loader.getConfig('core');
- if (!coreConfig) {
- logger.error(79, 'Data');
- return;
+import { ICoreState, ICoreStateCoreConfig } from '@user/data-state';
+import { FaceDirection, FaceGroup, IFacedTileLocator } from '@user/data-common';
+import { logger } from '@motajs/common';
+import { IDataMainPlugin } from './types';
+
+// 主插件节选:只保留 afterLoad,其余成员用注释省略
+export class MainDataPlugin implements IDataMainPlugin {
+ readonly id = 'main-data-plugin';
+ readonly dataDeps: string[] = [];
+
+ // 朝向组由入口在构造时传入
+ constructor(readonly heroFace: FaceGroup) {}
+
+ // ... awake / initialize 等成员省略
+
+ async afterLoad(state: ICoreState): Promise {
+ // 核心配置只有在加载完成后才拿得到
+ const coreConfig = state.loader.getConfig('core');
+ if (!coreConfig) {
+ logger.error(79, 'Data');
+ return;
+ }
+ const dir = state.faceManager.get(this.heroFace);
+ if (!dir) {
+ logger.error(80);
+ return;
+ }
+ state.hero.createAttribute(coreConfig.init.hero.attribute);
+ const location: IFacedTileLocator = {
+ x: coreConfig.init.hero.x,
+ y: coreConfig.init.hero.y,
+ direction: FaceDirection.Down
+ };
+ state.hero.createLocation(location, dir);
}
- const dir = state.faceManager.get(this.heroFace);
- if (!dir) {
- logger.error(80);
- return;
- }
- state.hero.createAttribute(coreConfig.init.hero.attribute);
- const location: IFacedTileLocator = {
- x: coreConfig.init.hero.x,
- y: coreConfig.init.hero.y,
- direction: FaceDirection.Down
- };
- state.hero.createLocation(location, dir);
}
```
@@ -353,12 +413,17 @@ async afterLoad(state: ICoreState): Promise {
启动完成后,用 `state.getDataPlugin` 取回插件,确认它已经进了注册表:
```ts
-// [验证] 取回插件并检查状态
-const holder = state.getDataPlugin('my-plugin');
-console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
+import { ICoreState, PluginStatus } from '@user/data-state';
+import { MyPlugin } from './my-plugin';
+
+// 启动完成后取回插件并检查状态
+export function checkMyPlugin(state: ICoreState): void {
+ const holder = state.getDataPlugin('my-plugin');
+ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
+}
```
-`PluginStatus` 从 `@user/data-state` 引入,`MyPlugin` 从你的插件文件引入。`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
+`PluginStatus` 与 `ICoreState` 从 `@user/data-state` 引入,`MyPlugin` 从你的插件文件引入。`status` 应为 `Loaded`,`plugin` 应是你 `new` 的那个实例。如果是 `null`,先检查 `list.ts` 有没有真的 `new` 并放进数组。
想确认依赖阻断,可以把 `dataDeps` 里的某个 id 改成一个不存在的名字再启动:控制台应报缺失依赖错误(`77`,带插件 id 与缺失的依赖 id),并且你的插件没有任何时机输出。插件注册的存档内容如何验证,见[可存档内容](./saveable-content.md)的「验证」一节。
@@ -367,7 +432,7 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
- 在 awake 里 await。`awake` 的返回值会被忽略,写 `async awake` 也不会被等待。异步准备放到 `initialize`。
- 把 dataDeps 写成对象。它必须是 id 字符串数组;插件对象是三个时机方法收到的参数,不是声明时写的东西。
- 依赖成环。环上的插件全部被排除,依赖它们的下游也不执行。依赖关系要单向。
-- 依赖 id 拼错。插件不会报「找不到类」,而是被记为缺失依赖并从 `order` 中排除;控制台会给出插件 id 与缺失的 id。
+- 依赖 id 拼错。插件不会报「找不到类」,而是被当作缺失依赖排除;控制台会给出插件 id 与缺失的 id。
- 用 getDataPlugin 不判空。它可能返回 `null`,也可能处于你还没等到阶段。
- 把插件 id 当显示名随意改。别的插件的 `dataDeps` 会立刻失效,存档 id 也不会跟着变。
- 直接改其他系统的内部字段。插件之间应通过公开方法与接口协作;直接改私有字段会绕开对方维护的一致性逻辑。
@@ -376,7 +441,7 @@ console.log(holder?.status === PluginStatus.Loaded, holder?.plugin);
- 插件把一组对象与它们的生命周期收进一个独立单元,启动时在 `dataPlugins` 里 `new` 一次。
- `dataDeps` 声明的是 id 字符串;三个时机方法收到的是解析后的插件对象。
-- 渲染端插件可以依赖数据端插件,数据端插件不能依赖渲染端插件。
+- 渲染端插件实现 `IClientPluginDefine`,用 `dataDeps` 与 `clientDeps` 分别声明两类依赖;渲染端可以依赖数据端,数据端不能依赖渲染端。
- `awake` 在构造期同步执行,不能等异步;`initialize` 在加载前、`afterLoad` 在加载后,二者都会阻塞。
- 三个阶段都按依赖顺序走;循环依赖、缺失依赖会连同下游一起被阻断。
- 插件 id 是对外契约,不要随意改名或复用;它与可存档 id 是两套命名。
diff --git a/docs/advanced/replay-consistency.md b/docs/advanced/replay-consistency.md
index 89b0f20..aebd019 100644
--- a/docs/advanced/replay-consistency.md
+++ b/docs/advanced/replay-consistency.md
@@ -39,6 +39,8 @@
:::warning 录像记录缺失
如果带 `@shouldReplay` 的方法执行了,而这次执行没有产生任何录像记录,控制台会输出一条「录像记录缺失」的警告,并列出这次执行经过的标记方法。看到它,说明这条路径该记却没记。检测的内部原理留到 `高级 III · 录像深层原理`。
+
+警告里还会附一条可直接复制的调用,形如 `Mota.require('@user/data-common').logReplaySafetyDetail(0);`。把它粘到控制台执行,就能展开这次检查的完整调用路径,逐层看到经过的标记方法。这个调用形式之后可能会调整,以控制台实际打印的那条为准。
:::
### @shouldReplay:只登记路径,不写录像
@@ -98,7 +100,7 @@ export class ShopSystem implements IShopSystem {
录像一致性最终要靠回放对比:录一段包含目标操作的录像,回放后检查关键数据——位置、属性、道具数量、Flag 值。检查器只管「有没有记」,这里要管「记得对不对」。
-如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。
+如果怀疑某条路径漏记,就给它加上 `@shouldReplay`,再执行一次对应操作。带标记的方法执行了却没有产生录像记录时,控制台会出现「录像记录缺失」警告;如果没有出现,先确认方法确实被走到、标记确实紧贴在方法上。想看更细的调用路径,就执行警告里附带的 `logReplaySafetyDetail(...)` 调用;命令形式若有变化,以控制台输出为准。
参数与顺序需要自己核对,装饰器不会替你检查这些。
diff --git a/docs/advanced/saveable-content.md b/docs/advanced/saveable-content.md
index 9aae6cd..a9e4d72 100644
--- a/docs/advanced/saveable-content.md
+++ b/docs/advanced/saveable-content.md
@@ -51,12 +51,24 @@ export interface ISaveableContent {
`loadState` 拿到的是普通数据,不是你的类实例。它要做的是把这份数据装回对象:该清空的清空,该重建的重建,不要假设旧字段还留着。商店管理器就是典型写法:
```ts
-loadState(state: IShopManagerSave, compression: SaveCompression): void {
- this.shops.clear();
- for (const save of state.list) {
- const shop: IShopState = new ShopState(save.id, this.state);
- shop.loadState(save, compression);
- this.shops.set(save.id, shop);
+import { SaveCompression } from '@user/data-common';
+import { IShopManager, IShopManagerSave, IShopState } from './types';
+import { ShopState } from './state';
+
+// 管理器节选:只保留 loadState,其余成员用注释省略
+export class ShopManager implements IShopManager {
+ private readonly shops: Map = new Map();
+
+ // ... 其他成员省略
+
+ loadState(state: IShopManagerSave, compression: SaveCompression): void {
+ this.shops.clear();
+ for (const save of state.list) {
+ // 不能把普通对象直接塞进 Map,要重新构造类实例
+ const shop: IShopState = new ShopState(save.id, this.state);
+ shop.loadState(save, compression);
+ this.shops.set(save.id, shop);
+ }
}
}
```
@@ -68,9 +80,19 @@ loadState(state: IShopManagerSave, compression: SaveCompression): void {
存档的顶层不会自动做深拷贝。 `CoreState.saveState` 只是把每个对象 `saveState` 的返回值按 id 放进一个 `Map`,不会再 `structuredClone` 一遍。因此 `saveState` 必须自己返回一个独立的新对象:不能返回 `this`,也不能让返回值里的字段与活对象共享引用。否则之后对游戏对象的修改会直接改到已保存的数据上,读档时拿到的就是被污染的值。
```ts
+import { ISaveableContent } from '@user/data-common';
+
// 错误:把活对象交给存档系统;顶层不会再复制
-saveState(): IMySave {
- return this;
+export class BadSaveable implements ISaveableContent {
+ constructor(private readonly count: number) {}
+
+ saveState(): BadSaveable {
+ return this;
+ }
+
+ loadState(state: BadSaveable): void {
+ // 这里只是让示例结构完整;真正的错误在 saveState 的返回值
+ }
}
```
@@ -95,8 +117,13 @@ saveState(): IMySave {
实现好之后,用 `state.addSaveableContent(id, content)` 把它交给存档系统:
```ts
-// [示例] 下文插件示例里的对象叫 this.store,注册 id 就沿用它
-state.addSaveableContent('@plugin/my-store', this.store);
+import { ICoreState } from '@user/data-state';
+import { MySaveable } from './state';
+
+// 在插件或初始化函数里注册;下文插件示例里的对象叫 this.store
+export function registerMyStore(state: ICoreState, store: MySaveable): void {
+ state.addSaveableContent('@plugin/my-store', store);
+}
```
`id` 是这个对象在存档里的唯一标识。整份数据端存档就是一个 `Map`,键是注册 id,值是该对象的 `saveState` 返回值。注册之后,存档时它会自动被遍历,读档时也会自动收到自己的那份数据。
@@ -176,7 +203,7 @@ export interface ISaveableExecutor {
```ts
import { IHeroAttr } from '@user/data-common';
import { IHeroStateSave, IStateBase } from '@user/data-base';
-import { ISaveableExecutor } from '@user/data-state';
+import { ICoreState, ISaveableExecutor } from '@user/data-state';
// 示例:读档后让勇士回到存档记录的楼层。
// 实际内部实现并不是这样处理的,这里只示意执行器能做什么。
@@ -192,8 +219,10 @@ export class HeroFloorExecutor implements ISaveableExecutor<
}
}
-// 绑定到勇士的存档 id
-state.bindSaveableExecuter('@system/hero', new HeroFloorExecutor());
+// 绑定到勇士的存档 id:通常写在初始化阶段
+export function bindHeroFloorExecutor(state: ICoreState): void {
+ state.bindSaveableExecuter('@system/hero', new HeroFloorExecutor());
+}
```
执行器接口的 `afterLoad` 是同步的,所以它只能「发起」异步操作;需要等待完成的流程要放在你自己的读档调用处。
@@ -256,9 +285,16 @@ export class QuestSaveable implements ISaveableContent {
引用者自己的注册与绑定如下,顺序不能反:
```ts
+import { ICoreState } from '@user/data-state';
+
// 先注册引用者,再绑定执行器
-state.addSaveableContent('@plugin/example-quest', this.quest);
-state.bindSaveableExecuter('@plugin/example-quest', new QuestSaveExecutor());
+export function registerQuest(state: ICoreState, quest: QuestSaveable): void {
+ state.addSaveableContent('@plugin/example-quest', quest);
+ state.bindSaveableExecuter(
+ '@plugin/example-quest',
+ new QuestSaveExecutor()
+ );
+}
```
执行器里按 `@plugin/shop` 取回商店管理器。`getSaveableContent` 返回的是 `ISaveableContent`,要调用管理器自己的 `getShop`,需要断言成 `IShopManager`:
@@ -356,19 +392,22 @@ export class MyPlugin implements IDataPluginDefine {
存档与读档的入口都在主对象上:`state.saveState(compression)` 返回一份 `ReadonlyMap`,键是注册 id,值是该对象的 `saveState` 结果;`state.loadState(snapshot, compression)` 按同一份映射恢复。下面的代码放进一个能拿到 `state` 的临时函数(或你插件的某个方法里)执行:
```ts
-// [验证] 存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
import { SaveCompression } from '@user/data-common';
+import { ICoreState } from '@user/data-state';
import { MyPlugin } from './plugin/my-plugin';
-const plugin = state.getDataPlugin('my-plugin')?.plugin;
-if (!plugin) return;
+// 验证:存档 → 改状态 → 读档;MyPlugin / MySaveable 换成你自己的类
+export function verifySaveLoad(state: ICoreState): void {
+ const plugin = state.getDataPlugin('my-plugin')?.plugin;
+ if (!plugin) return;
-plugin.store.setValue('count', 1);
-const snapshot = state.saveState(SaveCompression.NoCompression);
+ plugin.store.setValue('count', 1);
+ const snapshot = state.saveState(SaveCompression.NoCompression);
-plugin.store.setValue('count', 2);
-state.loadState(snapshot, SaveCompression.NoCompression);
-console.log(plugin.store.getValue('count')); // 应为 1
+ plugin.store.setValue('count', 2);
+ state.loadState(snapshot, SaveCompression.NoCompression);
+ console.log(plugin.store.getValue('count')); // 应为 1
+}
```
读档后应回到存档时的值。如果读到的是修改后的值,说明 `saveState` 返回了与活对象共享的引用,而不是独立副本。
@@ -376,18 +415,27 @@ console.log(plugin.store.getValue('count')); // 应为 1
对同一个对象把三档压缩各跑一次,可以确认三种策略读回来的结果一致:
```ts
-// [验证] 三档压缩都应恢复出同一个值
-for (const compression of [
- SaveCompression.NoCompression,
- SaveCompression.LowCompression,
- SaveCompression.HighCompression
-]) {
- plugin.store.setValue('count', 1);
- const snapshot = state.saveState(compression);
+import { SaveCompression } from '@user/data-common';
+import { ICoreState } from '@user/data-state';
+import { MyPlugin } from './plugin/my-plugin';
- plugin.store.setValue('count', 2);
- state.loadState(snapshot, compression);
- console.log(compression, plugin.store.getValue('count')); // 每次都应为 1
+// 验证:三档压缩都应恢复出同一个值
+export function verifyCompression(state: ICoreState): void {
+ const plugin = state.getDataPlugin('my-plugin')?.plugin;
+ if (!plugin) return;
+
+ for (const compression of [
+ SaveCompression.NoCompression,
+ SaveCompression.LowCompression,
+ SaveCompression.HighCompression
+ ]) {
+ plugin.store.setValue('count', 1);
+ const snapshot = state.saveState(compression);
+
+ plugin.store.setValue('count', 2);
+ state.loadState(snapshot, compression);
+ console.log(compression, plugin.store.getValue('count')); // 每次都应为 1
+ }
}
```
@@ -396,8 +444,13 @@ for (const compression of [
启动完成后还可以直接确认注册成功:
```ts
-const content = state.getSaveableContent('@plugin/my-store');
-console.log(content);
+import { ICoreState } from '@user/data-state';
+
+// 启动完成后确认注册成功
+export function checkRegistered(state: ICoreState): void {
+ const content = state.getSaveableContent('@plugin/my-store');
+ console.log(content);
+}
```
应拿到你注册的对象,而不是 `null`。为 `null` 时先检查 `addSaveableContent` 的 id 是否拼错、是否真的执行到了 `awake`。
@@ -405,11 +458,16 @@ console.log(content);
要确认缺键与多键的告警,可以故意删掉存档里的一个键。`saveState` 返回的是 `ReadonlyMap`,要删键先复制成可修改的 `Map`:
```ts
-// [验证] 删掉本对象的存档数据再读档,应告警 177
-const snapshot = state.saveState(SaveCompression.NoCompression);
-const edited = new Map(snapshot);
-edited.delete('@plugin/my-store');
-state.loadState(edited, SaveCompression.NoCompression);
+import { SaveCompression } from '@user/data-common';
+import { ICoreState } from '@user/data-state';
+
+// 验证:删掉本对象的存档数据再读档,应告警 177
+export function verifyMissingKey(state: ICoreState): void {
+ const snapshot = state.saveState(SaveCompression.NoCompression);
+ const edited = new Map(snapshot);
+ edited.delete('@plugin/my-store');
+ state.loadState(edited, SaveCompression.NoCompression);
+}
```
读档时控制台应出现缺键告警(`177`),并跳过该对象;存档里多出未注册的 id 时,应出现多键告警(`178`)。这两条能帮你区分「没存进去」和「读的时候没对上」。