diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 56da051..91b0d4c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -274,10 +274,13 @@ Plans: 3. pnpm test:ci 全绿且不新增跳过用例,数据范围 check:type / check:circular 门禁通过 4. 改动仅限数据端(packages 与 packages-user/data-*),不改动渲染端 @user/client-* 与 legacy 渲染接线,双端分离约束保持 -**Plans**: 8/8 plans executed(按 D-02 一系统一计划;每个计划以 D-09 预执行汇报关卡开头,`autonomous: false`) +**Plans**: 8/9 plans executed(按 D-02 一系统一计划;每个计划以 D-09 预执行汇报关卡开头,`autonomous: false`;07-09 为阶段标记 Complete 后追加的重开计划,尚未执行) + +> **阶段重开(2026-09-16)**:`07-LOADSTATE-AUDIT.md` 登记的同引用审计条目 `#06-17-1`(A)与 `#06-17-2`(B)在本阶段收口后追加为计划 `07-09`,Phase 7 因此由 `Complete` 回到未完成;执行 07-09 后 `07-VERIFICATION.md`(2026-09-16 结论仅覆盖 8/8 计划的工作树)**失效,必须重跑 `/gsd-verify-work`** 重新出具验证结论。 Plans: +- [ ] 07-09-PLAN.md — hero:`#06-17-1` / `#06-17-2` 同引用修复(`HeroAttribute` 自身实现 `ISaveableContent`,属性存读档在自身实例上原地完成;`IHeroStateSave` 形状变更 + 装备修饰器不入属性存档) - [x] 07-01-PLAN.md — combat:`#06-01-1` / `#06-01-2` / `#06-01-3` / `#06-01-4`(含 `#06-15-1`) - [x] 07-02-PLAN.md — enemy:`#06-03-1` 创建入口接入复用映射 - [x] 07-03-PLAN.md — replay:`#06-04-1` / `#06-04-2` / `#06-04-3` / `#06-04-4` @@ -319,6 +322,10 @@ Plans: - [x] 07-08-PLAN.md — path 顶层录像瞬移验证(用户负责接线,AI 仅取消 skip) +**Wave 9** *(blocked on Wave 8)* + +- [ ] 07-09-PLAN.md — hero 属性同引用存读档(`#06-17-1` / `#06-17-2`;`HeroAttribute` 实现 `ISaveableContent`) + ## Progress **Execution Order:** @@ -332,4 +339,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 | 4. 渲染适配与双布局 | 0/TBD | Not started | - | | 5. Legacy 移植 | 0/TBD | Not started | - | | 6. 单元测试 | 17/17 | In Progress| | -| 7. 数据端缺陷修复 | 8/8 | Complete | 2026-09-16 | +| 7. 数据端缺陷修复 | 8/9 | In Progress| | diff --git a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md index f58b47c..4aa4851 100644 --- a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md +++ b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md @@ -281,3 +281,18 @@ D-27/D-30 要求「每个公开方法至少一条正常用例」的公开接口 | 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 | | --- | --- | --- | --- | --- | --- | --- | --- | | `hero/state.ts` `HeroState.loadState` 与 `hero/equipment.ts` `HeroEquipment` 持有的 attribute 引用 | 读档后装备带来的属性加成从最终属性中消失:`HeroState.loadState` 新建并替换 `this.attribute`,而 `this.equip` 仍指向构造时传入的旧属性对象,装备修饰器被挂到被替换掉的旧对象上 | `const state = createCoreState()` → 注册 1 件装备定义(`equip.value` 为 `[['atk', 5]]`,`slots: [0]`)→ `const uid = state.hero.items.equipment.add(num)` → `state.hero.equip.equip(uid, 0)` → `state.hero.getModifiableAttribute().getFinalAttribute('atk')` 为 `5` → `const saved = state.hero.saveState(compression)` → `state.hero.loadState(saved, compression)` → 再读 `getFinalAttribute('atk')`:三个压缩档均实测为 `0`(期望仍为 `5`),同一次探针中 `getModifiableAttribute() === 旧 attribute` 为 `false`(属性对象确实被替换) | `hero/state.ts:71` 构造 `HeroEquipment(this.items.equipment, this.attribute)` 时保存了 attribute 引用,而 `HeroEquipment` 中该成员为 `private readonly`(`equipment.ts:21-26`)无法被重新指向;`HeroState.loadState`(`state.ts:177-189`)替换 `this.attribute` 后没有同步给 `equipment`,随后 `equipment.loadState` → `equip()` → `loadEquipEffect`(`equipment.ts:80-85`)把新修饰器加到了旧对象上 | 任何经 `hero.loadState` 的路径(读档、自动存档回滚、回放重置)都会丢失全部装备加成,直到属性对象被重新赋值且装备被重新挂载;此后 `HeroEquipment.equip` / `compareEquip` 也继续作用于旧属性;旧属性对象被 `HeroEquipment` 长期引用,重复读档会在其上继续累积未被卸载的装备修饰器(`equips.clear()` 不触发 `unloadEquipEffect`) | 让 `HeroEquipment` 持有 `IHeroState`/取值函数而非 attribute 实例,或在 `HeroState.loadState` 中按 `attachAttribute` 语义把新属性同步给 `equipment`(并在替换前先卸载旧属性上的装备修饰器);修复前补一条「读档后最终属性仍含装备加成」的回归用例 | 无(既有 `hero/saveLoad.test.ts` 只断言装备映射与槽位,未断言读档后的装备加成;本计划为零断言 perf 测量,探针仅临时使用后删除) | 高 | + +## #06-17 补充:读档同引用审计(种子之外) + +`#06-17-1` 之外,对数据端做了「loadState 替换实例 / 旧引用未重绑」全量只读审计,完整报告见 +`.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md`。同模式另有: + +- `#06-17-2`(高,B):`HeroState.loadState` 替换 attribute 后,`EnemyContext.bindedHero`(`data-system/combat/context.ts:87`,`core.ts:219` 接线、从不重绑)仍指向旧属性 → 战斗使用读档前勇士属性。 +- `#06-17-3`(中,H):`ReplayArray.setReplayArray`(`data-common/src/replay/array.ts:801-826`)漏 `expireStreams()`,活跃 `ReplaySandbox` 读流跨读档按陈旧偏移解码。 +- `#06-17-4`(中/低,D):`equipStore.ts:271/:278` 清空并重建 `EquipmentState`,外部持有的实例脱钩。 +- `#06-17-5`(中/低,E):`flag/system.ts:66/:68` 清空并重建 `FlagCommonField`,外部持有的字段引用脱钩。 +- `#06-17-6`(中/低,F):`hero/state.ts:190/:192` 重建 followers,缓存的 `IHeroFollower` 脱钩。 +- `#06-17-7`(低-中,C):`data-fallback/src/hero.ts:8-23` 的 `core.status.hero` Proxy 闭包持有旧属性(取决于仓外 `resetHero` 是否重发)。 +- `#06-17-8`(低,G):`mapLayer.ts:371-391` `setMapRef` 换缓冲;旧动态块未清理(`mapData.expired` 契约)。 + +处置:A/B 由 Phase 7 `07-09` 修复(同引用原则,`HeroAttribute` 自身实现 `ISaveableContent`);C–H 仅登记,待用户决定。 \ No newline at end of file diff --git a/.planning/phases/07-data-fixes/07-09-PLAN.md b/.planning/phases/07-data-fixes/07-09-PLAN.md new file mode 100644 index 0000000..7f1b618 --- /dev/null +++ b/.planning/phases/07-data-fixes/07-09-PLAN.md @@ -0,0 +1,462 @@ +--- +phase: 07-data-fixes +plan: 09 +type: execute +wave: 9 +depends_on: + - 07-08 +files_modified: + - packages-user/data-base/src/hero/types.ts + - packages-user/data-base/src/hero/attribute.ts + - packages-user/data-base/src/hero/state.ts + - packages-user/data-base/src/hero/equipment.ts + - packages-user/data-base/src/hero/attribute.test.ts + - packages-user/data-base/src/hero/saveLoad.test.ts + - packages-user/data-state/test/saveablesRoundTrip.test.ts + - packages-user/data-state/test/dataClosure.test.ts +autonomous: false +requirements: + - FIX-01 +estimate: + tokens: 62000 + raw_tokens: 62000 + tasks: 4 + confidence: low +must_haves: + truths: + - "读档后最终属性仍含装备加成:三档压缩下 `hero.getModifiableAttribute().getFinalAttribute('atk')` 等于读档前的值(`#06-17-1` 由 5 变 0 的缺陷消失)" + - "`hero.getModifiableAttribute()` 在读档前后是同一实例(`===` 成立),属性对象不再被 `loadState` 替换" + - "`HeroEquipment` 构造期捕获的属性引用始终有效:读档后再 `equip`/`unequip` 产生的加成落在活属性上,`getFinalAttribute` 随 `add`/`set` 同步" + - "战斗侧:`enemyContext.getBindedHero()` 与 `hero.getModifiableAttribute()` 同实例,读档后读到的是活属性而非读档前属性(`#06-17-2`)" + - "`HeroAttribute` 自身实现 `ISaveableContent>`:`saveState` 深拷贝基础值并枚举保存启用的修饰器状态,`loadState` 在自身实例上重置基础值、清空并重建修饰器、重算 final" + - "`HeroState.saveState` 委托 `this.attribute.saveState(...)`,`IHeroStateSave` 不再含顶层 `modifiers` 字段;`HeroState.loadState` 不再 `new HeroAttribute`,也不再给 `this.attribute` 赋值" + - "装备修饰器不进入属性存档:即使其类型已在修饰器注册表中注册,三档压缩往返后 atk 增量也不翻倍" + - "`pnpm test:ci` 全绿,且本计划不新增任何 `it.skip`、不弱化任何既有断言" + artifacts: + - path: packages-user/data-base/src/hero/types.ts + provides: "新存档类型 `IHeroAttributeSave`(`values` + `modifiers`);`IHeroAttribute` 扩展 `ISaveableContent>` 与修饰器工厂注册表成员;`IHeroStateSave.attribute` 改为 `IHeroAttributeSave` 并移除顶层 `modifiers`" + - path: packages-user/data-base/src/hero/attribute.ts + provides: "修饰器工厂注册表(按 Task 0 裁决归属);`saveState` 深拷贝基础值 + 保存启用修饰器枚举;`loadState` 原地重置基础值、清空并重建修饰器、重算 final(实例身份不变)" + - path: packages-user/data-base/src/hero/state.ts + provides: "`saveState` 委托属性存档、`loadState` 委托属性原地读档且不再替换 `this.attribute`;`registerModifier`/`createModifier`/`createAndInsertModifier` 委托属性注册表" + - path: packages-user/data-base/src/hero/equipment.ts + provides: "`loadEquipEffect` 以 `save = false` 挂载装备修饰器,使装备加成只经 `HeroEquipment.loadState` 的重新装备恢复" + - path: packages-user/data-base/src/hero/attribute.test.ts + provides: "`HeroAttribute` 自身存读档的原地性、三档恢复、存档禁用修饰器与实例身份回归" + - path: packages-user/data-base/src/hero/saveLoad.test.ts + provides: "`#06-17-1` 三档回归(读档后装备加成仍在、属性对象同实例、读档后再装备仍作用于活属性)与存档形状迁移后的既有断言" + - path: packages-user/data-state/test/dataClosure.test.ts + provides: "`#06-17-2` 回归:顶层 `CoreState` 经 `hero.loadState` 后 `enemyContext.getBindedHero()` 仍与活属性同实例且反映读档值" + key_links: + - "`HeroState.loadState` 的 `this.attribute.loadState(...)`(原地)↔ `HeroEquipment` 构造期捕获的 `attribute`(`equipment.ts:21-23`、`state.ts:71`)——同实例即同引用,装备加成不再落到孤儿属性" + - "`HeroAttribute.saveState` 的 `getModifierSaveEnabled` 过滤(原 `state.ts:150-157`)↔ `loadEquipEffect` 的 `addModifier(name, modifier, false)`(`equipment.ts:83`)——装备修饰器排除在属性存档外" + - "`HeroState.loadState` 属性 → `items.loadState` → `equip.loadState`(`state.ts:186-189`)的调用序 ↔ `equipment.ts:336-346` 清空 equips 后重新 `equip`,装备修饰器在属性读档之后重新施加到同一实例" + - "修饰器工厂注册表(现 `state.ts:41-44`)↔ `HeroAttribute.loadState` 的修饰器重建(按 `save.type` 取工厂、`cons()`、`modifier.loadState(save.state, compression)`)" + - "`EnemyContext.bindedHero`(`data-system/src/combat/context.ts:87`,`core.ts:219` 一次性 `bindHero(heroAttribute)`)↔ 原地读档后属性实例不变" + - "`IHeroStateSave.attribute` 的新形状 ↔ `saveablesRoundTrip.test.ts:254` 的快照分离断言(`heroSave.attribute.values.hp`)与 `saveLoad.test.ts:549` 的存档禁用断言(`saved.attribute.modifiers`)" + assumptions: + - "A1(D-10 例外,本计划唯一):本计划为用户显式授权的修复计划,允许为 `#06-17-1` / `#06-17-2` 新增回归用例(含三档压缩与「注册表命中不翻倍」对照);不得为 C–H(`#06-17-3..8`)新增用例,也不得为让套件转绿而改写既有断言(存档形状迁移导致的两处字段路径改动除外)" + - "A2(项目未发布、无向后兼容要求,与 07-03 的 A10 同口径):`IHeroStateSave` / `IHeroAttributeSave` 形状可自由变更,不保留任何旧形状兼容分支,不新增存档版本字段" + - "A3(同引用原则,用户已裁决):`HeroAttribute` 在自身实例上完成存读档,使 `HeroEquipment.attribute` 与 `EnemyContext.bindedHero` 这类长生命周期引用跨读档始终有效;A/B 两缺陷同根因、同修" + - "A4(装备加成归属装备子系统):装备修饰器由 `HeroEquipment.loadState` 重新装备恢复,不进属性存档,与 `equipment.ts:341` 既有注释「装备修饰器不进存档」一致;这是 A/B 修复后不产生重复计入的必要条件" + - "A5(相邻缺口,只登记不修):`attachAttribute`(`state.ts:90-92`)仍会替换属性实例、从而再次让 `HeroEquipment` 的捕获引用脱钩;本计划只登记" + - "A6(相邻缺口,只登记不修):`HeroAttribute.clone()`(`attribute.ts:257-271`)未把 `modifierName` / `modifierNosave` 复制到克隆对象,克隆属性上的 `getModifierIndex` / `markModifierDirty` 对克隆修饰器不生效" + - "A7(注册表归属为 Task 0 裁决项):Task 1–3 的 action 以【选项 A】为默认写法;若用户裁决为 B 或 C,按 Task 0 `` 中逐条列出的差异执行,其余步骤不变" + prohibitions: + - "不修复 C–H(`#06-17-3` replay `setReplayArray` 漏 `expireStreams`、`#06-17-4` `equipStore` 重建实例脱钩、`#06-17-5` `flag/system` 重建字段脱钩、`#06-17-6` followers 重建脱钩、`#06-17-7` `data-fallback` hero Proxy、`#06-17-8` `mapLayer.setMapRef` 换缓冲):这六条对应文件一行不动,只在 SUMMARY 登记" + - "不改 `data-system` 的战斗生产代码:不重绑 `EnemyContext.bindedHero`、不加 `DamageSystem`/`CombatFlow` 的重新绑定钩子——B 靠同引用原则自然成立" + - "不修改渲染端 `@user/client-*`、不改 legacy 渲染接线、不改 `packages-user/data-fallback`" + - "不引入新依赖、不新建文件、不新增错误码、不新增 warn/error 调用(未知修饰器类型沿用既有静默跳过语义)" + - "不弱化断言、不改写为可跑绿假象;不新增 `it.skip`;除存档形状迁移导致的两处字段路径外,既有断言一字不改" + - "不新增 `import type`、不使用连续 `as` 断言;jsDoc 写在接口源头(`types.ts`),实现处不重复" + - "不修改本计划 `files_modified` 之外的源码;`AttributePerf` / `saveables.perf.ts` / `saveablesReal.perf.ts` 等零断言测量文件不改" +--- + + +按「同引用原则」修复 `HeroState.loadState` 替换属性实例导致的长生命周期引用脱钩缺陷:**`HeroAttribute` 自身实现 `ISaveableContent>`**,属性存读档在**同一实例上原地完成**,从而 `HeroEquipment.attribute`(`#06-17-1`,A)与 `EnemyContext.bindedHero`(`#06-17-2`,B)的引用跨读档始终有效。 + +`#06-17-1`(高):`HeroState.loadState` 用 `new HeroAttribute(...)` 替换 `this.attribute`(`state.ts:177/185`),而 `HeroEquipment` 在构造期以 `private readonly` 捕获了旧实例(`equipment.ts:21-23`、`state.ts:71`),其 `equip()` / `loadState` 仍向旧实例 `addModifier`(`equipment.ts:83`)。结果:读档后装备加成写进孤儿属性,`getFinalAttribute('atk')` 由 5 变 0。 +`#06-17-2`(高):`core.ts:219` 用原始属性一次性 `enemyContext.bindHero(heroAttribute)` 且此后从不重绑,读档后战斗读到的是读档前的旧属性。 + +Purpose: 属性是勇士数值的唯一事实源,装备、Buff、战斗计算都通过它读取;实例被替换会让「读档后数值正确性」取决于谁持有引用,任何绕过 getter 的持有者都会静默读到过期数据。同引用原则把这类缺陷从根因上消除,而不是逐个补重绑调用点。 + +Output: `hero/types.ts` 新增 `IHeroAttributeSave` 并调整 `IHeroStateSave` 形状(顶层 `modifiers` 合并进属性存档);`hero/attribute.ts` 实现自身存读档(原地);`hero/state.ts` 改为委托且不再替换属性;`hero/equipment.ts` 令装备修饰器不进属性存档;`#06-17-1` / `#06-17-2` 的回归用例(三档压缩 + 战斗侧同引用);Phase 7 重开登记与 C–H 未修登记。 + +**本计划重开已标记 `Complete` 的 Phase 7**:`07-VERIFICATION.md`(2026-09-16)在 07-09 执行前即已失效,执行后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`)重新出具验证结论;`ROADMAP.md` 的 Phase 7 计划数、Wave 列表与进度表已同步标注为未完成。 + +执行序:本计划在 07-08 之后串行执行(`depends_on: 07-08`,对应 Wave 9)。`depends_on` 表示 D-09 逐步用户确认与 D-12/D-44 全量门禁的串行约束,源码层面与 path 系统无耦合。Task 0 为 D-09 预执行汇报关卡并承载两项待裁决事项,未获用户确认前不得进入 Task 1。 + +Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 9 个计划,承接 07-LOADSTATE-AUDIT 的 `#06-17-1`(A)/`#06-17-2`(B)。D-02: 属 hero 系统(`packages-user/data-base/src/hero`)。D-03: 其它系统契约裁决,不涉及。D-04: 其它系统契约裁决,不涉及。D-05: 其它系统契约裁决,不涉及。D-06: 其它系统契约裁决,不涉及。D-07: 其它系统契约裁决,不涉及。D-08: 其它系统契约裁决,不涉及。D-09: 由 Task 0 的预执行汇报关卡承载。D-10: 本计划为其唯一例外(A1,经用户授权为 `#06-17-1`/`#06-17-2` 新增回归用例)。D-11: 形状变更后同步 jsDoc 与 `equipment.ts` 注释。D-12: 沿用 06-CONTEXT D-44 的文件级三步门禁。D-13: 按根因族原子提交(Task 1 / Task 2 / Task 3)。D-14: `#06-17-1`/`#06-17-2` 无 WINDOWS.md 条目,不新建、只登记对照。 + + + +@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md +@C:/Users/book/.config/opencode/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/WINDOWS.md +@.planning/phases/07-data-fixes/07-CONTEXT.md +@.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md +@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md +@.planning/phases/07-data-fixes/07-VALIDATION.md +@.planning/phases/07-data-fixes/07-VERIFICATION.md +@dev.md +@packages-user/data-common/src/save/types.ts +@packages-user/data-base/src/hero/types.ts +@packages-user/data-base/src/hero/attribute.ts +@packages-user/data-base/src/hero/state.ts +@packages-user/data-base/src/hero/equipment.ts + + + + +Phase 7 既有裁决与本计划的对应关系(逐条引用,便于 D-01..D-14 覆盖核对): + +- **D-01**(修复范围):本计划是 Phase 7 的第 9 个计划,承接 07-LOADSTATE-AUDIT 登记的 `#06-17-1`(A)与 `#06-17-2`(B);20 条原始清单不受影响。 +- **D-02**(按系统切分 PLAN):本计划属 hero 系统(`packages-user/data-base/src/hero`),是既有 8 个系统计划的补充计划。 +- **D-03 / D-04 / D-05 / D-06 / D-07 / D-08**(其它系统的契约裁决:combat 战前脚本语义、map 码 128、save 码 178、hero 码 147 保留、path 用户接线、enemy 复用映射):本计划不涉及,相关文件一行不动。 +- **D-09**(每个计划执行前向用户汇报方案并获确认;失败即退出并修订计划):由 Task 0 的 `checkpoint:decision` 承载,未确认前不得进入 Task 1。 +- **D-10**(测试处置:只取消 skip、不额外新增用例):本计划的唯一例外为 A1——经用户显式授权为 `#06-17-1` / `#06-17-2` 新增回归用例(Task 0 汇报第 ⑥ 条待确认);C–H 一律不新增用例。 +- **D-11**(文档/注释同步):`IHeroStateSave` / `IHeroAttributeSave` 形状变更后同步 `types.ts` 的 jsDoc 与 `equipment.ts:341` 的注释,使文档与实现一致。 +- **D-12**(门禁沿用 06-CONTEXT D-44 的文件级三步):每个任务收尾执行 eslint / vue-tsc(按文件过滤)/ `pnpm test:ci`。 +- **D-13**(按缺陷/根因原子提交):Task 1 属性自持存读档、Task 2 `#06-17-1` 根因闭合、Task 3 `#06-17-2` 回归与门禁各成原子提交。 +- **D-14**(WINDOWS.md 收口):`#06-17-1` / `#06-17-2` 在 `WINDOWS.md` 中**无条目**(07-LOADSTATE-AUDIT 与 06-TEST-FINDINGS 均未登记账本 id),故不新建条目、不做 `fixed`/`waive` 动作,改为在 SUMMARY 记录 findings↔ledger 对照。 +- **06-CONTEXT D-43**(阶段化):本计划按 4 个阶段化任务推进(裁决记录 → 属性自持存读档 → 容器委托与装备归档 → 战斗侧回归与门禁),前序未完成不得进入后续。 +- **06-CONTEXT D-44**:门禁与验证节奏的判定口径(见 D-12 行)。 + + + Task 0: D-09 预执行汇报 —— 同引用原则修复方案 + 修饰器注册表归属裁决 + 装备修饰器是否进属性存档 + (只读汇报,不修改任何文件) + + - packages-user/data-base/src/hero/attribute.ts(:48-63 私有字段与构造器、:159-251 修饰器处理与存取开关、:257-279 克隆与结构化输出) + - packages-user/data-base/src/hero/state.ts(:41-44 注册表、:71 装备接线、:90-92 attachAttribute、:106-130 注册表方法、:148-195 saveState/loadState) + - packages-user/data-base/src/hero/equipment.ts(:21-26 捕获属性、:76-96 loadEquipEffect/unloadEquipEffect、:190 装备时施加、:336-346 loadState) + - .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(A / B 行、触发序列、处置) + - packages-user/data-common/src/save/types.ts(:12-25 ISaveableContent 契约) + + +按 D-09,执行前向用户汇报修复方案与两项待裁决事项,获确认后才进入 Task 1。汇报内容如下(**历史记录 + 待裁决**): + +① **根因与修法(同引用原则,用户已裁决方向)**:`HeroState.loadState` 目前 `new HeroAttribute(...)` 替换实例(`:177/185`),而 `HeroEquipment` 构造期以 `private readonly` 捕获旧实例(`:21-23`、`state.ts:71`)、`core.ts:219` 又一次性把旧实例绑给 `enemyContext`。修法为**让属性自己在原实例上存读档**:`HeroAttribute implements ISaveableContent>`,`loadState` 原地重置基础值 → 清空并重建修饰器 → 重算 final;`HeroState.loadState` 改为调用 `this.attribute.loadState(state.attribute, compression)` 且**不再给 `this.attribute` 赋值**。所有长生命周期引用因此天然有效,不需要任何重绑调用点。同时修复 A(`#06-17-1`)与 B(`#06-17-2`)。 + +② **存档形状变更(A2,项目未发布,无向后兼容要求)**:新增 `IHeroAttributeSave = { values: THero; modifiers: readonly IModifierStateSave[] }`;`IHeroStateSave.attribute` 由 `THero` 改为 `IHeroAttributeSave`,并删除顶层 `modifiers`(即把现在的 `attribute` + `modifiers` 两个字段合并到属性自己的存档里)。不保留旧形状兼容分支、不新增存档版本字段。**这是持久化格式变更**,请确认。 + +③ **【待裁决 1,关键】修饰器工厂注册表归属**。现状:注册表在 `HeroState`(`state.ts:41-44`,`Map IHeroModifier>`),`registerModifier`/`createModifier`/`createAndInsertModifier`(`:106-130`)属于 `IHeroState`;`HeroAttribute.loadState` 现在也需要它来重建修饰器。三个选项: +- **选项 A(推荐)**:把注册表移入 `HeroAttribute`(`private readonly registry`),`IHeroAttribute` 新增 `registerModifier` / `createModifier` / `createAndInsertModifier`(含既有 `logger.warn(116)` 未知类型分支,原样搬运),`HeroState` 的三个方法**委托**给 `this.attribute`,`IHeroState` 的公开签名不变(既有调用点 `state.hero.registerModifier(...)` 全部不受影响)。理由:与「属性拥有自己的修饰器」一致,属性存读档自洽,不需要跨对象传工厂。已知代价:`IHeroAttribute` 接口变大;`clone()` 需把注册表条目复制给克隆对象;`attachAttribute` 换上无注册表的新属性时需要决定是否转移注册记录(见 ④)。 +- **选项 B**:注册表仍由 `HeroState` 拥有,通过构造参数或 `useRegistry(...)` 注入到 `HeroAttribute`(共享同一个 Map),`clone()` 传递同一引用;`attachAttribute` 后由 `HeroState` 重新注入。理由:注册动作的唯一入口仍在 `HeroState`;代价:同一 Map 有两个写入方(`HeroState` 注册 / 属性读取),属性离开 `HeroState` 后注册表为空(脱离宿主即不可用),`attachAttribute` 需额外接线才不空。 +- **选项 C**:注册表留在 `HeroState`、`HeroAttribute` 只负责值状态:`IHeroAttribute.loadState(state, compression, factory?)` 增加一个可选的修饰器工厂解析参数(保持与 `ISaveableContent` 结构兼容),由 `HeroState.loadState` 传入解析器;**或**退化为「`HeroState` 驱动重建 + 属性提供原地原语」。理由:改动面最小、注册表归属不变;代价:**偏离用户已锁定的「属性自身存读档」方向**——单独调用 `attribute.loadState(state, compression)`(不传工厂)时修饰器不会恢复,属性实例的存读档不自洽,需要在文档中明写该限定。 + +④ **选项 A 下的 `attachAttribute` 子问题(若选 A 需一并答复)**:`attachAttribute`(`state.ts:90-92`)会替换属性实例,替换后新属性注册表为空、`createModifier` 会报 116。两种处置:(i) `HeroState` 保留一份「注册记录」并在 `attachAttribute` 时重放到新属性(代价:注册表信息两处登记);(ii) 明确 `attachAttribute` 为遗留逃生口、不转移注册表,并把它登记为相邻缺口(A5),由后续计划决定是否废弃该方法。**默认采用 (ii)**;注意 `attachAttribute` 本身仍会破坏 `HeroEquipment` 的捕获引用,本计划不修(A5)。 + +⑤ **【待裁决 2】装备修饰器是否进属性存档**。现状:`loadEquipEffect` 以 `addModifier(name, modifier, true)`(`equipment.ts:83`)挂载装备修饰器,因此它们**会**进入 `state.modifiers` 存档;读档时若其类型不在注册表(生产环境注册表为空)则被静默跳过,再由 `equip.loadState` 重新装备恢复,表面上无重复。**但原地读档后两侧会落到同一属性实例上**:若某个装备修饰器类型恰好已在注册表中注册(例如 `@system/value`、`@system/percentage`,测试夹具 `saveablesReal.perf.ts:361` 就同时注册了 `@system/value` 并装备了 4 件装备),属性读档会重建一份、`equip.loadState` 再挂一份,加成**翻倍**。修法(推荐):把 `loadEquipEffect` 的第三个实参由 `true` 改为 `false`,使装备修饰器不进属性存档——这与 `equipment.ts:341` 既有注释「装备修饰器不进存档」一致,且装备子系统有自己的存档负责恢复。请确认;若否决,需另选去重方案(本计划不建议)。 + +⑥ **回归覆盖(A1 授权,D-10 例外)**:新增 `#06-17-1` 三档回归(读档后 `atk` 仍含装备加成、`getModifiableAttribute()` 同实例、读档后再装备仍作用于活属性)、「注册表命中不翻倍」对照、`#06-17-2` 战斗侧回归(`enemyContext.getBindedHero()` 与活属性同实例并反映读档值)。这是修复计划,测试为必需项;不对 C–H 新增用例。 + +⑦ **不做的事(范围守卫)**:C–H(`#06-17-3..8`)只登记不修;不改 `data-system` 战斗代码(不重绑 `bindedHero`);不改渲染端与 `data-fallback`;不新建文件、不新增依赖、不新增错误码。 + +⑧ **失败纪律(D-09)**:任一任务方案失败必须退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法。 + +**待用户答复**:③(A/B/C 与 ④ 的 (i)/(ii))、⑤(同意/否决)、②(存档形状变更确认)、⑥(新增回归用例授权确认)。答复后写入下方 `` 并直接进入 Task 1。 + + 同引用原则修复方案的范围与两项裁决:修饰器工厂注册表归属(A/B/C)与装备修饰器是否排除出属性存档(推荐排除) + + `#06-17-1`(A,高)与 `#06-17-2`(B,高)同根因:`HeroState.loadState` 替换属性实例,而 `HeroEquipment`(`private readonly`)与 `EnemyContext`(一次性 `bindHero`)持有旧实例。用户已裁决方向为「同引用原则」:`HeroAttribute` 自身实现 `ISaveableContent`,存读档在自身实例上原地完成。剩余待定的是注册表归属(决定 `IHeroAttribute` 接口是否变大、`attachAttribute` 如何处理)与装备修饰器的存档归属(决定是否出现重复计入)。两者都影响接口/存档内容,属用户主导的接口设计范畴,故在执行前裁决。 + + + + + + + + + + 待用户答复后填写(执行者须原样记录用户选择,不得自行代选): + 1. 注册表归属裁决 = (A / B / C) + 2. 选项 A 的 `attachAttribute` 处置 = ((i) 转移注册记录 / (ii) 登记缺口,默认 (ii)) + 3. 装备修饰器不进属性存档 = (同意 / 否决),若否决记录用户指定的替代方案 + 4. 存档形状变更(`IHeroStateSave.attribute: IHeroAttributeSave`,删除顶层 `modifiers`)= (确认 / 修订) + 5. 回归用例新增授权(A1,D-10 例外)= (授权 / 限定范围) + + 若裁决为 B 或 C,Task 1–3 的差异(其余步骤不变): + - 选项 B:Task 1 在 `HeroAttribute` 增加 `private registry: Map<...>` 与 `useRegistry(registry)`(或构造参数)而非新增 `registerModifier`/`createModifier`/`createAndInsertModifier`;`IHeroAttribute` 只新增 `useRegistry`;Task 2 中 `HeroState` 保留自己的 `registry` 字段并在构造器内 `attribute.useRegistry(this.registry)`、`attachAttribute` 时对新属性再次注入;`clone()` 把同一注册表引用传给克隆对象。 + - 选项 C:Task 1 不新增注册表成员,`loadState(state, compression, factory?)` 增加可选工厂解析参数(签名保持与 `ISaveableContent` 结构兼容),并在 `types.ts` 的 jsDoc 中明写「不传工厂时修饰器不恢复」;Task 2 中 `HeroState.loadState` 传入 `type => this.registry.get(type) ?? null`,`saveState` 不变;`IHeroState` 的 `registerModifier`/`createModifier`/`createAndInsertModifier` 实现留在 `HeroState`(本计划不动)。 + + 用户已答复 ③④⑤②⑥ —— 执行者把答复原文写入 `` 后直接进入 Task 1,不再重复询问;若答复与 `` 中记录的差异表不符,暂停并请用户澄清。若用户选择 `revise`,退出本计划并修订 PLAN.md。 + + 用户已确认:注册表归属(A/B/C 及 A 下的 (i)/(ii))、装备修饰器是否排除出属性存档、存档形状变更、回归用例新增授权 + 未获用户确认即进入 Task 1,或 `` 与用户实际答复不符,或执行者自行代选注册表归属 + + + - D-09 预执行汇报已完成:根因、修法、存档形状变更、两项待裁决、范围守卫与失败纪律均已陈述 + - 用户对 ③(注册表归属 + `attachAttribute` 处置)、⑤(装备修饰器归档)、②(形状变更)、⑥(回归授权)的答复已逐条写入 `` + - 若裁决非选项 A,`` 中对应的差异表已明确列出 Task 1–3 需要替换的具体步骤 + - 执行者据 `` 直接进入 Task 1,不再重复询问 + + 用户裁决已完整记录(注册表归属 + `attachAttribute` 处置 + 装备修饰器归档 + 形状变更确认 + 回归授权),执行者据此进入 Task 1 + + + + Task 1: `HeroAttribute` 实现 `ISaveableContent` —— 新存档类型 `IHeroAttributeSave` + 原地 `saveState` / `loadState` + 注册表(按 Task 0 裁决) + packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/attribute.ts, packages-user/data-base/src/hero/attribute.test.ts + + - packages-user/data-base/src/hero/attribute.ts(全文件::48-63 字段与构造器、:80-102 `recalculateAttribute`、:159-163 `iterateModifiers`、:180-201 `addModifier`、:241-251 存档开关、:257-279 `clone`/`toStructured`) + - packages-user/data-base/src/hero/types.ts(:16-68 `IHeroModifier`/`IModifierStateSave`、:75-149 `IReadonlyHeroAttribute`、:151-221 `IHeroAttribute`) + - packages-user/data-base/src/hero/state.ts(:41-44 现注册表、:106-130 现注册表方法、:148-171 现 `saveState` 的修饰器枚举循环——该循环原样搬入 `HeroAttribute.saveState`) + - packages-user/data-common/src/save/types.ts(:12-25 `ISaveableContent`:`saveState(compression): T` / `loadState(state: T, compression): void`) + - packages-user/data-base/src/hero/attribute.test.ts(:1-130 既有夹具与用例风格、`TestModifier`、`createAttribute`) + + +让勇士属性自己承担存读档(同引用原则的核心一步)。**默认按 Task 0 裁决的【选项 A + 排除装备修饰器】书写;若裁决为 B 或 C,按 Task 0 `` 的差异表替换注册表相关步骤,其余步骤不变。** + +(1) `hero/types.ts` 新增存档类型(jsDoc 写在接口源头,中文,写明「基础属性值 + 保存启用的修饰器状态」;成员之间按功能换行): +- `export interface IHeroAttributeSave`,成员两个:`readonly values: THero;`(勇士基础属性值,即未经过任何 Buff 或装备加成的属性,须为深拷贝)、`readonly modifiers: readonly IModifierStateSave[];`(保存启用的属性修饰器状态)。复用既有 `IModifierStateSave`(:61-68),不改它的形状。 + +(2) `hero/types.ts` 让 `IHeroAttribute` 同时实现存档契约:声明改为 `extends IReadonlyHeroAttribute, ISaveableContent>`(`ISaveableContent` 已从 `@user/data-common` 引入,确认顶部 import 列表无需新增)。**不要**把存读档加到 `IReadonlyHeroAttribute`(战斗侧只读视图不应暴露 `loadState`)。 + +(3) `hero/types.ts` 按【选项 A】给 `IHeroAttribute` 增加三个注册表成员(jsDoc 中文,签名与 `IHeroState` 现有的一份逐字一致,便于 Task 2 委托): +- `registerModifier(type: string, cons: () => IHeroModifier): void`(注册一个修饰器工厂函数) +- `createModifier(type: string): IHeroModifier | null`(创建指定类型的修饰器实例) +- `createAndInsertModifier(type: string, name: K): IHeroModifier | null`(创建并插入至本属性,默认进入存档) + +(4) `hero/attribute.ts` 按【选项 A】把注册表搬进来: +- 新增字段 `private readonly registry: Map() => IHeroModifier> = new Map();`,jsDoc 与 `state.ts:40` 一致。 +- 从 `state.ts:106-130` **原样搬入** `registerModifier` / `createModifier` / `createAndInsertModifier`(保留 `logger.warn(116, type)` 未知类型分支与返回 `null` 的语义、保留 `this.attribute.addModifier(name, modifier)` 的默认 `save = true`);搬入后把 `createAndInsertModifier` 内的 `this.attribute.addModifier(...)` 改为 `this.addModifier(...)`(属性自身即宿主)。 +- 按 dev.md(私有方法放在调用它的方法之前、处于合理 region)把这三个方法放进「修饰器处理」region,并为其补 `#region` 内位置;实现处**不重复**写 jsDoc(接口源头已写)。 +- `clone()`(:257-271)新增:创建 `cloned` 后把注册表条目复制过去——遍历 `this.registry` 逐条 `cloned.registry.set(type, cons)`(类内可访问同类的 `private` 成员)。**不改**既有行为:仍不复制 `modifierName` / `modifierNosave`(A6,相邻缺口只登记)。 + +(5) `hero/attribute.ts` 实现 `saveState(compression: SaveCompression): IHeroAttributeSave`: +- `values`:`this.toStructured()`(既有实现即 `structuredClone(this.attribute)`,满足 `ISaveableContent` 的深拷贝要求)。 +- `modifiers`:把 `state.ts:149-157` 的循环原样搬入(`for (const [name, modifier] of this.iterateModifiers())`,`if (!this.getModifierSaveEnabled(modifier)) continue;`,push `{ name: name as keyof THero, type: modifier.type, state: modifier.saveState(compression) }`)。 +- jsDoc 由接口提供,实现处不重复;需要时用单行注释说明压缩参数透传给修饰器。 + +(6) `hero/attribute.ts` 实现 `loadState(state: IHeroAttributeSave, compression: SaveCompression): void`,**必须原地修改、实例身份不变**,顺序: +- (a) 基础值原地重置:先对 `this.attribute` 的自有键中「不存在于 `state.values` 的键」执行 `delete`(用 `Object.keys(this.attribute)` 与 `Object.prototype.hasOwnProperty.call(state.values, key)` 判定,避免误删 `Object.prototype` 键);再把 `structuredClone(state.values)` 的每个自有键赋回 `this.attribute`。**不得**给 `this.attribute` 或 `this.finalAttribute` 重新赋值(两者都是 `private readonly`,只改其成员)。 +- (b) 清空修饰器簿记:`this.modifier.clear()`、`this.modifierName.clear()`、`this.modifierNosave.clear()`。 +- (c) 重建修饰器:遍历 `state.modifiers`,`const cons = this.registry.get(save.type);`,`if (!cons) continue;`(**保持既有 `state.ts:179-180` 的静默跳过语义,不新增错误码/告警**),随后 `const modifier = cons(); modifier.loadState(save.state, compression); this.addModifier(save.name, modifier);`。 +- (d) 重算 final:对 `Object.keys(this.attribute)` 的每个键调用私有 `this.recalculateAttribute(name as keyof THero)`(`:80-102`;无修饰器时会把 final 写回基础值,同时清掉被移除修饰器的陈旧 final,避免 `#06-05-1` 类问题)。 +- jsDoc:接口已声明,实现处只保留必要的单行注释(例如说明「原地修改以保持同引用」)。 + +(7) `hero/attribute.test.ts` 新增 `describe('HeroAttribute same-reference save and load')`,每个 `it` 前写中文单行注释(dev.md:85)。覆盖: +- 三档压缩(`SaveCompression.NoCompression` / `LowCompression` / `HighCompression` 循环)下:构造 `createAttribute()`、挂一个已注册修饰器(`attribute.registerModifier('@test/value', () => new TestModifier(5))` + `attribute.createAndInsertModifier('@test/value', 'atk')`)、`attribute.set('hp', 88)`,`const saved = attribute.saveState(compression)`;随后打乱活状态(`attribute.set('hp', 1)`、`deleteModifier('atk', ...)` 或直接 `attribute.getModifiers('atk')` 中删除),再 `attribute.loadState(saved, compression)`;断言基础值 `hp === 88`、`atk` 的修饰器恢复且 `getFinalAttribute('atk')` 与读档前一致。 +- 实例身份:`const before = attribute;`(或记下 `attribute.toStructured()` 之外的引用)在 `loadState` 后断言 `attribute` 仍是同一对象(`expect(attribute).toBe(before)` 配合 `getFinalAttribute` 已恢复),并断言 `attribute.toStructured()` 与 `saved.values` 值相等但**不同引用**(`expect(attribute.toStructured()).not.toBe(saved.values)`)。 +- 存档禁用:`setModifierSaveEnabled(modifier, false)` 后 `saveState(...)` 的 `modifiers` 不含它,`loadState` 后该修饰器不存在(与既有 `saveLoad.test.ts:541-558` 同一语义,但在属性层直接验证)。 +- 快照独立:`saved.values` 与活基础属性分离——`attribute.set('hp', 1)` 后 `saved.values.hp` 仍为 88。 + +不做的事:不改 `IReadonlyHeroAttribute`;不改 `toStructured` 的返回语义;不改 `catchCalculateProgress` / 告警码 108/109 行为;不新增错误码;不动 `state.ts` / `equipment.ts`(Task 2 负责);不改 `attribute.perf.ts`。 + +验证:`pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts` 全绿 → 随后 D-12/D-44 文件级门禁(`pnpm exec eslint --fix <本任务改动文件>` 后 `pnpm exec eslint <本任务改动文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 按本任务改动文件路径过滤 0 类型错误)。提交信息:`feat(07-09): make HeroAttribute own its save state in place`。 + + + pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts + 非零退出,或摘要行出现 "failed"(原地读档未恢复基础值/修饰器、实例身份丢失、存档禁用修饰器被恢复、快照与活对象未分离,或既有修饰器用例回归),或输出出现 "no tests found" + + + - `hero/types.ts` 存在 `IHeroAttributeSave`(`values: THero` + `modifiers: readonly IModifierStateSave[]`),且 `IHeroAttribute` 声明继承 `ISaveableContent>`;`IReadonlyHeroAttribute` 未被加入存读档成员 + - `hero/attribute.ts` 存在 `saveState(compression): IHeroAttributeSave`(`values` 为深拷贝、`modifiers` 按 `getModifierSaveEnabled` 过滤)与 `loadState(state, compression): void`;`loadState` 中不出现 `new HeroAttribute`、不给 `this.attribute` / `this.finalAttribute` 赋值 + - `loadState` 依次完成基础值原地重置、`modifier`/`modifierName`/`modifierNosave` 清空、按注册表重建修饰器、逐键 `recalculateAttribute` + - 按裁决(默认选项 A)`HeroAttribute` 拥有注册表并实现 `registerModifier`/`createModifier`/`createAndInsertModifier`(未知类型仍 `logger.warn(116)` 并返回 `null`),`clone()` 复制注册表条目且不改变既有克隆行为 + - `hero/attribute.test.ts` 新增三档压缩恢复、实例身份、存档禁用与快照独立四类回归,每个 `it` 前有中文单行注释;既有断言未被改动 + - `pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts` 全绿;本任务改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误 + + `HeroAttribute` 自身实现原地存读档(新存档类型 `IHeroAttributeSave`、深拷贝 values、保存启用修饰器枚举、原地重置与重建),`attribute.test.ts` 三档与同实例回归转绿;已一个原子提交入库 + + + + Task 2: `HeroState` 委托 + 原地读档 + 存档形状更新 + 装备修饰器不入属性存档(`#06-17-1` 根因闭合) + packages-user/data-base/src/hero/state.ts, packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/saveLoad.test.ts + + - packages-user/data-base/src/hero/state.ts(:148-195 saveState/loadState 现状、:88-130 属性相关与注册表方法) + - packages-user/data-base/src/hero/types.ts(:786-801 现 `IHeroStateSave`、:803-874 `IHeroState`) + - packages-user/data-base/src/hero/equipment.ts(:76-96 loadEquipEffect/unloadEquipEffect、:329-346 saveState/loadState、:341 既有注释) + - packages-user/data-base/src/hero/saveLoad.test.ts(:508-559 `HeroState save and load round trips`、:561-609 容器覆盖与 `#06-09-2` 用例、:66-205 夹具 `createState`/`createBaseAttr`/`createEquipItem`/`registerItem`/`createEquipEnv`/`createHeroState`) + - packages-user/data-base/src/hero/attribute.ts(Task 1 产出的 `saveState`/`loadState` 签名,导入路径与调用方式) + + +把 `HeroState` 从「替换实例」改为「委托原地」,并让装备修饰器脱离属性存档。**默认按 Task 0 裁决的【选项 A + 排除装备修饰器】书写;若裁决为 B/C 或否决 ⑤,按 Task 0 `` 执行对应差异。** + +(1) `hero/types.ts` 的 `IHeroStateSave`(:786-801): +- 把 `readonly attribute: THero;` 改为 `readonly attribute: IHeroAttributeSave;`(jsDoc 相应改为「勇士属性状态,含基础属性值与保存启用的修饰器」)。 +- **删除** `readonly modifiers: readonly IModifierStateSave[];` 及其 jsDoc(修饰器状态已并入属性存档)。这是持久化形状的破坏性变更(A2:项目未发布,无兼容要求),SUMMARY 必须显式登记。 +- 复核 `IModifierStateSave` 仍被 `IHeroAttributeSave` 使用,import 不产生未使用告警。 + +(2) `hero/state.ts` 的 `saveState`(:148-171): +- 删除本地 `const modifiers: IModifierStateSave[] = [];` 与 `:150-157` 的枚举循环。 +- 返回对象的 `attribute` 改为 `this.attribute.saveState(compression)`,删除 `modifiers` 字段;其余字段(`location`/`rendering`/`followers`/`items`/`equip`)保持原样与原有顺序。 +- 从 import 列表移除已不再使用的 `IModifierStateSave`。 + +(3) `hero/state.ts` 的 `loadState`(:173-195): +- 删除 `const newAttribute = new HeroAttribute(state.attribute);` 与 `:178-184` 的修饰器重建循环。 +- 改为 `this.attribute.loadState(state.attribute, compression);`——**属性原地读档**。 +- **不得**出现对 `this.attribute` 的赋值(`this.attribute = ...`):`state.ts:185` 的 `this.attribute = newAttribute;` 必须消失;`getModifiableAttribute()` / `getAttribute()` 的返回值在 `loadState` 前后必须为同一实例。 +- 保持其余调用序不变:`location.loadState` → `rendering.loadState` → `items.loadState` → `equip.loadState` → `followers.removeAllFollowers()` + 逐个 `addFollower` + `follower.loadState`。属性读档必须在 `items.loadState`/`equip.loadState` 之前(装备修饰器靠 `equip.loadState` 的重新装备恢复)。 +- 从 import 列表移除已不再使用的 `HeroAttribute` 与 `IModifierStateSave`(保留 `IHeroAttribute` 等仍在用的类型)。 + +(4) `hero/state.ts` 的注册表方法(按选项 A):删除 `:40-44` 的 `private readonly registry` 字段,`registerModifier` / `createModifier` / `createAndInsertModifier`(:106-130)改为一行委托到 `this.attribute.*`(保留 jsDoc 在 `IHeroState` 源头、保留返回类型与 `null` 语义)。`IHeroState` 的公开签名不变,既有调用点(含 `state.test.ts:134-146` 的 `@system/value` 与码 116 用例)应零改动通过。 +- `attachAttribute`(:90-92)保持替换语义不动,按 Task 0 `` 的 (i)/(ii) 执行;默认 (ii):不转移注册表,并在 SUMMARY 登记 A5 缺口。 + +(5) `hero/equipment.ts` 的 `loadEquipEffect`(:80-85):把 `this.attribute.addModifier(name, modifier, true)` 的第三个实参改为 `false`,并补中文单行注释说明原因:「装备修饰器由 `HeroEquipment.loadState` 的重新装备恢复,故不进入属性存档,避免与属性读档的重建重复计入」。同步复核 `equipment.ts:341` 的既有注释与实现现在一致(若不准确一并修正文案)。**不**改 `unloadEquipEffect`、不改 `equip`/`unequip`/`compareEquip`/`saveState`/`loadState` 的其它逻辑。 + +(6) `hero/saveLoad.test.ts` 的形状迁移与回归: +- 既有断言纠偏(形状迁移,唯一允许的既有断言改动):`:549` 的 `expect(saved.modifiers).toHaveLength(1)` 改为 `expect(saved.attribute.modifiers).toHaveLength(1)`。其余断言(`:510-538`、`:553-557`、`:563-585`、`:589-609`)一律不动。 +- 新增 `describe('HeroState same-reference attribute load (#06-17-1)')`,每个 `it` 前写中文单行注释(A1 授权): + - 「三档压缩读档后装备加成仍在且属性对象同实例」:用既有夹具 `createEquipEnv()` + `registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]))`(`createBaseAttr()` 的 `atk` 为 10,故 `atk` 最终为 15)构造 `HeroState`、`hero.equip.setSlots(['weapon'])`、`uid = hero.items.equipment.add(10)`、`hero.equip.equip(uid, 0)`;断言 `getFinalAttribute('atk') === 15`;`const attrBefore = hero.getModifiableAttribute();`;`const saved = hero.saveState(compression)`;打乱活状态(`hero.equip.unequip(0)`、`hero.getModifiableAttribute().set('atk', 1)`);`hero.loadState(saved, compression)`;断言 `hero.getModifiableAttribute()` **是** `attrBefore`(`toBe`)、`getFinalAttribute('atk')` 回到 15、`getEquipped(0)` 仍为 `uid`、基础 `atk` 回到 10。 + - 「读档后装备仍作用于活属性」:承接上一 `it`,在 `loadState` 之后再 `hero.equip.unequip(0)` 并断言 `getFinalAttribute('atk')` 回到 10(证明 `HeroEquipment` 捕获的引用是活属性),再 `hero.equip.equip(uid, 0)` 断言回到 15。 + - 「注册表命中时装备加成不翻倍」:额外 `hero.registerModifier('@system/value', () => new ValueModifier(5))` 并 `hero.createAndInsertModifier('@system/value', 'atk')`(英雄侧 +5),再装备同一件 `[['atk', 5]]` 装备;三档压缩往返后断言 `getFinalAttribute('atk') === 20`(基础 10 + 英雄 5 + 装备 5,**不是** 25),且 `getModifiers('atk')` 的修饰器数量为 2。 + +不做的事:不改 `EnemyContext` / `DamageSystem` / 战斗代码;不改 `HeroEquipment` 的捕获方式(保持 `private readonly`);不改 `equipStore.ts`(`#06-17-4` 只登记);不改 `follower` / `items` 的 `loadState`;不新增错误码;不新增 `it.skip`。 + +验证:`pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/state.test.ts packages-user/data-base/src/hero/equipment.test.ts` 全绿(重点确认 `#06-09-2` 容器用例、码 116 注册表用例、`attachAttribute` 用例不回归)→ D-12/D-44 文件级门禁。提交信息:`fix(07-09): #06-17-1 load hero attribute in place and keep equipment bonuses`。 + + + pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/state.test.ts packages-user/data-base/src/hero/equipment.test.ts + 非零退出,或摘要行出现 "failed"(读档后装备加成丢失或翻倍、`getModifiableAttribute()` 非同实例、注册表委托后码 116 用例回归、`attachAttribute` 用例回归),或输出出现 "no tests found" + + + - `hero/state.ts` 的 `loadState` 中不出现 `new HeroAttribute`,也不出现 `this.attribute =`;属性读档通过 `this.attribute.loadState(state.attribute, compression)` 完成,且位于 `items.loadState`/`equip.loadState` 之前 + - `hero/state.ts` 的 `saveState` 的 `attribute` 字段为 `this.attribute.saveState(compression)`,返回值不再含顶层 `modifiers`;`IModifierStateSave`/`HeroAttribute` 的未使用 import 已移除(eslint 0 错误可证) + - `hero/types.ts` 的 `IHeroStateSave` 为 `attribute: IHeroAttributeSave` 且顶层 `modifiers` 已删除;`IHeroAttributeSave` 的 jsDoc 说明与实现一致 + - `hero/equipment.ts` 的 `loadEquipEffect` 以 `addModifier(name, modifier, false)` 挂载并带中文原因注释;`:341` 注释与实现一致 + - `saveLoad.test.ts:549` 已迁移为 `saved.attribute.modifiers`,其余既有断言一字未改;新增三条回归(同实例 + 加成保留、读档后装备作用于活属性、注册表命中不翻倍)且每个 `it` 前有中文单行注释 + - 三个既有 hero 测试文件全绿;本任务改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误 + + 读档后属性实例不变、装备加成保留且不翻倍,`#06-17-1` 根因闭合;`IHeroStateSave` 形状变更已落地并登记;已一个原子提交入库 + + + + Task 3: `#06-17-2` 战斗侧回归 + 顶层形状迁移 + D-12/D-44 门禁 + 全量套件 + 缺口登记与 Phase 7 重开说明 + packages-user/data-state/test/saveablesRoundTrip.test.ts, packages-user/data-state/test/dataClosure.test.ts, packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/attribute.ts, packages-user/data-base/src/hero/state.ts, packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/attribute.test.ts, packages-user/data-base/src/hero/saveLoad.test.ts + + - packages-user/data-state/test/saveablesRoundTrip.test.ts(:239-256 快照分离断言、:1-60 夹具与压缩常量) + - packages-user/data-state/test/dataClosure.test.ts(:1-30 导入、:180-191 现有 hero 往返用例、:46-90 `vi.hoisted` 与 `REPLAY_COMMAND_ORDER`) + - packages-user/data-state/test/saveablesReal.perf.ts(:238-278 `createItemRaw` / `registerItem` 配方:`tileStore.addTile({ num, id, events: {}, type: TileType.Item, pass: { onlyEvents: false, inPass: 15, outPass: 15 }, eventPass: true })` + `itemStore.addItem(item)`;:331-352 `equipment.add` + `equip.equip` 的顺序) + - packages-user/data-state/src/core.ts(:182-184 属性构造、:219 `enemyContext.bindHero(heroAttribute)`、:248 `@system/hero` 注册) + - packages-user/data-system/src/combat/context.ts(:87 `bindedHero`、:193-202 `bindHero`/`getBindedHero`、:209-220 handler 取 `this.bindedHero`) + - .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(B 行与触发序列 B)、.planning/phases/06-unit-tests/06-TEST-FINDINGS.md(`#06-17` 补充小节 `C–H` 清单) + + +补齐战斗侧回归(B),完成顶层形状迁移,跑门禁与全量套件,并把未修项登记清楚。 + +(1) `packages-user/data-state/test/saveablesRoundTrip.test.ts:254` 的形状迁移:`expect(heroSave.attribute.hp).toBe(88)` 改为 `expect(heroSave.attribute.values.hp).toBe(88)`(`IHeroStateSave.attribute` 现为 `IHeroAttributeSave`)。`:250-252` 的类型断言与 `:255` 的地图断言不动;该文件的其它断言一律不改。 + +(2) 在 `packages-user/data-state/test/dataClosure.test.ts` 新增 `describe('CoreState hero attribute same-reference load (#06-17-2)')`,每个 `it` 前写中文单行注释(A1 授权)。所需导入按需补齐(`ItemCategory`、`TileType`、`IItemRawData` 等,与 `saveablesReal.perf.ts:1-40` 同源;文件顶部现有 `vi.hoisted` 的 `Map.prototype.getOrInsert` 垫片必须保留)。用例内容: +- 装备注册与装配:按 `saveablesReal.perf.ts:268-278` 的配方注册一件装备(`num` 取未被占用的数值,`slots: [0]`,`equip.value` 为 `new Map([['atk', 5]])`、`equip.percentage` 为 `new Map()`),`state.hero.equip.setSlots(['weapon'])`,`const uid = state.hero.items.equipment.add(num)`,`state.hero.equip.equip(uid, 0)`。注意 `createCoreState()` 的 `HERO_DEFAULT_ATTRIBUTE.atk` 为 `0`,故装备后 `atk` 最终为 `5`(与 `#06-17-1` 探针记录的 5 → 0 一致)。 +- 「读档前战斗侧与活属性同实例」:`const bound = state.enemyContext.getBindedHero();`、`const attr = state.hero.getModifiableAttribute();`,断言 `bound` 与 `attr` 同一实例(`toBe`)且 `attr.getFinalAttribute('atk') === 5`。注释说明 `core.ts:219` 的 `bindHero` 只发生一次。 +- 「读档后战斗侧无需重绑即读到活属性」(`#06-17-2`):`const saved = state.hero.saveState(SaveCompression.NoCompression);`;打乱(`state.hero.equip.unequip(0)`、`state.hero.getModifiableAttribute().set('atk', 1)`);`state.hero.loadState(saved, SaveCompression.NoCompression)`;断言 `state.enemyContext.getBindedHero()` **是** `state.hero.getModifiableAttribute()`(无任何重绑调用)且其 `getFinalAttribute('atk')` 为 5。 +- 「三档压缩下战斗侧读到同一活属性」:对 `NoCompression` / `LowCompression` / `HighCompression` 各跑一遍上述往返,断言 `getBindedHero() === getModifiableAttribute()` 且 `getFinalAttribute('atk') === 5`(每档用独立的 `createCoreState()` 与独立装备实例,避免跨档互相污染)。 + +(3) 门禁(D-12 沿用 D-44 文件级判定,对 Task 1–3 的全部改动文件串行执行):`pnpm exec eslint --fix <改动文件...>` 后 `pnpm exec eslint <改动文件...>` 0 错误;`pnpm exec vue-tsc --noEmit` 后按改动文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。 + +(4) 在 SUMMARY 显式登记(不得静默丢弃): +- **修复内容**:`#06-17-1`(A)与 `#06-17-2`(B)按同引用原则闭合——`HeroAttribute implements ISaveableContent>`,`loadState` 原地完成;`HeroState.loadState` 不再替换实例;`EnemyContext.bindedHero` / `HeroEquipment.attribute` 无需重绑即始终有效。 +- **形状变更**:`IHeroStateSave.attribute: THero` → `IHeroAttributeSave`(`values` + `modifiers`),顶层 `modifiers` 移除;`hero/equipment.ts` 的装备修饰器改为不进属性存档。A2:项目未发布、无向后兼容要求、不保留兼容分支;对仓外已存在的旧存档不承诺可读。 +- **账本对照**:`#06-17-1` / `#06-17-2` 在 `.planning/WINDOWS.md` 中**无对应条目**(`Select-String` 复核 `WINDOWS.md` 无 `06-17`),按 07-03 的处置先例**不新建条目**(新增会抬高 `open_count` 门禁),逐条注明「无 ledger entry」;`WINDOWS.md` id 28(`saveablesReal.perf.ts` deviation)与本计划无关,保持 `open`。 +- **未修的 C–H 清单**(只登记不修,附各自文件与现状):`#06-17-3`(`data-common/src/replay/array.ts:801-826` `setReplayArray` 漏 `expireStreams`)、`#06-17-4`(`hero/equipStore.ts:271/:278` 重建实例脱钩)、`#06-17-5`(`flag/system.ts:66/:68` 重建字段脱钩)、`#06-17-6`(`hero/state.ts:190/:192` followers 重建脱钩)、`#06-17-7`(`data-fallback/src/hero.ts:8-23` Proxy 闭包持旧属性,是否触发取决于仓外 `resetHero`)、`#06-17-8`(`map/mapLayer.ts:371-391` `setMapRef` 换缓冲)。 +- **相邻缺口(只登记不修)**:A5 `attachAttribute` 仍会替换属性实例并让 `HeroEquipment` 的捕获引用脱钩(本计划保语义不动);A6 `HeroAttribute.clone()` 未复制 `modifierName`/`modifierNosave`。 +- **Phase 7 重开说明**:本计划在 Phase 7 标记 `Complete`(2026-09-16)之后追加,阶段验证因此**失效**——`07-VERIFICATION.md` 的结论(含「8/8 plans」「orphaned requirements: none」等)不再覆盖当前工作树,执行完成后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`)重新出具结论;`07-VALIDATION.md` 的 Per-Task Verification Map 未登记 `#06-17-1` / `#06-17-2`,由该次重验证一并处理(本计划不改该文件)。 +- **门禁结果**:eslint / vue-tsc / `pnpm test:ci` 的实测结论,以及 `pnpm test:ci` 的用例计数(改前为 66 文件 / 680 passed / 1 skipped,新增用例后计数应上升、skipped 不得上升)。 + +不做的事:不放宽 `pnpm test:ci`;不新增 `it.skip`;不改 `.planning/WINDOWS.md`(不新建条目、不打 `fixed`——无对象可结清);不改 `07-VALIDATION.md` / `07-VERIFICATION.md`(留给重验证环节;本计划只写 SUMMARY);不改零断言 perf 文件。 + +验证:`pnpm test:ci` 全绿且 skipped 数不增加 → `git status` 最终仅含本计划产物。提交信息:`test(07-09): #06-17-2 combat-side same-reference hero attribute regression`(门禁与 SUMMARY 可并入同一提交或按其根因族拆分,按 D-13)。 + + + pnpm test:ci + 非零退出,或摘要行出现 "failed"(战斗侧同引用回归失败、顶层形状迁移遗漏导致既有用例失败、或任一既有用例回归),或 skipped 数量较改前上升,或 `git status` 含本计划 files_modified 之外的源码改动 + + + - `saveablesRoundTrip.test.ts` 的快照分离断言已迁移为 `heroSave.attribute.values.hp`,其余断言未改 + - `dataClosure.test.ts` 新增 `#06-17-2` 回归:读档前 `getBindedHero()` 与活属性同实例、读档后仍同实例且 `getFinalAttribute('atk')` 为 5(三档压缩),且全程无重绑调用 + - `pnpm exec eslint --fix` + `pnpm exec eslint` 对全部改动文件 0 错误;`pnpm exec vue-tsc --noEmit` 按改动文件路径过滤 0 类型错误 + - `pnpm test:ci` 全绿,skipped 数不增加,本计划未新增 `it.skip`、未弱化既有断言 + - SUMMARY 已登记:A/B 修复、存档形状变更(A2)、`#06-17-1`/`#06-17-2` 无 ledger entry 的对照、C–H 六条未修清单、相邻缺口 A5/A6、Phase 7 重开与 `07-VERIFICATION.md` 失效须重验证、门禁与用例计数实测结果 + + `#06-17-1` / `#06-17-2` 各自有可跑的回归见证(属性层三档 + 战斗侧三档),顶层形状迁移完成,D-12/D-44 三步与全量套件通过,未修项与 Phase 7 重开说明全部登记于 SUMMARY + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| 存档数据 → 属性读档 | 存档中的基础属性值与修饰器类型是不受信输入:类型可能未注册、键集合可能与当前 `THero` 形状不一致 | +| 属性子系统 ↔ 装备子系统 | 两者都会向同一个属性实例挂载修饰器;归属划分不清就会重复计入数值 | +| 长生命周期持有者 ↔ 属性实例 | `HeroEquipment`(构造期捕获)、`EnemyContext`(一次性绑定)持有属性引用,实例被替换即静默读到过期数据 | +| 包管理器 → 仓库 | 本计划无安装动作 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-7-13 | Tampering | `hero/attribute.ts` 的 `loadState` 原地重置 | high | mitigate | Task 1 的基础值重置只改 `this.attribute` 的成员:先删除不在 `state.values` 的自有键(`Object.prototype.hasOwnProperty.call` 判定,避免原型链误删),再逐键赋入 `structuredClone(state.values)`;不对 `private readonly` 引用整体重新赋值,最终属性与基础属性对象身份保持不变 | +| T-7-14 | Tampering | `hero/attribute.ts` 的修饰器重建 | medium | mitigate | Task 1 重建前 `clear()` 三个簿记容器,遍历 `state.modifiers` 时仅接受注册表命中的 `type`(未命中静默跳过,与既有 `state.ts:179-180` 同语义、不新增错误码),并在重建后逐键 `recalculateAttribute` 使 final 不含被移除修饰器的陈旧值 | +| T-7-15 | Tampering | 装备加成重复计入 | high | mitigate | Task 2 让 `loadEquipEffect` 以 `save = false` 挂载装备修饰器,装备加成只经 `HeroEquipment.loadState` 的重新装备恢复;Task 2 新增「注册表命中时加成不翻倍」三档回归(英雄 5 + 装备 5 → 20 而非 25)锁定该不变量 | +| T-7-16 | Tampering | 持久化形状破坏性变更 | low | accept | `IHeroStateSave.attribute` 改为 `IHeroAttributeSave`、顶层 `modifiers` 移除(A2);项目未发布、无向后兼容要求,不保留兼容分支、不引入版本字段,但在 SUMMARY 显式登记,且约定对仓外旧存档不承诺可读 | +| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本计划不安装任何包(`package.json` / `pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 | + + + +- `pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts` 全绿(属性层三档恢复、实例身份、存档禁用、快照独立) +- `pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/state.test.ts packages-user/data-base/src/hero/equipment.test.ts` 全绿(含 `#06-09-2`、码 116 与 `attachAttribute` 既有用例不回归) +- `pnpm exec vitest run packages-user/data-state/test/dataClosure.test.ts packages-user/data-state/test/saveablesRoundTrip.test.ts` 全绿(`#06-17-2` 战斗侧同引用 + 顶层形状迁移) +- `pnpm test:ci` 全绿且 skipped 数不增加;本计划不新增 `it.skip` +- 全部改动文件:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 按改动文件路径过滤 0 类型错误 +- `git status` 最终仅含本计划 `files_modified` 内的文件改动与 SUMMARY +- SUMMARY 可见:A/B 修复记录、存档形状变更(A2)、`#06-17-1`/`#06-17-2` 无 ledger entry 对照、C–H 六条未修清单、相邻缺口 A5/A6、Phase 7 重开与 `07-VERIFICATION.md` 失效须重验证 + + + +1. `#06-17-1` 闭合:三档压缩下 `hero.loadState(saved, compression)` 后 `getFinalAttribute('atk')` 与读档前一致(装备加成不再丢失),且 `getModifiableAttribute()` 读档前后为同一实例 +2. `#06-17-2` 闭合:`hero.loadState` 之后 `enemyContext.getBindedHero()` 与 `hero.getModifiableAttribute()` 同实例并反映读档值,全程无任何重绑调用 +3. `HeroAttribute implements ISaveableContent>`:`saveState` 深拷贝基础值 + 枚举保存启用修饰器,`loadState` 原地重置并重建,实例身份与 final 一致性均由回归锁定 +4. `HeroState.saveState` 委托属性存档、`loadState` 委托属性原地读档且不再替换实例;`IHeroStateSave` 形状变更(`attribute: IHeroAttributeSave`、无顶层 `modifiers`)已落地并在 SUMMARY 登记 +5. 装备修饰器不进属性存档,注册表命中时加成不翻倍(回归对照通过) +6. C–H(`#06-17-3..8`)与相邻缺口(`attachAttribute`、`clone` 簿记)明确登记为未修,未被静默忽略;渲染端、`data-fallback`、`data-system` 战斗代码、零断言 perf 文件一行未动 +7. Phase 7 重开已登记:`ROADMAP.md` 计划数/Wave/进度状态更新,SUMMARY 说明 `07-VERIFICATION.md` 结论失效并须重跑验证 +8. 门禁三步与 `pnpm test:ci` 全过,提交按 D-13 的根因族粒度入库 + + +## Artifacts this phase produces + +| 类型 | 路径 | 内容 | +|------|------|------| +| 生产源码(修改) | `packages-user/data-base/src/hero/types.ts` | 新增 `IHeroAttributeSave`(`values` + `modifiers`);`IHeroAttribute` 扩展 `ISaveableContent` 与注册表成员;`IHeroStateSave.attribute` 改为 `IHeroAttributeSave` 并移除顶层 `modifiers` | +| 生产源码(修改) | `packages-user/data-base/src/hero/attribute.ts` | 修饰器工厂注册表(默认选项 A);`saveState` 深拷贝基础值 + 保存启用修饰器枚举;`loadState` 原地重置基础值、清空并重建修饰器、逐键重算 final;`clone()` 复制注册表条目 | +| 生产源码(修改) | `packages-user/data-base/src/hero/state.ts` | `saveState` 委托 `attribute.saveState`;`loadState` 委托 `attribute.loadState` 且不再替换 `this.attribute`;注册表方法委托属性;移除未使用 import | +| 生产源码(修改) | `packages-user/data-base/src/hero/equipment.ts` | `loadEquipEffect` 以 `save = false` 挂载装备修饰器(装备加成只经重新装备恢复) | +| 测试(修改) | `packages-user/data-base/src/hero/attribute.test.ts` | 属性自身存读档:三档恢复、实例身份、存档禁用、快照独立 | +| 测试(修改) | `packages-user/data-base/src/hero/saveLoad.test.ts` | `#06-17-1` 三档回归(加成保留 + 同实例 + 读档后再装备)+ 注册表命中不翻倍对照 + 一处形状迁移断言 | +| 测试(修改) | `packages-user/data-state/test/dataClosure.test.ts` | `#06-17-2` 战斗侧同引用回归(三档) | +| 测试(修改) | `packages-user/data-state/test/saveablesRoundTrip.test.ts` | 快照分离断言迁移到 `attribute.values` | +| 追踪(修改,编排层负责) | `.planning/ROADMAP.md` | Phase 7 计划列表加入 07-09、计划数 8/9、新增 Wave 9、进度表状态改回未完成 | +| 摘要 | `.planning/phases/07-data-fixes/07-09-SUMMARY.md` | 同引用修法记录、形状变更(A2)、无 ledger entry 对照、C–H 未修清单、相邻缺口 A5/A6、Phase 7 重开与验证失效说明、门禁与用例计数 | + + +Create `.planning/phases/07-data-fixes/07-09-SUMMARY.md` when done + diff --git a/.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md b/.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md new file mode 100644 index 0000000..963a961 --- /dev/null +++ b/.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md @@ -0,0 +1,58 @@ +# loadState 同引用(identity/alias)审计 + +**日期:** 2026-09-16 +**范围:** 数据端 `packages-user/data-{common,base,system,state}`(含 `packages` 中数据端依赖) +**性质:** 只读探索记录,未修改任何代码 +**种子缺陷:** `#06-17-1`(`06-TEST-FINDINGS.md`) + +## 背景 + +`HeroState.loadState` 用 `new HeroAttribute(...)` 替换属性实例(`state.ts:177/185`),而 `HeroEquipment` 在构造期捕获了**旧**属性实例(`state.ts:71` → `equipment.ts:23`,`private readonly`),其 `equip()`/`loadState` 仍向旧实例 `addModifier`(`equipment.ts:83`)。结果:读档后装备加成落到已丢弃的属性上,`getFinalAttribute('atk')` 由 5 → 0。 + +## 方法 + +枚举所有 `loadState(` 实现及 `clear`/`destroy`/`removeAll`/`setMapRef`/`setReplayArray`/`fromRaw` 等重建点;对每个被替换字段,搜索此前持有它的对象(构造期 `private readonly`、`new X(this, field)`、`bind*`/`use*`/`register*`、以身份为键的 Map/Set/WeakMap、闭包/回调、hooks/listener 列表);判断持有者是「重读活字段」还是「持旧引用」;并检查同包 `*.test.ts` 覆盖。 + +## 发现 + +| ID | 替换点 | 被替换字段 | 陈旧持有者 | 触发 | 症状 | 置信 | 现有测试 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| **A**(种子) | `hero/state.ts:185` | `HeroState.attribute` | `HeroEquipment.attribute`(`equipment.ts:23`,`state.ts:71` 捕获) | `hero.loadState` / `CoreState.loadState` | 装备加成写进孤儿属性 → `getFinalAttribute` 5→0 | 高 | 无(`hero/saveLoad.test.ts:588-609` 只断言 `getEquipped`/`slots`) | +| **B** | `hero/state.ts:185` | `HeroState.attribute` | `EnemyContext.bindedHero`(`data-system/combat/context.ts:87`,`core.ts:219` 用原始属性接线,之后从不重绑);连带 `DamageSystem.heroStatus`(`damage.ts:36`) | `hero.loadState` | 战斗读**读档前**的旧勇士属性(`context.ts:217` → `enemy/calculator.ts:22-25`、`final.ts:12-13`、`mapDamage.ts:88`) | 高 | 无 | +| **H** | `replay/array.ts:801-826` `setReplayArray` | `commandBuffer/paramBuffer/indexArray` 及视图 | 活跃 `ReplaySandbox.reader`(`sandbox.ts:43` 捕获偏移) | `ReplaySystem.loadState:106` | `setReplayArray` 漏 `expireStreams()`(其它 5 处写路径都有),活跃读流按陈旧偏移解码 | 中 | 无(跨 load 的活跃读流未见用例) | +| **D** | `hero/equipStore.ts:271/:278` | 全部 `EquipmentState` 实例 | 外部从 `get`/`getEquips`/`instancesOf` 持有的 `IEquipmentState` | `HeroState.loadState` → `items.loadState` | 保留实例与活 store 脱钩 | 中(类)/低(仓内) | 部分(只测按 uid 重取) | +| **E** | `flag/system.ts:66/:68` | 全部 `FlagCommonField` 实例 | 持有 `getField`/`getOrInsert` 结果的调用方 | `FlagSystem.loadState`(`@system/flags`) | 写入保留字段丢失、读到旧值 | 中/低 | 部分(用例均重取) | +| **F** | `hero/state.ts:190/:192` | 整个 `followers[]` 及每个 follower 的 `location`/`rendering` | 缓存的 `IHeroFollower`、其 hooks | `HeroState.loadState` | 旧 follower 引用指向被移除对象、mover 实例更换 | 中/低 | 无(只测同实例 `follower.loadState`) | +| **C** | `hero/state.ts:185` | `HeroState.attribute` | `data-fallback/src/hero.ts:8-23` 的 `core.status.hero` Proxy(闭包持有 `getModifiableAttribute()`) | `HeroState.loadState`(若 legacy load 未重发 `resetHero`) | 旧引擎写入落到孤儿属性 | 低-中 | 无 | +| **G** | `map/mapLayer.ts:371-391` `setMapRef` | `mapArray`/`IMapLayerData`/`StaticTile` 缓存(`loadDynamics` 亦不清旧动态块) | 持 `getMapRef()` 者、旧 `StaticTile`/`DynamicTile` | `MapState.loadState` → `GameMap.loadState` → `MapLayer.loadState` | `mapData.expired` 契约、实例脱钩、旧动态块累积 | 低 | 覆盖 load,不覆盖外部引用 | + +## 触发序列(举例) + +**A:** 构造 `HeroState`(属性 `A0`)→ `equip(uid, slot)`(atk+5,写入 `A0`)→ `saveState()` → `loadState(saved)` → `HeroState.attribute` 变 `A1`,`equip.loadState` 仍写 `A0` → `getFinalAttribute('atk')` = 基础值(5→0)。 + +**B:** `createCoreState()`(`core.ts:219` 用原始 `A0` 绑定 `enemyContext`)→ 修改/装备 → `hero.loadState()` → `enemyContext.getBindedHero()` 仍是 `A0`(`!== hero.attribute`)→ 伤害计算用读档前属性。 + +**H:** `replaySystem.createReplaySandbox(cfg)` → `replaySystem.loadState(saved)` → `sandbox.step()`:reader 未被 expire,按旧偏移解码。 + +## 判定为「安全」的同类边界 + +- `HeroLocation` / `HeroRendering` / `HeroFollower.loadState`:原地修改。 +- `HeroItems.loadState`:只清填 map,`equipment` 字段不换,`HeroEquipment.store` 保持有效(其内部实例被替换 → 见 D)。 +- `HeroState` 的 getter:重读 `this.attribute`。 +- `GameMap` / `MapState.loadState`:原地改已有实例;`finder.useMapState(this.maps)`、`DefaultPassPredicateImpl` 持有的 `MapState` 稳定。 +- `EnemyManager.loadState`:原地改 prefab;身份键注册表未重分配。 +- `Enemy.loadState`:全部经 accessor 读取,未发现裸属性对象的外部持有者(但 `EnemyManager.loadState` 改的是 prefab,`EnemyContext` 持有的是 `createEnemy()` 克隆 → 独立逻辑缺口,不属本类)。 +- `StaticTile` / `DynamicTile.loadState`:原地修改。 +- `CombatFlow.bindHero`:同 B 的捕获模式,但 `CombatFlow` 仅测试实例化,属潜在 API 隐患而非在役。 + +## 未能从阅读确定(未猜测) + +1. 仓外旧引擎 `core.loadData` 读档后是否重发 `resetHero`(决定 C 严重度)。 +2. 渲染端/legacy 插件是否跨读档保留 D/E/F/G 的实例引用。 +3. 活跃 `ReplaySandbox` 读流是否会在真实流程中跨 `loadState`(H)。 +4. `EnemyContext` 的生产填充点(全仓无测试外的 `setEnemyAt`),影响 B 的爆炸半径。 + +## 处置 + +- **A / B**:由 Phase 7 新增计划 `07-09` 按「同引用原则」修复——`HeroAttribute` 自身实现 `ISaveableContent`,属性存读档在其实例上原地完成,从而 `HeroEquipment` 与 `EnemyContext.bindedHero` 的引用始终有效。 +- **C–H**:本文件仅记录;是否修复由用户决定。