docs(phase-07): plan plan set 07-10..07-14 for registered-not-fixed data defects

This commit is contained in:
unanmed 2026-09-16 21:27:10 +08:00
parent f97c76966b
commit 10f88628cd
6 changed files with 1920 additions and 3 deletions

View File

@ -278,12 +278,20 @@ Plans:
3. pnpm test:ci 全绿且不新增跳过用例,数据范围 check:type / check:circular 门禁通过
4. 改动仅限数据端(packages 与 packages-user/data-*),不改动渲染端 @user/client-* 与 legacy 渲染接线,双端分离约束保持
**Plans**: 9/9 plans executed(按 D-02 一系统一计划;每个计划以 D-09 预执行汇报关卡开头,`autonomous: false`;07-09 为阶段标记 Complete 后追加的重开计划,尚未执行)
**Plans**: 14 plans — 07-01..07-09 已执行(07-09 为阶段标记 Complete 后追加的重开计划,已执行并有 SUMMARY);07-10..07-14 为第二轮追加(把 C–H 与 CR-01/CR-02 由「只登记」转为「修复」,**尚未执行**)(按 D-02 一系统一计划;每个计划以 D-09 预执行汇报关卡开头,`autonomous: false`)
> **阶段重开(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`** 重新出具验证结论。
> **阶段二次追加(2026-09-16)**:`07-LOADSTATE-AUDIT.md` 的同引用审计条目 **C–H**(`#06-17-3..8`)、`07-REVIEW.md` 的 **CR-01 / CR-02** 与 4 条相关警告(WR-01/02/03/07、WR-04/05/06)、以及 `06-TEST-FINDINGS.md` 中此前「只登记不修」的部分,经用户裁定**由「登记」转为「修复」**,追加为计划 `07-10`(replay 编解码与索引编辑)、`07-11`(容器同引用存读档)、`07-12`(装备/属性存档正确性)、`07-13`(地图失效边界)、`07-14`(legacy hero 代理)。07-09 的范围守卫(「C–H 只登记不修」)据此**解除**。
> 每个计划以 `checkpoint:decision`(`gate="blocking-human"`)关卡开头,逐条列出需用户裁决的契约点(`set()` 语义、录像格式版本、溢出处置、诊断码、逐子系统同引用保留、读档禁录、失效契约等);**契约未裁决前不得执行**。执行完毕后 `07-VERIFICATION.md` 必须重跑。
Plans:
- [ ] 07-10-PLAN.md — replay:CR-01(`set()` 索引数组损坏,与 `delete()` 对齐)/ 审计 H `#06-17-3`(`setReplayArray` 漏 `expireStreams`)/ WR-01(bigint 长度字节溢出)/ WR-02(参数计数用未截断长度)/ WR-03(编解码格式版本)/ WR-07(`insert`/`delete`/`set` 越界校验)
- [ ] 07-11-PLAN.md — 容器同引用:`#06-17-4`(`equipStore` 重建实例脱钩)/ `#06-17-5`(`flag/system` 字段脱钩)/ `#06-17-6`(followers 重建脱钩)
- [ ] 07-12-PLAN.md — 装备/属性存档正确性:WR-04(装备修饰器活值未持久化)/ WR-05(`deleteModifierByIndex` 簿记残留)/ WR-06(`HeroEquipment.loadState` 读档写录像)
- [ ] 07-13-PLAN.md — 地图:CR-02(`MapDamage` 幽灵伤害:来源消失/范围收缩后缓存残留)/ `#06-17-8`(`MapLayer.setMapRef` 失效契约 + 读档旧动态块累积)
- [ ] 07-14-PLAN.md — legacy:`#06-17-7`(`data-fallback` `core.status.hero` 代理闭包持有读档前属性)
- [x] 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` 创建入口接入复用映射
@ -328,7 +336,27 @@ Plans:
**Wave 9** *(blocked on Wave 8)*
- [ ] 07-09-PLAN.md — hero 属性同引用存读档(`#06-17-1` / `#06-17-2`;`HeroAttribute` 实现 `ISaveableContent`)
- [x] 07-09-PLAN.md — hero 属性同引用存读档(`#06-17-1` / `#06-17-2`;`HeroAttribute` 实现 `ISaveableContent`)
**Wave 10** *(blocked on Wave 9;用户裁决 Task 0 契约后执行)*
- [ ] 07-10-PLAN.md — replay 编解码与索引编辑(CR-01 / `#06-17-3` / WR-01 / WR-02 / WR-03 / WR-07)
**Wave 11** *(blocked on Wave 10)*
- [ ] 07-11-PLAN.md — 容器同引用存读档(`#06-17-4` / `#06-17-5` / `#06-17-6`)
**Wave 12** *(blocked on Wave 11)*
- [ ] 07-12-PLAN.md — 装备/属性存档正确性(WR-04 / WR-05 / WR-06)
**Wave 13** *(blocked on Wave 12)*
- [ ] 07-13-PLAN.md — 地图失效边界与动态块(CR-02 / `#06-17-8`)
**Wave 14** *(blocked on Wave 13)*
- [ ] 07-14-PLAN.md — legacy hero 代理跟随活属性(`#06-17-7`;含该包首个测试文件)
## Progress
@ -343,4 +371,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. 单元测试 | 18/18 | In Progress| |
| 7. 数据端缺陷修复 | 9/9 | In Progress| |
| 7. 数据端缺陷修复 | 9/14 | In Progress| |

View File

@ -0,0 +1,450 @@
---
phase: 07-data-fixes
plan: 10
type: execute
wave: 10
depends_on:
- 07-09
files_modified:
- packages-user/data-common/src/replay/array.ts
- packages-user/data-common/src/replay/types.ts
- packages-user/data-common/src/replay/system.ts
- packages-user/data-common/src/replay/array.test.ts
- packages-user/data-common/src/replay/saveLoad.test.ts
- packages/common/src/logger.json
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 78000
raw_tokens: 78000
tasks: 6
confidence: low
must_haves:
truths:
- "`ReplayArray.set()` 在目标命令前后参数字节长度变化时,`indexArray` 仍与实际参数字节一致:`set()` 之后的每一步 `get(i)`/读流 `read()` 都解码出正确命令与参数(CR-01 消失)"
- "`set()` 对最后一步不再读未初始化的 `indexArray[length]`(末步参数终点取 `paramUsed`,与 `delete()` 同口径)"
- "`insert` / `delete` / `set` 在 `index` 越界时不再静默破坏缓冲区:按 Task 0 裁决的诊断/拒绝行为可观测"
- "`ReplayArray.setReplayArray()` 调用 `expireStreams()`:活跃 `ReplaySandbox.reader` 在 `ReplaySystem.loadState` 后 `expired === true`,播放告警 156 而不是按陈旧偏移继续解码(`#06-17-3` / 审计 H)"
- "bigint 参数的编码长度与写入的长度字节一致:|值| ≥ 2^2040(长度字节装不下)时按 Task 0 裁决的处置触发诊断,不再写出被截断的长度字节导致后续参数错位(WR-01)"
- "单条命令的参数个数写入值等于实际写入的参数个数:`params.length > 255` 时命令的参数计数与参数缓冲区一致,读流不再与下一条命令错位(WR-02)"
- "`pnpm test:ci` 全绿,不新增 `it.skip`、不弱化既有断言;既有 153 用例(`array.test.ts:439-449`)与 152 用例(`:431-436`)语义不变"
artifacts:
- path: packages-user/data-common/src/replay/array.ts
provides: "CR-01 修复:`set()` 改用 `getParamRange(index)` 取 `[start, end)` 并按 `index` 起回退索引数组;`insert`/`delete`/`set` 的 `index` 边界校验与 `insert(index === length)` 语义;`setReplayArray` 补 `expireStreams()`;`normalizeParam` 的 bigint 长度字节守卫;`add`/`insert`/`set` 写入截断后的参数个数"
- path: packages-user/data-common/src/replay/types.ts
provides: "`IReplayArray.insert/set/delete` 的 index 边界契约 jsDoc(接口源头);若 Task 0 裁决要求版本标记,则 `IReplaySystemSave` 增加版本字段与其 jsDoc"
- path: packages-user/data-common/src/replay/system.ts
provides: "仅当版本字段落到 `IReplaySystemSave` 时,`saveState`/`loadState` 透传该字段"
- path: packages-user/data-common/src/replay/array.test.ts
provides: "CR-01 回归(增长/缩短/末步/读流遍历)、边界校验回归、bigint 长度字节回归、255 参数计数回归"
- path: packages-user/data-common/src/replay/saveLoad.test.ts
provides: "`#06-17-3` 回归:活跃沙箱读流在 `ReplaySystem.loadState` 后过期(告警 156)"
- path: packages/common/src/logger.json
provides: "仅当 Task 0 裁决新增边界诊断码时,登记该码文案"
key_links:
- "`set()` 的索引回退起点 `index`(命令索引)↔ `getParamRange(index)` 的 `[start, end)`(`array.ts:407-415`)——`delete()`(`:467-500`)已用该口径,`set()` 与之对齐后二者不可能再分叉"
- "`getParamRange` 的末步终点 `this.paramUsed`(`:412`)↔ `set()` 不再读 `indexArray[index + 1]`(`:507`)——末步 set 不再产生负 `paramLength` 与巨型 `deltaLength`"
- "`setReplayArray`(`:801-826`)↔ `expireStreams()`(`:214-219`)——其它 6 处写路径(`add`/`insert`/`delete`/`set`/`setCommandWidth`/扩容)都调用,`setReplayArray` 是唯一遗漏"
- "`normalizeParam` 的 bigint `arr.length`(`:277`、写入 `:389`)↔ `decodeParam` 读取的单字节长度(`:660`)——长度字节必须 ≤ 255,即 |值| < 2^2040"
- "`normalizeParamList` 的截断(`:316-323`)↔ `setCommandArray(commandStart, params.length, …)`(`:426`/`:453`/`:517`)——计数必须用截断后的长度"
- "`ReplaySandbox.reader`(`sandbox.ts:43` 构造期捕获偏移)↔ `ReplaySystem.loadState`(`system.ts:106-113`)→ `expireStreams()` ——过期后 `step()`/`play()` 告警 156(`sandbox.ts:66-69/78-81/141-144`)"
assumptions:
- "A1(D-10 例外,本计划授权):本计划为修复计划,为上述 6 项新增/收紧回归用例是必需项;不得为让套件转绿而改写既有断言(Task 0 裁决要求的用例调整除外,须逐条在 SUMMARY 说明)"
- "A2(项目未发布,与 07-09 的 A2 同口径):不承诺读回仓外已存在的旧录像;是否给录像存档加格式版本由 Task 0 裁决,未获裁决前不得自行新增版本字段"
- "A3(码表唯一性):`packages/common/src/logger.json` 的 warn 码唯一、不复用;若 Task 0 裁决新增边界诊断码,取当前最大 warn 码 178 之后的下一个可用号(179),不复用任何既有码"
- "A4(范围守卫):仅修本计划 6 项;不修 `ReplayArray` 之外任何文件的行为,不改 `sandbox.ts`、不改 `data-common/src/save/*`(Dexie schema 与录像编解码无关),不动渲染端与 legacy"
- "A5(同引用原则不适用):`ReplayArray` 的缓冲区替换(`setReplayArray`)是契约内的整体装载,本计划不引入「缓冲区实例跨读档不变」的新要求"
prohibitions:
- "不修 C–H 中本计划之外的四条(`#06-17-4` equipStore / `#06-17-5` flag / `#06-17-6` followers / `#06-17-7` data-fallback / `#06-17-8` mapLayer)——对应文件一行不动"
- "不改 `ReplaySandbox`(`sandbox.ts`)任何一行:`#06-17-3` 的修法在 `ReplayArray.setReplayArray` 侧补 `expireStreams()`,不在沙箱侧加兜底"
- "不改 `ReplayArray` 的参数类型码分配(Task 0 若裁决为「登记并冻结为追加式格式」则只改注释与文档,不得移动任何既有 type 码)"
- "不引入新依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言"
- "不修改本计划 `files_modified` 之外的源码;不改渲染端 `@user/client-*`、不改 legacy 接线"
- "不新增 `it.skip`、不删除既有回归用例、不把断言改写成弱于现状的形式"
---
<objective>
修复 `ReplayArray` 的索引/编解码缺陷与流生命周期缺陷(CR-01 + 审计 H + 4 条 review 警告),使 `set()` 与 `delete()` 同口径、写入路径的边界与长度字段自洽、活跃读流跨读档不再按陈旧偏移解码。
现状(逐条锚点):
1. **CR-01(高)** `set()`(`array.ts:502-539`)把 `paramStart`(参数字节偏移)当作命令索引回退起点(`:534`),且直接读未初始化的 `indexArray[index + 1]`(`:507`)——与 07-03 已在 `delete()` 中修掉的缺陷镜像(`07-REVIEW.md` CR-01;`07-03-PLAN.md` 已登记 `set` 与 `insert(index === length)` 为「只登记不修」)。
2. **`#06-17-3`(审计 H)** `setReplayArray()`(`:801-826`)漏 `expireStreams()`,其它 6 处写路径都有——`ReplaySystem.loadState` 后活跃 `ReplaySandbox.reader` 按陈旧偏移解码。
3. **WR-01** bigint 长度存单字节(写入 `:389`、读取 `:660`),而守卫只拦 2^2047(`:270-273`);|值| ≥ 2^2040 时长度字节溢出为 0,解码只前进 2 字节 → 后续参数全部错位。
4. **WR-02** `add()` 写 `params.length`(`:426`)而只存 `normalizeParamList` 截断后的 255 个参数(`:316-323`)——参数计数与实际内容不一致,读流与下一条命令错位。
5. **WR-03** 参数类型码 5/6/7/8 与短字符串基址已被重新分配(`:11-33`、`:593-688`),`IReplaySystemSave`(`types.ts:324-352`)无格式版本标记 —— 旧录像重解码会静默变成垃圾。
6. **WR-07** `insert`/`delete`/`set` 不校验 `index`(`:437-539`),越界调用静默破坏缓冲区。
Purpose: 录像是数据端唯一可回放的事实源,`set()` 是公开编辑 API;索引数组一旦与参数字节错位,二次录制比对与录像验证全部失去意义。本计划把这三类缺陷(编辑索引、写入自洽、流生命周期)一次性收口,并把需要用户裁决的契约点(`set()` 语义、版本、溢出处置、诊断码)留给 Task 0 关卡。
Output: `array.ts` 的 6 处修复 + `types.ts` 的契约 jsDoc(+ 版本字段,取决于 Task 0)、`logger.json`(取决于 Task 0)、`array.test.ts` 与 `saveLoad.test.ts` 的回归用例。
**Artifacts this phase produces(本计划产出物)**:修改文件见 frontmatter `files_modified`;新增/收紧用例见 `must_haves.artifacts`;无新建源码文件、无新增依赖。
**Phase 7 验证已失效**:`07-VERIFICATION.md`(2026-09-16T06:04:11Z,15/15)在 07-09 执行前即已过期,本轮又追加 07-10..07-14。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论,`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步更新。
Phase 7 裁决对应(逐条引用):D-01/D-02: 本计划是 Phase 7 第 10 个计划,承接 `packages-user/data-common/src/replay` 系统的 review 与审计条目(07-03 在本系统「只登记不修」的两条由本计划接管)。D-03/D-04/D-05/D-06: 其它系统的契约裁决,不涉及。D-07: 用户负责项(path 接线),不涉及。D-08: enemy,不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划,回归用例为必需项(A1)。D-11: `set()` 语义、边界契约与格式版本裁决落地后同步更新 `types.ts` jsDoc 与 `array.ts` 注释。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按根因原子提交(Task 1 / 2 / 3 / 4 各一组)。D-14: 本计划 6 项均无 `WINDOWS.md` 条目(07-03 的 id 23 已 fixed,review/审计条目未入账),只登记不新建。
执行序:`depends_on: 07-09`(Wave 10)。该依赖是**门禁型**而非文件/类型型:Task 0 是 `gate="blocking-human"` 的用户裁决关卡,D-09 要求逐计划暂停确认,且 D-12 的 `pnpm test:ci` 基线必须逐计划重录(并发执行会互相污染基线)。源码层面与 07-09 无耦合。
**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(跳过项为 `equipment.test.ts:306` 码 147,D-06 设计如此,全阶段永久保留)。执行前重录当日基线并与本行对比,任何新增失败都必须在 SUMMARY 中逐条说明。
</objective>
<execution_context>
@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/book/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.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-REVIEW.md
@.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md
@.planning/phases/07-data-fixes/07-03-PLAN.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/codebase/TESTING.md
@.planning/codebase/CONVENTIONS.md
@dev.md
@packages-user/data-common/src/replay/array.ts
@packages-user/data-common/src/replay/types.ts
@packages-user/data-common/src/replay/system.ts
@packages-user/data-common/src/replay/sandbox.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 契约裁决关卡(D-09)——`set()` 语义 / `insert(index === length)` / 越界诊断码 / &gt;255 参数 / bigint ≥2^2040 / 录像格式版本</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-common/src/replay/array.ts(:11-33 类型码表注释、:225-310 `normalizeParam` 含 bigint 分支 :268-285、:316-323 `normalizeParamList`、:339-350 `setCommandArray`、:407-415 `getParamRange`、:417-435 `add`、:437-465 `insert`、:467-500 `delete`、:502-539 `set`、:612-688 `decodeParam` 含 bigint :658-667、:740-775 `createReadStream`、:801-826 `setReplayArray`、:838-846 `disable`/`revert`)
- packages-user/data-common/src/replay/types.ts(:184-238 `IReplayArray` 的 `insert`/`set` jsDoc 在 :204/:220、:324-352 `IReplaySystemSave`)
- packages-user/data-common/src/replay/system.ts(:97-113 `saveState`/`loadState`)
- packages-user/data-common/src/replay/saveLoad.test.ts(现有 ReplaySystem 同实例往返用例与夹具)
- .planning/phases/07-data-fixes/07-REVIEW.md(CR-01、WR-01、WR-02、WR-03、WR-07、IN-03 原文与建议修法)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(H 行、触发序列)
- packages/common/src/logger.json(warn 148-155 文案、178 为当前最大 warn 码)
</read_first>
<decision>
`ReplayArray` 六项修复中需要用户裁决的契约点(其余修法无分支,见 Task 1–3):
(Q1)`set()` 的语义与其末步终止方式;(Q2)`insert(index === length)` 是合法追加还是非法调用(07-03 登记为「只登记不修」,本计划接管);(Q3)越界诊断用什么码;(Q4)>255 参数的处置;(Q5)bigint ≥2^2040 的处置;(Q6)录像参数编解码是否需要格式版本标记。
</decision>
<context>
这六个问题的共同点是:**改法都能实现,但落地后对外契约不同**(公开 API 语义、旧录像是否可读、诊断码表内容、写出格式)。
07-03 已按 D-10「不新增用例」把 `set` 与 `insert(index === length)` 登记为不修;本轮用户已解除该范围守则(C–H 与 CR-01/CR-02 由「只登记」转为「修复」),因此这两条的契约必须由用户重新拍定,AI 不自行选择。
Q1/Q2 决定 `IReplayArray` 的公开语义(会写进 `types.ts` jsDoc 并由 Phase 6 既有用例锚定);Q3 决定是否动 `packages/common/src/logger.json`;Q4/Q5 决定「warn 后静默丢弃/钳制」还是「拒绝整条命令」;Q6 决定是否给 `IReplaySystemSave` 加字段(项目未发布,无向后兼容义务,但一旦选择「加版本」就会有两条解码路径的维护成本)。
</context>
<options>
<option id="q1-a">
<name>Q1 选项 A:【建议默认】`set()` 支持参数长度变化,改用 `getParamRange(index)`(末步终点 = `paramUsed`)并把索引回退循环起点改为 `index`</name>
<pros>与 `delete()`(`:467-500`)完全同口径,`insert`/`delete`/`set` 三者的索引维护只有一套写法;修复 CR-01 的两个子缺陷(错起点 + 末步读 0)一步到位;review 建议修法即此</pros>
<cons>改变「set 只用于等长替换」的隐含前提;`set()` 之后所有后续步骤的 `indexArray` 都会变动,调用方若缓存过 `get(i)` 结果需重新取值(当前无此调用方)</cons>
</option>
<option id="q1-b">
<name>Q1 选项 B:`set()` 只允许等编码长度替换,长度变化时告警并整条放弃(不写入)</name>
<pros>`set()` 变成纯覆盖、无位移,索引数组永不移动,实现最简单且不会引入任何位移风险</pros>
<cons>公开 API 能力被削减(07-03 的既有用例 `array.test.ts:224-234` 恰好是等长,不受影响;但任何需要改参数长度的调用方被硬性拒绝);`insert`/`delete` 仍走位移路径,三者的索引维护依旧是两套写法</cons>
</option>
<option id="q1-c">
<name>Q1 选项 C:保持现状语义(含缺陷)、只在 jsDoc 标注「参数长度变化时行为未定义」</name>
<pros>零代码改动</pros>
<cons>CR-01 是 review 判定的**数据损坏级**缺陷(公开 API 静默写坏缓冲区),拒绝修复等于把已知损坏保留在在役路径上;与「本轮把 CR-01 纳入修复」的用户指令直接冲突</cons>
</option>
<option id="q2-a">
<name>Q2 选项 A:`insert(index === length)` 判为**合法追加**,按 `add()` 语义写入(`paramStart = paramUsed`、`indexArray[length] = paramUsed`)</name>
<pros>与「插入到末尾」的直觉一致;消除 `indexArray[length]` 从未初始化(值恒 0)导致的整段参数位移破坏;`insert` 的合法区间变为闭区间 `[0, length]`</pros>
<cons>需要在 `insert` 内分叉为「追加」与「插入」两条写入路径,或复用 `add`;`indexArray` 的「末步终点 = paramUsed」约定在追加分支同样成立,需在 jsDoc 写明</cons>
</option>
<option id="q2-b">
<name>Q2 选项 B:`insert(index === length)` 判为**非法调用**(告警 + 无操作),追加一律走 `add()`</name>
<pros>语义最窄、实现最少;`insert` 的合法区间为 `[0, length)`,与 `set`/`delete` 一致</pros>
<cons>调用方必须知道「末尾追加要用 add」;若仓外/legacy 有 `insert(length, …)` 调用会被静默拒绝(本仓复核:仅 `array.test.ts:189/562/585` 三处调用,索引均 < length)</cons>
</option>
<option id="q3-a">
<name>Q3 选项 A:复用既有通用码 9(`Data transfered is partially (or totally) out of range. Overflowed data will be ignored.`)作为越界诊断</name>
<pros>不动 `packages/common/src/logger.json`(满足「不新增错误码」的既有惯例);文案本身是「越界数据被忽略」,与「越界调用无操作」语义贴合</pros>
<cons>码 9 无参数占位符,无法携带「哪个方法 / 哪个索引 / 当前长度」,诊断力弱;码 9 属 legacy 通用码,与 replay 专用码段(148-158)风格不一致</cons>
</option>
<option id="q3-b">
<name>Q3 选项 B:新增 replay 专用 warn 码 179(当前最大 warn 码 178 之后的第一个可用号),携带操作名/索引/长度</name>
<pros>诊断力最强(可定位具体调用);落在 replay 专用码段之后,风格一致;码唯一、不复用(A3)</pros>
<cons>需要改 `packages/common/src/logger.json`(`packages` 属数据端公共层,改动本身合规);07-09 曾把「不新增错误码」作为该计划的范围守卫,本计划若新增需在此明确覆盖该惯例</cons>
</option>
<option id="q3-c">
<name>Q3 选项 C:越界静默无操作(不加任何诊断)</name>
<pros>零码表改动、零日志噪音</pros>
<cons>review WR-07 明确要求「静默缓冲区损坏比告警更糟」;无诊断时调用方无法察觉自己的索引越界</cons>
</option>
<option id="q4-a">
<name>Q4 选项 A:【与码 153 文案一致】写入**截断后**的参数个数(`normalized.length`),溢出参数按既有告警 153 忽略</name>
<pros>与 `logger.json` 码 153 文案「Up to 255 parameters for a single replay command… Overflowed parameters will be ignored」完全一致;既有 153 用例(`array.test.ts:439-449`)继续通过;实现最小</pros>
<cons>溢出参数仍然静默生效(命令被执行时参数不全),调用方只能从 153 告警得知</cons>
</option>
<option id="q4-b">
<name>Q4 选项 B:`params.length > 255` 时**拒绝整条命令**(告警 153 + 不写入任何命令/参数)</name>
<pros>不会产生「参数不全的命令」;语义上「要么完整、要么不记」</pros>
<cons>改变既有行为(今日会写入一条 255 参数的命令);录像中出现近 255 参数命令时会导致后续步骤整体少一步,录像语义变化更大;既有 153 用例需同步调整断言</cons>
</option>
<option id="q5-a">
<name>Q5 选项 A:|值| ≥ 2^2040 判为不可表示 → 告警 152(沿用既有 bigint 超限码)+ 按裁决钳制/拒绝</name>
<pros>不新增码;把「能表达」与「不能表达」的边界与长度字节的真实容量(255 字节 = 2^2040)对齐</pros>
<cons>钳制会**静默改变值**(round-trip 不再等价),拒绝则会丢掉该参数(命令参数个数变化)——两者都需在 jsDoc 写明;`array.test.ts:431-436`(2^2048 触发 152)语义不变但边界前移</cons>
</option>
<option id="q5-b">
<name>Q5 选项 B:把 bigint 编码改为带长度前缀(如 int32 长度 + 字节载荷,与 string 一致),支持任意长度 bigint</name>
<pros>不再有人为边界,2^2040 以上可完整往返;语义最干净</pros>
<cons>属**写出格式变更**(byteLength 由 `len+2` 变 `len+5`),与 Q6 强耦合:若同时选 Q6「不加版本」,旧录像的 bigint 段会错位(就是 WR-03 想避免的问题);改动面最大</cons>
</option>
<option id="q5-c">
<name>Q5 选项 C:登记为已知限制,只把守卫阈值与注释对齐(不改行为、不加处置)</name>
<pros>不改变任何写出格式与值语义</pros>
<cons>WR-01 的**静默缓冲错位**仍在(这是错位而非丢值),只是把触发条件写进文档;与「本轮修复 15 项」的指令冲突(该项在清单第 3 条)</cons>
</option>
<option id="q6-a">
<name>Q6 选项 A:给 `IReplaySystemSave` 增加格式版本字段(如 `version: number`),加载时版本不匹配即告警并按裁决拒绝或走兼容路径</name>
<pros>录像存档自描述,未来编解码变更不再静默把旧数据处理成垃圾;`RebuildIndexArray` 的版本选择有据可依</pros>
<cons>需新增解码分支或明确的版本拒绝码(是否新增码 → 与 Q3 联动);`IReplaySystemSave` 形状变更会影响 `saveablesRoundTrip.test.ts` 的存档形状断言(需随裁决更新)</cons>
</option>
<option id="q6-b">
<name>Q6 选项 B:【建议默认】不加版本字段,改为「类型码只增不改」的追加式格式约定(记录既有 5/6/7/8 与短字符串基址的重新分配为已发生的一次性破坏,项目未发布、旧录像明确不在范围内)</name>
<pros>零格式变更、零解码分支;把「不再复用类型码」写成显式约定即可防止未来重演;与 07-09 的 A2(项目未发布、无向后兼容)口径一致</pros>
<cons>旧录像仍然不可读且**无诊断**(只是不承诺);未来若再次需要变更格式,仍要靠约定而非机制约束</cons>
</option>
<option id="q6-c">
<name>Q6 选项 C:加版本字段 + 保留旧类型码表的兼容解码路径(旧录像可读)</name>
<pros>真正的向后兼容;仓外旧录像可继续回放</pros>
<cons>需要维护两套类型码表与两套 `decodeParam`/`rebuildIndexArray` 分支,`decodeParam` 需按版本取表(`array.ts:612-688` 全量分叉);本轮只按「不承诺旧录像」立项,收益与成本不匹配</cons>
</option>
<option id="revise">
<name>(兜底)修订方案后再执行</name>
<pros>避免带错误契约落地</pros>
<cons>按 D-09 退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法</cons>
</option>
</options>
<record>
待用户在关卡处逐条填写(Q1..Q6:勾选选项 id 或直接给出裁决文字)。
**本记录为空的 `<record>` 表示尚未裁决**:执行者必须停在本任务,不得自行选择任何选项、不得按「建议默认」标记推进。
填写后,Task 1–4 一律按本记录执行;若记录与 Task 1–4 中某分支描述冲突,暂停并请用户澄清(不得自行改选)。
</record>
<resume-signal>逐条给出 Q1..Q6 的裁决(例如「Q1=A, Q2=B, Q3=A, Q4=A, Q5=A, Q6=B」或直接文字说明);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md</resume-signal>
<action>向用户汇报上述 6 个契约点与本计划其余部分(Task 1–4 的无分支修法可一并汇报,便于一次确认)。不得在裁决前修改任何文件。</action>
<verify>
<human-check>`<record>` 中 Q1..Q6 均有明确裁决,且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进</fails_when>
</verify>
<acceptance_criteria>
- Q1(`set()` 语义与末步终止)、Q2(`insert(index === length)`)、Q3(越界诊断码)、Q4(>255 参数)、Q5(bigint ≥2^2040)、Q6(格式版本)六条均有用户裁决并写入 `<record>`
- 裁决内容与 Task 1–4 的分支描述可对应;如某条裁决不在既有选项中,已在 `<record>` 写清其对 `array.ts`/`types.ts`/`logger.json` 的落地口径
- 未在裁决前修改任何文件
</acceptance_criteria>
<done>Q1..Q6 裁决已完整写入 `<record>`;执行者据记录直接进入 Task 1,不再重复询问</done>
</task>
<task type="auto">
<name>Task 1: CR-01 —— `set()` 索引数组损坏修复(`getParamRange` 末步哨兵 + 按 `index` 起回退),含增长/缩短/末步/读流遍历回归</name>
<files>packages-user/data-common/src/replay/array.ts, packages-user/data-common/src/replay/types.ts, packages-user/data-common/src/replay/array.test.ts</files>
<read_first>
- packages-user/data-common/src/replay/array.ts(:407-415 `getParamRange`、:467-500 `delete` 参照实现、:502-539 `set` 缺陷本体、:729-738 `get`、:740-775 `createReadStream`)
- packages-user/data-common/src/replay/types.ts(:204-230 `insert`/`set` jsDoc)
- packages-user/data-common/src/replay/array.test.ts(:196-246 现有 delete/set 用例、:452-475 读流遍历、`:224-234` 等长 set —— IN-03 指出该用例 `deltaLength === 0` 无法发现 CR-01)
- .planning/phases/07-data-fixes/07-REVIEW.md(CR-01 原文与 trace)
</read_first>
<action>
按 Task 0 的 Q1 裁决修复 `set()`。
**若 Q1 = 选项 A(推荐默认)**:
(1) `set()`(`array.ts:502-539`)改为 `const range = this.getParamRange(index); const paramStart = range.start; const nextParam = range.end;`(删除 `:506-507` 的 `indexArray[index]` / `indexArray[index + 1]` 直读),`paramLength = nextParam - paramStart` 与 `deltaLength` 不变;
(2) 索引回退循环(`:534-536`)起点由 `paramStart + 1` 改为 `index + 1`:`for (let i = index + 1; i < this.length; i++) this.indexArray[i] += deltaLength;`(本命令自身起点不变,故从 `index + 1` 起);
(3) `paramUsed` 增减、`deltaLength < 0` 的尾部置零(`:520-528`)、`setParamArray(paramStart, normalized)`(`:531`)顺序保持不变;
(4) `types.ts` 的 `set()` jsDoc(`:212-220` 附近)补写:允许参数编码长度变化;其后所有步骤的 `indexArray` 会被同步平移;末步参数终点取 `paramUsed`。
**若 Q1 = 选项 B**:`set()` 在 `deltaLength !== 0` 时按 Q3 裁决的诊断方式告警并无操作返回(不写命令、不写参数、不位移),等长路径保持现状;`types.ts` jsDoc 写明「仅支持等编码长度替换」。
**若 Q1 = 选项 C**:不修改 `array.ts`/`types.ts`,仅在 SUMMARY 登记「用户裁决保持现状」并跳过本任务其余内容(该情形下 Task 3 的 WR-02 `set` 分支同样不执行)。
回归用例(`array.test.ts`,中文单行注释说明覆盖内容,按 `dev.md:85` 约定):
(a) 增长:`add(1,[1]); add(2,[2]); add(3,[30]); set(1, 5, [true,true,true])` → `get(0)/get(1)/get(2)` 三者命令与参数全部正确(review 的 trace 场景);
(b) 缩短:`set(1, 5, [])` → 后续步骤参数与读流仍正确,`paramUsed` 相应减少;
(c) 末步:`set(length - 1, …)` 在长度变化下不产生负 `paramLength`(对末步 set 前后 `get(last)` 与 `length`、`paramUsed` 断言);
(d) 读流遍历:`createReadStream(0)` 逐条 `read()` 与 `get(i)` 结果一致(`index = position + 1`,按 06-12 既有比较口径只比 `command`/`params`)。
断言写法沿用文件既有风格(`createArray()` / `expectParamTyped` / `expectRouteStream` 若适用)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts</automated>
<fails_when>CR-01 场景下 `get(2)` 仍读到命令 1 的新参数(或末步 set 后 `length`/`paramUsed` 异常、读流中途返回 null)——说明回退起点或末步终点仍未对齐,按 D-09 退出并修订计划,不得改用其它修法</fails_when>
</verify>
<acceptance_criteria>
- `array.ts` 的 `set()` 不再读取 `indexArray[index + 1]`,索引回退起点为命令索引(选项 A)或按选项 B 明确拒绝长度变化
- 新增用例 (a)(b)(c)(d) 全绿,且既有 `array.test.ts:224-234` 等长 set 用例保持通过
- `types.ts` 中 `set()` 的 jsDoc 与最终语义一致(含末步终点与长度变化约定)
</acceptance_criteria>
<done>`set()` 在参数长度变化的中间步与末步上均保持 `indexArray` 与参数字节一致,回归用例锚定 review 的 trace 场景并全绿</done>
</task>
<task type="auto">
<name>Task 2: 写入路径边界校验(`insert`/`delete`/`set` 的 index + `insert(index === length)`)+ `setReplayArray` 补 `expireStreams()`(`#06-17-3` 闭环)</name>
<files>packages-user/data-common/src/replay/array.ts, packages-user/data-common/src/replay/types.ts, packages-user/data-common/src/replay/array.test.ts, packages-user/data-common/src/replay/saveLoad.test.ts, packages/common/src/logger.json</files>
<read_first>
- packages-user/data-common/src/replay/array.ts(:214-219 `expireStreams`、:437-465 `insert`、:467-500 `delete`、:502-539 `set`、:801-826 `setReplayArray`)
- packages-user/data-common/src/replay/types.ts(:147-166 `IReplayReadStream` 的 `expired`、:204-230 `insert`/`set` jsDoc)
- packages-user/data-common/src/replay/system.ts(:106-113 `loadState`)
- packages-user/data-common/src/replay/sandbox.ts(:37-44 构造期 `createReadStream`、:66-69/:78-81/:141-144 过期告警 156)
- packages-user/data-common/src/replay/saveLoad.test.ts(现有 `createSystem` 夹具与同实例往返用例)
- packages/common/src/logger.json(warn 9 与 178 之后的可用号,按 Q3 裁决取用)
</read_first>
<action>
(1) **越界校验(WR-07)**:在 `insert`/`delete`/`set` 的 `disabled` 早退之后、任何缓冲读写之前插入 `index` 校验——`delete`/`set` 合法区间 `[0, this.length)`,`insert` 按 Q2 裁决为 `[0, this.length]`(追加合法)或 `[0, this.length)`;越界时按 Q3 裁决处理:选项 A 复用 `logger.warn(9)`、选项 B 新增码(在 `packages/common/src/logger.json` 的 `warn` 段按现有 JSON 结构追加 179,文案含操作名/索引/长度占位符,并在 `array.ts` 调用处传参)、选项 C 静默返回。三种情形下越界调用都**不得**触碰 `commandArray`/`paramArray`/`indexArray`/`length`/`paramUsed`。
(2) **`insert(index === length)`(Q2)**:选项 A 时把该情形走追加语义(`paramStart = this.paramUsed`、`setCommandArray(length * commandSize, …)`、`setParamArray(paramUsed, normalized)`、`indexArray[length] = paramUsed`,`length++`/`paramUsed += length` 后 `expireStreams()`,不执行任何 `copyWithin`);选项 B 时由 (1) 的校验拒绝并告警。
(3) **`setReplayArray` 补 `expireStreams()`(`#06-17-3`)**:在 `rebuildIndexArray()`(`:825`)之后调用 `this.expireStreams()`,使 `ReplaySystem.loadState` 装载缓冲区时把此前 `createReadStream` 创建的活跃读流全部标记过期(`sandbox.ts:43` 的 `reader`),与其它 6 处写路径一致。不改 `sandbox.ts`。
回归用例:
(a) `array.test.ts`:`insert(this.length, …)`(按 Q2 裁决断言追加成功或告警无操作)、`insert(-1, …)` / `delete(length)` / `set(length)` 越界后 `length`、`get(i)`、`getCommandArray()` 首字节均未被破坏(越界调用前后对比);
(b) `saveLoad.test.ts`:新建 `ReplaySystem` → `record(...)` → 建沙箱(`createReplaySandbox`,`reseter` 用最小桩)→ 取 `sandbox` 的读取流已被创建 → `system.loadState(snapshot)` → 断言 `sandbox.reader.expired === true`(必要时经 `sandbox.step()`/`play()` 断言告警 156,`sandbox.test.ts:196-211` 已有同款断言口径可参照);本用例即 `#06-17-3` 的回归。若 `IReplaySandbox` 未公开 `reader`,则在 `sandbox.test.ts` 口径下通过 `logger.catch(() => sandbox.play())` 断言 `info` 含 156(不得为此改 `sandbox.ts`)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts packages-user/data-common/src/replay/saveLoad.test.ts</automated>
<fails_when>越界调用后缓冲区仍被改写(说明校验位置在读写之后)、或 `#06-17-3` 用例在 `loadState` 后读取流未过期——按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- `insert`/`delete`/`set` 的越界调用不修改任何缓冲区、`length`、`paramUsed`,并按 Q3 裁决产生可观测结果(诊断或明示静默)
- `insert(index === length)` 行为与 Q2 裁决一致,并有对应断言
- `setReplayArray` 调用 `expireStreams()`;活跃读流在 `ReplaySystem.loadState` 后过期(`expired === true` 且后续 `step()`/`play()` 走 156 分支)
- 未修改 `sandbox.ts`;未新增 `it.skip`
</acceptance_criteria>
<done>越界索引不再能静默破坏录像缓冲区;活跃读流跨 `loadState` 由 `ReplayArray` 主动过期(`#06-17-3` / 审计 H 关闭)</done>
</task>
<task type="auto">
<name>Task 3: 编解码自洽 —— bigint 长度字节守卫(WR-01)+ 参数计数使用截断后长度(WR-02,含 `add`/`insert`/`set` 三处)</name>
<files>packages-user/data-common/src/replay/array.ts, packages-user/data-common/src/replay/array.test.ts</files>
<read_first>
- packages-user/data-common/src/replay/array.ts(:225-310 `normalizeParam` 的 bigint 分支 :268-285、:316-323 `normalizeParamList`、:357-401 `setParamArray` 的 bigint 写入 :387-390、:417-435 `add` 的计数写入 :426、:437-465 `insert` 的 :453、:502-539 `set` 的 :517、:612-688 `decodeParam` 的 bigint 读取 :658-667)
- packages-user/data-common/src/replay/array.test.ts(:430-449 既有 152/153 用例、:248-300 参数编解码用例簇)
- .planning/phases/07-data-fixes/07-REVIEW.md(WR-01、WR-02 原文与最小复现)
</read_first>
<action>
(1) **WR-01(bigint 长度字节)**:`normalizeParam` 的 bigint 分支(`:268-285`)在既有 2^2047 墙体守卫之外,按 Q5 裁决处理「长度字节装不下」的情形——选项 A:追加 `magnitude >= 1n << 2040n` 判定,复用 `logger.warn(152)`,并按裁决钳制到 `(1n << 2040n) - 1n` 或拒绝该参数(后者需与 `normalizeParamList`/`setParamArray` 的参数计数保持一致,避免参数个数与写入不符);选项 B:把编码改为长度前缀(`byteLength = arr.length + 5`,写入 `setInt32(index + 1, arr.length)`、字节从 `index + 5` 起,读取同步改 `decodeParam`),此时**必须**按 Q6 裁决处理旧数据;选项 C:仅把守卫阈值注释与 `logger.warn(152)` 文案对齐并在 jsDoc 写明已知限制,不改写出行为。无论哪种选项,**写出后的 `arr.length` 必须 ≤ 255**,且长度字节与 `byteLength` 自洽。
(2) **WR-02(参数计数)**:`add`(`:426`)、`insert`(`:453`)、`set`(`:517`)三处的 `setCommandArray(..., params.length, ...)` 一律改为使用已标准化的参数列表长度(`normalized.length`),使命令计数等于实际写入的参数个数;按 Q4 裁决决定「截断写入」(选项 A)或「整条拒绝」(选项 B,此时 `add`/`insert` 需在 `normalizeParamList` 之后、任何写入之前判定并返回,`set` 同)。`normalizeParamList`(`:316-323`)的截断与码 153 告警保持不变。
回归用例(`array.test.ts`):
(a) WR-01:对 `1n << 2040n`(长度字节边界)与 `1n << 2041n` 分别断言最终写出字节可被 `get()` 完整解码、后续参数不错位(可构造「超大 bigint 参数 + 后续 int 参数」的同一条命令,断言第二个参数读回正确);按 Q5 裁决同时断言诊断(152)或钳制后的值;
(b) WR-02:256 个参数的 `add` 后断言命令计数字节与 `get(0).params.length` 一致(`255`),并断言后续再 `add(1,[7])` 后 `createReadStream(0)` 能依次读回两条命令(今日会错位);`insert`/`set` 同样各一条;
(c) 既有 `array.test.ts:431-436`(152)与 `:439-449`(153)用例保持通过(按裁决需要时可扩充断言,但不得删除或弱化)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts</automated>
<fails_when>超大 bigint 或 256 参数命令之后,同一读流中的后续步骤解码出错位 —— 说明长度字节或计数仍未自洽,按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- bigint 写出路径的长度字节恒 ≤ 255、与 `byteLength` 自洽;超界值按 Q5 裁决触发 152 或完整往返(选项 B)
- `add`/`insert`/`set` 写入的命令参数计数等于实际写入的参数个数(256 参数场景下为 255),读流不再错位
- 既有 152/153 用例语义不变;新增用例覆盖 bigint 边界与「超 255 参数后仍能顺序读回」
</acceptance_criteria>
<done>参数缓冲区的长度字段与计数与实际内容一致:超界 bigint 与超 255 参数都不再让读流与写出的字节序列分叉</done>
</task>
<task type="auto">
<name>Task 4: 录像参数格式版本(WR-03)—— 按 Task 0 Q6 裁决落地(加版本字段 / 追加式约定 + 旧录像不承诺)与文档同步</name>
<files>packages-user/data-common/src/replay/array.ts, packages-user/data-common/src/replay/types.ts, packages-user/data-common/src/replay/system.ts, packages-user/data-common/src/replay/array.test.ts</files>
<read_first>
- packages-user/data-common/src/replay/array.ts(:11-33 类型码表注释、:593-688 `decodeParam`、:781-799 `rebuildIndexArray`)
- packages-user/data-common/src/replay/types.ts(:324-352 `IReplaySystemSave`)
- packages-user/data-common/src/replay/system.ts(:97-113 `saveState`/`loadState`)
- packages-user/data-common/src/save/system.ts(:61-67 Dexie `db.version(1)` —— 说明该版本与本编解码格式无关)
- .planning/phases/07-data-fixes/07-REVIEW.md(WR-03 原文与 (a)/(b) 两个方向)
</read_first>
<action>
按 Task 0 的 Q6 裁决落地。
**若 Q6 = 选项 A(加版本字段)**:`types.ts` 的 `IReplaySystemSave` 增加只读版本字段(jsDoc 写清「录像参数编解码格式版本,与存档数据库 schema 版本无关」);`system.ts` 的 `saveState` 写入当前版本常量、`loadState` 在版本不匹配时按裁决告警(用 Task 0 指定的码;若要新增码则改 `packages/common/src/logger.json`)并按裁决拒绝装载或继续;`array.ts` 的类型码表注释(`:11-33`)标注该表对应当前版本。**不改 Dexie schema**(录像格式与 IndexedDB schema 无关,`save/system.ts:63` 不动)。
**若 Q6 = 选项 B(追加式约定,不承诺旧录像)**:`array.ts:11-33` 的类型码表注释补写「类型码只增不改:新语义必须分配新码,禁止复用既有码」的显式约定,并记录短字符串基址(`length + 9`)与 5/6/7/8 的历史重新分配为已发生的一次性格式破坏;`types.ts` 的 `IReplaySystemSave` jsDoc 写明「不承诺读回旧格式录像」;`array.ts` 的 `rebuildIndexArray`(`:781-799`)与 `decodeParam`(`:612-688`)行为不变。
**若 Q6 = 选项 C(加版本 + 兼容解码路径)**:按版本字段 + 旧码表分支实现(`decodeParam`/`rebuildIndexArray` 按版本取表),并新增「旧格式录像仍可读」的回归用例。
无论哪种选项,都在 `array.test.ts` 加/改一条与裁决对应的断言:选项 A/C 断言 `saveState()` 携带版本且 `loadState` 对不匹配版本的可观测行为;选项 B 断言类型码分配表的关键映射不变(如 float=6、非负 bigint=7、短字符串基址 9),把约定锚定成测试。
同时在 SUMMARY 中记录:本计划是否新增了 warn 码、是否新增了存档字段、旧录像兼容性结论,供 `07-VERIFICATION.md` 重跑时核对。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts packages-user/data-common/src/replay/saveLoad.test.ts</automated>
<fails_when>裁决为 A/C 但 `saveState` 未携带版本或 `loadState` 对版本不匹配无任何可观测行为;或裁决为 B 但类型码分配被改动(移动既有码)——按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- Q6 裁决已落地:格式自描述(A/C)或类型码只增不改的显式约定 + 旧录像不承诺(B),二者之一有代码/文档 + 测试锚定
- `packages-user/data-common/src/save/system.ts` 未被修改(Dexie schema 与本格式无关)
- SUMMARY 记录本计划新增的码/字段/兼容性结论
</acceptance_criteria>
<done>录像参数编解码的格式演进有明确机制或明确约定,且旧录像的兼容性结论已写入文档与 SUMMARY</done>
</task>
<task type="auto">
<name>Task 5: D-12/D-44 文件级门禁 + 全量套件 + 缺口登记(07-03 只登记两条 / review 条目 / 审计 H)与 Phase 7 验证失效说明</name>
<files>.planning/phases/07-data-fixes/07-10-SUMMARY.md</files>
<read_first>
- .planning/phases/07-data-fixes/07-03-PLAN.md(「`set` 与 `insert(index === length)` 只登记不修」的范围守卫原文,用于交叉引用)
- .planning/phases/07-data-fixes/07-REVIEW.md(CR-01/WR-01/WR-02/WR-03/WR-07 与 IN-03 的编号,便于逐条对照闭合)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(H 行)
- .planning/WINDOWS.md(各条 id 与状态列,用于确认无需新建/无需 fixed)
- .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status,用于说明已失效)
- .planning/ROADMAP.md(Phase 7 段的 Plans 列表与 Wave 列表)
</read_first>
<action>
(1) **门禁(D-12 / 06-CONTEXT D-44,文件级)**:对 `files_modified` 中每个改动的源码/测试文件执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出中不出现本计划改动文件的任何类型错误;`pnpm test:ci` 全绿(与「基线」行对比:66 文件 / 690 通过 / 1 跳过,跳过项仍只能是 `equipment.test.ts:306` 码 147)。
(2) **原子提交(D-13)**:Task 1 / 2 / 3 / 4 各自独立提交(`fix(07-10): …` 前缀,可携带 `#06-17-3`、`CR-01`、`WR-01/02/03/07` 等锚点)。
(3) **缺口登记**:在 `07-10-SUMMARY.md` 的 findings/ledger 段落登记——07-03 登记为「不修」的 `ReplayArray.set` 与 `insert(index === length)` 已由本计划关闭(附 commit 锚点);`07-REVIEW.md` 的 CR-01/WR-01/WR-02/WR-03/WR-07 与 `IN-03` 逐条标注「已修/已随裁决登记」;`07-LOADSTATE-AUDIT.md` 的 H 行(`#06-17-3`)标注已修。`WINDOWS.md` 本计划不新建条目、不执行 `fixed`/`waive`(6 项均无入账 id,只登记对照)。
(4) **Phase 7 验证失效**:在 SUMMARY 中明写 `07-VERIFICATION.md`(2026-09-16T06:04:11Z,覆盖 8/8 计划)已因 07-09 与本轮 07-10..07-14 失效,本计划结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表由本计划提交时同步(`docs(phase-07): …`)。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`(不允许以跳过换取全绿);或 `vue-tsc --noEmit` 输出中出现本计划改动文件的类型错误 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条(码 147)
- Task 1–4 各有独立提交,commit 信息含对应锚点
- SUMMARY 完成缺口登记(07-03 两条 + CR-01/WR-01/02/03/07 + IN-03 + 审计 H)与 Phase 7 验证失效说明
- `WINDOWS.md` 未被本计划改动
</acceptance_criteria>
<done>D-44 三步门禁全绿、原子提交完成、缺口登记与 Phase 7 验证失效说明写入 SUMMARY</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 录像数据 → 回放执行 | 录像缓冲区是外部产物(存档/仓外录像文件),解码结果直接驱动游戏指令 |
| 编辑 API 调用方 → `ReplayArray` 缓冲区 | `insert`/`delete`/`set` 的 `index` 与参数列表来自调用方,越界/超界输入直接作用于二进制缓冲区 |
| `ReplaySystem.loadState` → 活跃读流 | 装载缓冲区与活跃沙箱读流并发存在,偏移语义依赖过期机制 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-18 | Tampering | `ReplayArray.set()`(`array.ts:502-539`) | high | mitigate | Task 1:`getParamRange(index)` + 按 `index` 起回退,消除 CR-01 的静默缓冲区损坏;回归用例锚定 review trace 场景 |
| T-7-19 | Tampering | `ReplayArray.add/insert/set` 参数计数(`:426`/`:453`/`:517`) | high | mitigate | Task 3:计数一律取截断后的 `normalized.length`,使命令计数与写入参数个数一致,消除跨命令错位 |
| T-7-20 | Tampering | `normalizeParam` bigint 长度字节(`:277`/`:389`) | high | mitigate | Task 3:`arr.length ≤ 255` 守卫(按 Q5 裁决告警/钳制/改长度前缀),消除长度字节溢出导致的整段错位 |
| T-7-21 | Denial of Service | `insert`/`delete`/`set` 越界 `index`(`:437-539`) | medium | mitigate | Task 2:`index` 区间校验在任何缓冲读写之前;越界无副作用 + 按 Q3 裁决诊断 |
| T-7-22 | Repudiation | 录像格式变更无版本识别(`types.ts:324-352`) | medium | mitigate | Task 4:按 Q6 裁决落地版本字段或类型码只增不改的显式约定;旧录像兼容性结论写入文档与 SUMMARY |
| T-7-23 | Tampering | `setReplayArray` 装载缓冲区但活跃读流未过期(`:801-826`) | medium | mitigate | Task 2:补 `expireStreams()`,读流过期后走告警 156 分支(`sandbox.ts:66-69/141-144`),不再按陈旧偏移解码 |
| T-7-24 | Information Disclosure | 边界/编码诊断参数内容 | low | accept | 诊断只输出操作名/索引/长度等结构性整数(不含录像内容);新增码按 Q3 裁决,文案不含用户数据 |
| T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增任何依赖、不改 `package.json`;若执行中出现安装需求,暂停并要求用户确认包合法性(本计划预期为零安装) |
</threat_model>
<verification>
1. `pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts packages-user/data-common/src/replay/saveLoad.test.ts` 全绿(Task 1–4 的聚焦验证)。
2. `pnpm test:ci` 全绿:66 文件 / 690 通过 / 1 跳过(跳过项仅 `equipment.test.ts:306` 码 147)。
3. `pnpm exec eslint <files_modified 中每个改动文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。
4. Task 0 的 `<record>` 中 Q1..Q6 均有裁决,且代码与裁决一致(逐条在 SUMMARY 对照)。
5. 无新增依赖、无新建源码文件(`hero.test.ts` 等新建文件不在本计划)、`WINDOWS.md` 未改动、`data-common/src/save/system.ts` 未改动。
6. Phase 7 验证失效已在 `07-10-SUMMARY.md` 明写,`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表已更新。
</verification>
<success_criteria>
- `set()` 在长度变化的中间步与末步上均保持索引数组自洽(CR-01 关闭,与 `delete()` 同口径)。
- `#06-17-3`(审计 H)关闭:活跃读流跨 `ReplaySystem.loadState` 过期,不再按陈旧偏移解码。
- WR-01/WR-02 关闭:bigint 长度字节与参数计数都与实际写入内容一致,读流不再错位。
- WR-03 落地为 Q6 裁决的形式(版本字段或显式追加式约定 + 旧录像结论)并有测试/文档锚定。
- WR-07 关闭:越界 `index` 不再静默破坏缓冲区。
- `pnpm test:ci` 全绿、无新增 `it.skip`、既有断言未弱化;D-44 三步门禁全绿。
- Phase 7 验证失效与重跑要求已记录。
</success_criteria>
<output>
Create `.planning/phases/07-data-fixes/07-10-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,406 @@
---
phase: 07-data-fixes
plan: 11
type: execute
wave: 11
depends_on:
- 07-10
files_modified:
- packages-user/data-base/src/hero/equipStore.ts
- packages-user/data-base/src/flag/system.ts
- packages-user/data-base/src/hero/state.ts
- packages-user/data-base/src/hero/follower.ts
- packages-user/data-base/src/hero/types.ts
- packages-user/data-base/src/hero/equipStore.test.ts
- packages-user/data-base/src/hero/follower.test.ts
- packages-user/data-base/src/hero/saveLoad.test.ts
- packages-user/data-base/src/flag/system.test.ts
- packages-user/data-state/test/saveablesRoundTrip.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 62000
raw_tokens: 62000
tasks: 5
confidence: low
must_haves:
truths:
- "`HeroEquipsStore.loadState` 不再清空重建:同一 uid 的 `EquipmentState` 在读档前后为**同一实例**(`===` 成立),外部从 `get`/`instancesOf`/`instances` 持有的引用跨读档仍指向活 store 中的实例(`#06-17-4` / 审计 D 关闭)"
- "`FlagSystem.loadState` 不再清空重建:同一 key 的 `FlagCommonField` 在读档前后为**同一实例**,外部从 `getField`/`getOrInsert` 持有的引用跨读档仍读写到活字段(`#06-17-5` / 审计 E 关闭)"
- "`HeroState.loadState` 不再 `removeAllFollowers()` 全量重建:按裁决口径保留的 `IHeroFollower` 实例在读档前后为同一实例,其 `location`/`rendering` 原地读档(`#06-17-6` / 审计 F 关闭)"
- "保留实例后状态仍以存档为准:读档后的值/修饰器/槽位/位置与三档压缩的存档点一致(同引用不改变恢复结果)"
- "存档中不存在而内存中存在的实例(装备实例 / flag 字段 / follower)按 Task 0 裁决处置并有可观测断言"
- "`pnpm test:ci` 全绿,不新增 `it.skip`、不弱化既有断言;`hero/state.test.ts`、`flag/saveLoad.test.ts`、`hero/equipment.test.ts` 等既有用例不改写(Task 0 裁决要求调整的除外)"
artifacts:
- path: packages-user/data-base/src/hero/equipStore.ts
provides: "`HeroEquipsStore.loadState` 改为「按 uid 复用现有实例 + `instance.loadState(...)`」,多余实例按裁决销毁;`nextUid` 语义保持(IN-02 不在本计划范围)"
- path: packages-user/data-base/src/flag/system.ts
provides: "`FlagSystem.loadState` 改为「按 key 复用现有 `FlagCommonField` + `fromStructured`」,多余字段按裁决删除"
- path: packages-user/data-base/src/hero/state.ts
provides: "`HeroState.loadState` 的 followers 段改为保留式恢复(调用 `HeroFollowersController` 的保留式入口或既有个体 `loadState`),不再 `removeAllFollowers()` 全量重建"
- path: packages-user/data-base/src/hero/follower.ts
provides: "`HeroFollowersController` 的保留式恢复入口(按 Task 0 裁决:索引对齐复用 / 槽位替换 + 钩子语义),`HeroFollower` 未变或按裁决最小变更"
- path: packages-user/data-base/src/hero/types.ts
provides: "仅当 Task 0 裁决需要时:`IHeroFollowersController` 新增保留式恢复成员的声明与 jsDoc(接口源头、中文);`IHeroFollower` 契约如按裁决变更亦在此处"
- path: packages-user/data-base/src/hero/equipStore.test.ts
provides: "`#06-17-4` 回归:同 uid 实例跨 `loadState` 保持同一实例且读档后值与修饰器为存档点"
- path: packages-user/data-base/src/flag/system.test.ts
provides: "`#06-17-5` 回归:同 key 字段跨 `loadState` 保持同一实例且读档后值/结构化内容为存档点"
- path: packages-user/data-base/src/hero/follower.test.ts
provides: "`#06-17-6` 回归:按裁决口径的 follower 实例保留与位置/渲染恢复"
- path: packages-user/data-base/src/hero/saveLoad.test.ts
provides: "三档压缩下 `hero.loadState` 的装备实例 / follower 实例保真回归(与既有容器往返用例并存)"
- path: packages-user/data-state/test/saveablesRoundTrip.test.ts
provides: "顶层 `CoreState.loadState` 的跨读档同引用集成断言(装备实例、flag 字段、follower 实例)"
key_links:
- "`HeroEquipsStore.loadState`(`equipStore.ts:267-288`)的 `instanceMap.clear()`(`:271`)+ `new EquipmentState(...)`(`:278`)↔ 外部 `get`/`instancesOf`(`:201-253`)持有的实例——复用实例后二者指向同一对象"
- "`FlagSystem.loadState`(`flag/system.ts:65-72`)的 `fieldMap.clear()`(`:66`)+ `new FlagCommonField(...)`(`:68`)↔ 外部 `getField`/`getOrInsert`(`:16-35`)持有的实例"
- "`HeroState.loadState` 的 followers 段(`state.ts:148-152`)的 `removeAllFollowers()`(`:148`)+ `addFollower`(`:150`)↔ `HeroFollowersController.followers`(`follower.ts` 私有数组)与外部缓存的 `IHeroFollower`"
- "`HeroFollower.loadState`(`follower.ts` 中 `rendering.loadState` + `location.loadState`)——两个子对象本就原地读档,保留实例后无需新逻辑"
- "`HeroFollowersController.removeAllFollowers`(`follower.ts`)与 `removeFollower` 的 `onRemoveFollower` 钩子 ↔ 渲染端跟随者同步:保留式恢复是否仍发钩子由 Task 0 裁决决定"
- "`EquipmentState` 的 `item`(`equipStore.ts:16`)为 `readonly` 且构造期由 `itemStore.getData(save.num)` 决定 ↔ 同 uid 但 `save.num` 不同的存档(理论上不应出现):复用前必须比对 `item.num`,不同则按裁决处置"
assumptions:
- "A1(D-10 例外,本计划授权):本计划为修复计划,为 `#06-17-4/5/6` 新增同引用回归是必需项;既有断言的改写仅限 Task 0 裁决要求的情形,并逐条在 SUMMARY 说明"
- "A2(同引用原则延续):07-09 已为 `HeroAttribute` 确立「存读档在自身实例上原地完成」的原则;本计划把同一原则推广到装备实例、flag 字段与 follower 三类容器,是否逐子系统保留由 Task 0 裁决"
- "A3(项目未发布,与 07-09 的 A2 同口径):不承诺读回仓外旧存档;本计划不新增存档字段、不改存档形状,除非 Task 0 裁决明确要求"
- "A4(同步语义):`HeroState.loadState` 是同步方法,`HeroFollowersController.removeAllFollowers`/`removeFollower` 是 `async` 但方法体无 `await`(`follower.ts`),故保留式恢复可在同步读档路径内完成;实现不得改为异步"
- "A5(范围守卫):仅修 `#06-17-4/5/6`(审计 D/E/F);review 的 IN-02(空存档不重置 `nextUid`)与本计划无关,只登记不修"
prohibitions:
- "不修 `#06-17-7`(`data-fallback` hero Proxy)与 `#06-17-8`(`mapLayer.setMapRef`)——属 07-14 / 07-13"
- "不修 CR-01/CR-02 与 replay 编解码(07-10 / 07-13)"
- "不改 `HeroAttribute` 的存读档行为(07-09 已确立)、不改 `HeroItems.loadState`(审计判定为安全原地读档)、不改 `HeroLocation`/`HeroRendering` 的读档实现"
- "不改 `data-state/src/core.ts` 的 saveables 顺序与顶层存读档接线;不改渲染端 `@user/client-*` 与 legacy 接线"
- "不新增存档字段、不改 `IHeroStateSave`/`IHeroEquipsStoreSave`/`IFlagSystemSave` 形状(除非 Task 0 裁决明确要求)"
- "不引入新依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言;不为让套件转绿改写既有断言(Task 0 裁决情形除外)"
- "不新增 `it.skip`;`equipment.test.ts:306`(码 147,D-06)保持原样"
---
<objective>
把 07-09 确立的「同引用原则」推广到三个容器型 saveable:`HeroEquipsStore`(`#06-17-4` / 审计 D)、`FlagSystem`(`#06-17-5` / 审计 E)、`HeroFollowersController`(`#06-17-6` / 审计 F),使外部持有的 `IEquipmentState` / `IFlagCommonField` / `IHeroFollower` 引用跨读档仍然有效。
现状(逐条锚点):
1. **`#06-17-4`(中,审计 D)** `HeroEquipsStore.loadState`(`equipStore.ts:267-288`)先 `instanceMap.clear()`(`:271`)再逐个 `new EquipmentState(...)`(`:278`)——外部从 `get(uid)`/`instancesOf`/`instances`(`:201-257`)拿到的实例读档后与活 store 脱钩:继续改它不会进入存档,读它得到读档前的旧状态。
2. **`#06-17-5`(中/低,审计 E)** `FlagSystem.loadState`(`flag/system.ts:65-72`)先 `fieldMap.clear()`(`:66`)再 `new FlagCommonField(...)`(`:68`)——持有 `getField`/`getOrInsert` 结果的调用方(含 legacy 侧)保留的是被丢弃的字段对象;`saveState` 反过来枚举 `fieldMap`,故对保留实例的写入永久丢失。
3. **`#06-17-6`(中/低,审计 F)** `HeroState.loadState`(`state.ts:148-152`)先 `removeAllFollowers()`(`:148`)再 `addFollower(save.num)`(`:150`)——缓存的 `IHeroFollower` 及其 `location`/`rendering` 全部换成新实例,渲染端若持有旧引用即脱钩;`HeroFollower.loadState` 本身是原地读档(`follower.ts`),所以缺的只是「不要换实例」这一步。
Purpose: 数据层与渲染端之间靠实例传递状态,实例脱钩会让「读档后数值正确」取决于谁持有引用(同一个根因族 07-09 已在属性上消除)。容器层不修则属性层的修复仍会被绕过。
Output: `equipStore.ts` / `flag/system.ts` / `state.ts`(+ 按裁决的 `follower.ts` / `types.ts`)的保留式读档实现 + 三个子系统的同引用回归 + 顶层 `CoreState` 集成断言。
**Artifacts this phase produces(本计划产出物)**:修改文件见 frontmatter `files_modified`;回归用例见 `must_haves.artifacts`;无新建源码文件、无新增依赖。
**Phase 7 验证已失效**:`07-VERIFICATION.md`(2026-09-16T06:04:11Z)在 07-09 之前即已过期,本轮追加 07-10..07-14 后结论必然失效。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论。
Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 11 个计划,承接 `07-LOADSTATE-AUDIT.md` 的 D/E/F(`#06-17-4/5/6`)。D-02: 属 hero 与 flag 两个系统(`equipStore.ts` / `flag/system.ts` / `hero/state.ts`)。D-03/D-04/D-05/D-06: 其它系统契约裁决,不涉及。D-07: path 接线,不涉及。D-08: enemy 复用映射,不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划,回归用例为必需项(A1)。D-11: 保留式读档的口径变更需同步更新 `types.ts` 的 jsDoc。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按子系统原子提交(Task 1 / Task 2 / Task 3 + 集成断言)。D-14: 三条均无 `WINDOWS.md` 条目,只登记不新建。
执行序:`depends_on: 07-10`(Wave 11)。门禁型依赖(D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录),源码层面与 replay 无耦合。
**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147,D-06)。执行前重录当日基线并与本行对比。
</objective>
<execution_context>
@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/book/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md
@.planning/phases/07-data-fixes/07-REVIEW.md
@.planning/phases/07-data-fixes/07-09-PLAN.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@dev.md
@packages-user/data-base/src/hero/equipStore.ts
@packages-user/data-base/src/flag/system.ts
@packages-user/data-base/src/flag/field.ts
@packages-user/data-base/src/hero/state.ts
@packages-user/data-base/src/hero/follower.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 契约裁决关卡(D-09)——同引用保留范围(equipStore / flag / followers)、followers 保留口径与钩子语义、存档缺失实例的处置、诊断码</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState`(`item` 为 readonly、:46-55 `rebuildModifiers`、:104-164 三档 save/load)、:167-289 `HeroEquipsStore`(:201-257 `get`/`instancesOf`/`instances`、:267-288 `loadState` 的 clear + 重建))
- packages-user/data-base/src/flag/system.ts(:4-73 全文件,重点 :16-35 字段获取入口与 :65-72 `loadState`)
- packages-user/data-base/src/flag/field.ts(`FlagCommonField` 的 `value`/`key` 私有字段与 `fromStructured`)
- packages-user/data-base/src/hero/state.ts(:138-153 `loadState` 的 followers 段)
- packages-user/data-base/src/hero/follower.ts(`HeroFollower`(`num` 为 readonly、构造器按 num 解析、`loadState` 原地)、`HeroFollowersController`(`followers` 私有数组、`addFollower`/`getFollower`/`getAllFollowers`、`removeFollower`/`removeAllFollowers` 的 `onRemoveFollower` 钩子))
- packages-user/data-base/src/hero/types.ts(`IHeroFollower`、`IHeroFollowersController`、`IHeroEquipsStore`、`IEquipmentState` 的公开契约)
- packages-user/data-base/src/hero/saveLoad.test.ts(三档压缩既有夹具与 `HeroState` 容器往返用例)
- packages-user/data-state/test/saveablesRoundTrip.test.ts(`CoreState.saveState/loadState` 三档往返与 `seedState`/`mutateState`/`assertRestored` 夹具)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(D/E/F 行、判定为「安全」的同类边界、未确定项 2)
- .planning/phases/07-data-fixes/07-09-PLAN.md(同引用原则的既有裁决与实现口径)
</read_first>
<decision>
三个容器的保留式读档需要用户裁决:
(Q1)逐子系统是否保留实例(`equipStore` / `flag` / `followers`);(Q2)followers 的保留口径与 `onRemoveFollower`/`onAddFollower` 钩子语义;(Q3)存档中不存在而内存中存在的实例如何处置;(Q4)这些处置是否需要诊断、用哪些码。
</decision>
<context>
保留实例会把「以存档为准」的语义从「整表替换」改成「逐项合并」:必须回答「存档里没有的项怎么办」(删除 = 与今日等价;保留 = 合并语义),以及「同一 id 但内容指代不同」(`EquipmentState.item` 是构造期 readonly,uid 相同但 `num` 不同的存档无法原地复用)。followers 更复杂:`HeroFollower.num` 是 readonly,列表长度/顺序变化时无法原地改 num,且 `removeAllFollowers` 会触发 `onRemoveFollower`(渲染端可能依赖)——保留实例与保留钩子是两件事,需要用户拍定取哪个组合。以上均属用户主导的接口/契约范畴,AI 不自行选择。
</context>
<options>
<option id="q1-all">
<name>Q1 选项 A:【建议默认】三个子系统全部保留实例(`equipStore` / `flag` / `followers` 都按同引用原则改)</name>
<pros>与 07-09 在 `HeroAttribute` 上确立的原则一致;三个缺陷同根因,一次收口;外部持有者(含渲染端与 legacy 侧)不需要任何重取逻辑</pros>
<cons>三处 `loadState` 都要从「整表替换」改为「逐项合并」,需要同时定义「多余项」的处置与错误分支;实现面比逐个替换大</cons>
</option>
<option id="q1-partial">
<name>Q1 选项 B:只保留部分子系统(由用户在记录中点名哪几个保留、哪几个维持现状并登记为已接受契约)</name>
<pros>把改动面压到用户认为真正有外部持有者的子系统</pros>
<cons>未保留的子系统保留「脱钩」契约,必须在 jsDoc 与 SUMMARY 明写;同一原则在不同容器上口径不一致,后续容易再次踩坑</cons>
</option>
<option id="q1-none">
<name>Q1 选项 C:三个都维持现状,只登记为已知契约(`loadState` 会替换容器内实例)</name>
<pros>零改动、零风险</pros>
<cons>与「本轮把这 15 项纳入修复」的指令冲突(清单第 7–9 条);与 07-09 刚确立的同引用原则自相矛盾</cons>
</option>
<option id="q2-index-inplace">
<name>Q2 选项 A:【建议默认】followers 按索引对齐:同索引且 `num` 相同 → 原地 `loadState`(保留实例);`num` 不同 → 该槽位替换并按既有钩子语义发 `onRemoveFollower`/`onAddFollower`;数量变化同样按替换 + 删除/新增处理</name>
<pros>不改 `IHeroFollower`(`num` 维持 readonly);最常见的「读同一存档」路径零脱钩;钩子语义与今日一致(渲染端不需要改)</pros>
<cons>`num` 不同的槽位仍会换实例(外部持有该槽位的引用会脱钩)——需在 jsDoc 写明该边界</cons>
</option>
<option id="q2-mutable-num">
<name>Q2 选项 B:把 `num` 改为可变(`IHeroFollower.num` 去掉 readonly + 内部更新方法),任何情况都原地复用实例,load 期间不发 remove/add 钩子</name>
<pros>实例身份在所有情形下都保留;读档不再触发跟随者增删副作用</pros>
<cons>公开契约变更(`readonly num` → 可写);渲染端若依赖 `onRemoveFollower`/`onAddFollower` 重同步,读档后将不再收到通知,需要另定同步方式</con>
</option>
<option id="q2-no-preserve">
<name>Q2 选项 C:followers 维持 `removeAllFollowers()` + `addFollower()` 全量重建,只登记契约</name>
<pros>不动 follower 相关代码</pros>
<cons>`#06-17-6` 未修;与 Q1 选项 A 冲突(若 Q1 选 A,本选项只能用于 followers 例外)</cons>
</option>
<option id="q3-delete">
<name>Q3 选项 A:【建议默认】以存档为准:内存中多出的装备实例 / flag 字段 / follower 一律删除(与今日 `clear()` 的可观测结果等价,只是不再波及存档里存在的项)</name>
<pros>读档结果与今日完全一致(不会留下「存档里没有的旧数据」);语义简单、易断言</pros>
<cons>被删除实例的外部持有者仍然脱钩(但这与「该实例在存档点不存在」一致,属正确语义)</cons>
</option>
<option id="q3-keep">
<name>Q3 选项 B:合并语义:存档中不存在的项**保留**(不删除),读档只覆盖存档里出现的项</name>
<pros>外部持有的实例全都不会消失</pros>
<cons>读档不再等价于「回到存档点」:存档里已不存在的装备/字段会残留,可能与后续存档产生漂移(`saveState` 会把它们重新写入);语义风险明显,需要用户在记录中承担该后果</con>
</option>
<option id="q4-existing">
<name>Q4 选项 A:不新增码,复用既有诊断——followers 用 91 / 142(既有「follower number 不存在」「id 解析失败」码),装备用 58/59,flag 无专用码则不打诊断(删除静默)</name>
<pros>不动 `packages/common/src/logger.json`(延续 07-09「不新增错误码」惯例)</pros>
<cons>91/142 的文案原意与本场景不完全相符(它们是「添加不存在的老鼠」/「id 解析失败」);借用会让日志语义变模糊</cons>
</option>
<option id="q4-new">
<name>Q4 选项 B:新增 replay 之外的通用 warn 码(当前最大 warn 码 178 之后的可用号)用于「读档时丢弃了存档外的实例/字段」,携带类别与标识</name>
<pros>诊断精确、语义清晰;码唯一不复用</pros>
<cons>需改 `packages/common/src/logger.json`;需要用户确认这条诊断值得进码表</cons>
</option>
<option id="q4-none">
<name>Q4 选项 C:不打任何诊断(删除静默)</name>
<pros>零码表改动、零日志噪音</pros>
<cons>外部持有者被解绑时无任何线索;排查「读档后我的引用失效了」只能靠读代码</cons>
</option>
<option id="revise">
<name>(兜底)修订方案后再执行</name>
<pros>避免带错误契约落地</pros>
<cons>按 D-09 退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法</cons>
</option>
</options>
<record>
待用户在关卡处逐条填写(Q1..Q4:勾选选项 id 或给出裁决文字)。
**空的 `<record>` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。
填写后 Task 1–4 一律按本记录执行;若 Q1 选了「部分保留」,请在记录中点名保留哪些子系统,Task 1/2/3 中被排除的子系统跳过实现、只保留登记。
</record>
<resume-signal>逐条给出 Q1..Q4 的裁决(例如「Q1=A, Q2=A, Q3=A, Q4=C」);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md</resume-signal>
<action>向用户汇报上述 4 个契约点及其对三个子系统的影响(可连带汇报 Task 1–4 的实现路径,便于一次确认)。不得在裁决前修改任何文件。</action>
<verify>
<human-check>`<record>` 中 Q1..Q4 均有明确裁决(Q1 若为部分保留,已点名子系统),且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进</fails_when>
</verify>
<acceptance_criteria>
- Q1(保留范围,逐子系统)、Q2(followers 保留口径与钩子语义)、Q3(存档缺失实例处置)、Q4(诊断码)均有用户裁决并写入 `<record>`
- 裁决与 Task 1–4 的分支描述可对应;未在裁决前修改任何文件
</acceptance_criteria>
<done>Q1..Q4 裁决已完整写入 `<record>`;执行者据记录直接进入 Task 1,不再重复询问</done>
</task>
<task type="auto">
<name>Task 1: `#06-17-4` —— `HeroEquipsStore.loadState` 保留实例(按 uid 复用 + 原地 `EquipmentState.loadState`),多余实例按裁决处置</name>
<files>packages-user/data-base/src/hero/equipStore.ts, packages-user/data-base/src/hero/equipStore.test.ts, packages-user/data-base/src/hero/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState` 的 `uid`/`item`/`loadState`、:167-289 `HeroEquipsStore` 的 `instanceMap`/`nextUid`/`add`/`get`/`instancesOf`/`saveState`/`loadState`(:267-288 缺陷本体))
- packages-user/data-base/src/hero/equipStore.test.ts(现有 `HeroEquipsStore instances` / `EquipmentState modifiers` 用例夹具)
- packages-user/data-base/src/hero/saveLoad.test.ts(三档压缩夹具与 `#06-09-1` 的加成恢复用例,注意不要弱化)
- packages-user/data-base/src/hero/types.ts(`IHeroEquipsStore`、`IEquipmentState` 的公开契约与 jsDoc 位置)
</read_first>
<action>
按 Task 0 的 Q1/Q3/Q4 裁决重写 `HeroEquipsStore.loadState`(`equipStore.ts:267-288`)。**若 Q1 判 equipStore 保留**(推荐默认):
(1) 遍历 `state.equipments`,对每条 `save`:`const raw = this.state.itemStore.getData(save.num)`;`raw` 为空时保持既有 `logger.error(59, …)` + `continue`;否则 `const existing = this.instanceMap.get(save.uid)`:若 `existing` 存在且 `existing.item.num === save.num` → `existing.loadState(save, compression)`(**不新建实例**);否则 → 新建 `new EquipmentState(save.uid, raw, false)`(`buildModifier = false` 与现状一致)、`instance.loadState(save, compression)`、`this.instanceMap.set(save.uid, instance)`;
(2) 按 Q3 裁决处置「存档中不存在的 uid」:选项 A(以存档为准)→ 收集存档 uid 集合,遍历 `this.instanceMap.keys()`,不在集合中的按裁决删除(Q4 选项 A 不打诊断 / B 打新码 / C 静默);选项 B(合并)→ 不删除,并在 jsDoc 写明合并语义;
(3) `nextUid` 段(`:282-287`)保持不变(`maxBy(state.equipments, 'uid')` → `nextUid = maxUid.uid + 1`,空存档走既有 `logger.error(58)` 分支)——IN-02 不在本计划范围,不得顺手改动;
(4) 在 `IHeroEquipsStore.loadState` 的 jsDoc(`hero/types.ts`)补写「同一 uid 的实例在读档前后为同一实例;存档中不存在的实例按 {裁决} 处置」。
**若 Q1 判 equipStore 不保留**:不修改行为,仅在 SUMMARY 登记「用户裁决维持现状」。
回归用例:
(a) `equipStore.test.ts`:`add(num)` → 取 `const before = store.get(uid)` → 改一下活实例(如 `before.getModifiers()` 对应的修饰器 `setValue`,或先 `loadState` 前修改活状态以便观察)→ `saveState(三档)` → 再改动 → `loadState(save, 三档)` → 断言 `store.get(uid) === before`(同实例)且实例上读到的状态等于存档点;
(b) `saveLoad.test.ts`:`HeroState` 容器三档往返中,断言 `hero.items.equipment.get(uid)` 读档前后同实例;若 Task 0 裁决 Q3 为删除,另加一条「存档中不存在的 uid 读档后被移除(且按 Q4 裁决有/无诊断)」;
(c) 断言 `EquipmentState` 的加成恢复(`#06-09-1` 既有语义)在保留实例路径下不回归。
用例中文单行注释说明覆盖内容(`dev.md:85`)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/equipStore.test.ts packages-user/data-base/src/hero/saveLoad.test.ts</automated>
<fails_when>读档后 `store.get(uid)` 返回的不是读档前持有的实例(Q1 判保留时);或三档压缩下装备加成/槽位恢复结果与既有断言不一致 —— 按 D-09 退出并修订计划,不得改用「读档后让调用方重取」的替代方案</fails_when>
</verify>
<acceptance_criteria>
- 同一 uid 的 `EquipmentState` 在读档前后为同一实例(Q1 判保留时),且读档后的值与修饰器等于存档点
- 存档中不存在的实例处置与 Q3/Q4 裁决一致并有断言
- `nextUid` 段与 IN-02 未被改动;既有 `equipStore.test.ts` / `saveLoad.test.ts` 断言未弱化
</acceptance_criteria>
<done>`HeroEquipsStore.loadState` 不再让外部持有的 `EquipmentState` 脱钩,且恢复结果仍以存档为准(`#06-17-4` / 审计 D 关闭)</done>
</task>
<task type="auto">
<name>Task 2: `#06-17-5` —— `FlagSystem.loadState` 保留字段实例(按 key 复用 + `fromStructured`),多余字段按裁决处置</name>
<files>packages-user/data-base/src/flag/system.ts, packages-user/data-base/src/flag/system.test.ts</files>
<read_first>
- packages-user/data-base/src/flag/system.ts(:4-73 全文件,重点 :16-35 `getField`/`getOrInsert`/`getOrInsertComputed`、:57-63 `saveState`、:65-72 `loadState` 缺陷本体)
- packages-user/data-base/src/flag/field.ts(`FlagCommonField` 的 `set`/`get`/`toStructured`/`fromStructured`)
- packages-user/data-base/src/flag/system.test.ts(现有 `FlagSystem field container` / `FlagSystem value accessors` 用例与 `logger.catch` 口径)
- packages-user/data-base/src/flag/saveLoad.test.ts(既有 flag 三档往返用例)
- packages-user/data-base/src/flag/types.ts(`IFlagSystem` / `IFlagCommonField` / `IFlagSystemSave` 契约与 jsDoc 位置)
</read_first>
<action>
按 Task 0 的 Q1/Q3/Q4 裁决重写 `FlagSystem.loadState`(`system.ts:65-72`)。**若 Q1 判 flag 保留**(推荐默认):
(1) 遍历 `state.fields`,对 `[key, data]`:`const field = this.fieldMap.get(key)`;若存在 → `field.fromStructured(data)`(原地写值,与 `saveState` 的 `toStructured` 对称)、`this.fieldMap.set(key, field)`;否则 → `const created = new FlagCommonField<unknown>(this, key, undefined); created.fromStructured(data); this.fieldMap.set(key, created)`;
(2) 按 Q3 裁决处置「存档中不存在的 key」:选项 A → 收集存档 key 集合并按裁决删除(Q4 选项 A 无 flag 专用码时静默 / B 打新码 / C 静默);选项 B → 保留(合并语义须写入 jsDoc);
(3) 在 `IFlagSystem.loadState` 的 jsDoc(`flag/types.ts`)补写同引用契约与多余字段处置。
**若 Q1 判 flag 不保留**:不修改行为,仅在 SUMMARY 登记「用户裁决维持现状」。
回归用例(`flag/system.test.ts`):
(a) 取字段引用 `const before = system.getField('score')`(或 `getOrInsert`)→ 写值 → `saveState()` → 再改值 → `loadState(save)` → 断言 `system.getField('score') === before`(同实例)且 `before.get()` 等于存档点的值(而不是改后的值);
(b) `getOrInsert` 在 load 覆盖后仍返回同一实例;
(c) 按 Q3 裁决断言「存档外的字段」读档后存在/不存在,并按 Q4 裁决断言诊断(`logger.catch` 口径)或无诊断;
(d) 既有 `flag/saveLoad.test.ts` 的三档往返断言保持通过(不改写)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/flag/system.test.ts packages-user/data-base/src/flag/saveLoad.test.ts</automated>
<fails_when>读档后 `getField(key)` 返回的不是读档前持有的实例(Q1 判保留时);或保留实例上的值不等于存档点(说明 `fromStructured` 未在原实例上生效)—— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 同一 key 的 `FlagCommonField` 在读档前后为同一实例(Q1 判保留时),且其值等于存档点
- 存档外字段的处置与 Q3/Q4 裁决一致并有断言
- `IFlagSystem.loadState` 的 jsDoc 与最终语义一致;既有断言未弱化
</acceptance_criteria>
<done>`FlagSystem.loadState` 不再让外部持有的字段引用脱钩,读档值仍以存档为准(`#06-17-5` / 审计 E 关闭)</done>
</task>
<task type="auto">
<name>Task 3: `#06-17-6` —— followers 保留式恢复(按 Q2 裁决:索引对齐原地 `loadState` / 槽位替换 + 钩子语义)</name>
<files>packages-user/data-base/src/hero/state.ts, packages-user/data-base/src/hero/follower.ts, packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/follower.test.ts, packages-user/data-base/src/hero/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/state.ts(:138-153 `loadState` 的 followers 段与 `void this.followers.removeAllFollowers()`)
- packages-user/data-base/src/hero/follower.ts(`HeroFollower` 的 `num` readonly 与 `loadState`、`HeroFollowersController` 的 `followers` 私有数组、`addFollower`/`getFollower`/`getAllFollowers`/`removeFollower`/`removeAllFollowers` 及各自的钩子触发)
- packages-user/data-base/src/hero/types.ts(`IHeroFollower`(`readonly num`)、`IHeroFollowersController`、`IHeroFollowersControllerHooks`(`onAddFollower`/`onRemoveFollower`))
- packages-user/data-base/src/hero/follower.test.ts(现有 follower 用例夹具与钩子断言口径)
- packages-user/data-base/src/hero/saveLoad.test.ts(`HeroState` 容器三档往返中的 followers 断言)
</read_first>
<action>
按 Task 0 的 Q2 裁决实现保留式恢复。**若 Q2 = 选项 A(索引对齐)**(推荐默认):
(1) `HeroFollowersController` 增加一个保留式恢复入口(命名由实现者定,如 `restoreFollowers(saves, compression)`):按索引遍历 `saves`,对第 i 项:若 `this.followers[i]` 存在且 `this.followers[i].num === save.num` → 调用其 `loadState(save, compression)`(**保留实例**,不发钩子);否则 → 按既有顺序触发 `onRemoveFollower`(若该槽位原有 follower)、构造新的 `HeroFollower`(复用 `addFollower` 的定位/朝向初始化口径)、并触发 `onAddFollower`;`saves` 短于现有时,末尾多余项按 Q3 裁决删除;
(2) `HeroState.loadState`(`state.ts:148-152`)改为调用该入口,删除 `void this.followers.removeAllFollowers()` 与 `addFollower` 循环;**保持同步**(`HeroState.loadState` 不得改为 async,A4);
(3) 契约声明放接口源头:若新增入口,则在 `hero/types.ts` 的 `IHeroFollowersController` 上补声明与中文 jsDoc(写清「同索引同 num 保留实例、否则替换,并说明钩子触发条件」)。
**若 Q2 = 选项 B(num 可变)**:按裁决把 `IHeroFollower.num` 去掉 `readonly` 并允许原地更新(实现方式由记录指定),同时按裁决在 load 期间不发 remove/add 钩子;在 jsDoc 写明渲染端同步方式的变更。
**若 Q2 = 选项 C(不保留)**:不修改行为,仅在 SUMMARY 登记「用户裁决维持现状」。
回归用例:
(a) `follower.test.ts`:`addFollower(num)` → 取引用 → `saveState` → 改位置/数量 → 恢复 → 断言保真口径(选项 A:同索引同 num 为同一实例;按 Q2/Q3 裁决断言 num/数量变化时的替换与钩子);
(b) `saveLoad.test.ts`:`HeroState` 三档往返中,跟随者数量与位置恢复;按裁决断言 `getAllFollowers()[0]` 读档前后同实例(同索引同 num 时);
(c) 断言 `HeroFollower.location` / `rendering` 的恢复仍走既有原地 `loadState`(不新增第三条读档路径)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/follower.test.ts packages-user/data-base/src/hero/saveLoad.test.ts</automated>
<fails_when>`HeroState.loadState` 仍然调用 `removeAllFollowers()` 全量重建(Q2 判保留时);或读档后跟随者位置/数量与存档点不一致;或 `HeroState.loadState` 被改成 async —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 按 Q2 裁决:同索引同 `num` 的 follower 实例在读档前后为同一实例(选项 A/B),或明确维持现状(选项 C)
- 钩子语义与 Q2 裁决一致并有断言(`onRemoveFollower`/`onAddFollower` 何时触发)
- `HeroState.loadState` 仍为同步方法;`HeroFollower.loadState` 未被替换为新的读档路径
</acceptance_criteria>
<done>followers 不再因读档全量换实例(按裁决口径),位置/渲染恢复仍以存档为准(`#06-17-6` / 审计 F 关闭)</done>
</task>
<task type="auto">
<name>Task 4: 顶层同引用集成断言 + D-12/D-44 门禁 + 全量套件 + 缺口登记与 Phase 7 验证失效说明</name>
<files>packages-user/data-state/test/saveablesRoundTrip.test.ts, .planning/phases/07-data-fixes/07-11-SUMMARY.md</files>
<read_first>
- packages-user/data-state/test/saveablesRoundTrip.test.ts(`createCoreState`/`seedState`/`mutateState`/`assertRestored` 夹具与三档往返循环、码 178 相关既有断言)
- packages-user/data-state/src/core.ts(:248-252 saveables 注册顺序、`loadState` 的 177/178 判定——本计划不改动,仅用于理解触发路径)
- packages-user/data-base/src/hero/saveLoad.test.ts、flag/system.test.ts、hero/follower.test.ts、hero/equipStore.test.ts(Task 1–3 新增用例,用于避免重复设计)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(D/E/F 行与「未确定项 2」——渲染端是否跨读档保留实例,本任务用顶层断言把该不确定性在数据端封口)
- .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status)
- .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表)
- .planning/WINDOWS.md(确认三条无入账 id)
</read_first>
<action>
(1) **顶层集成断言**(`saveablesRoundTrip.test.ts`):在既有三档循环里新增一组同引用断言——`CoreState` 初始化并装备/写 flag/加 follower 后,取 `state.hero.items.equipment.get(uid)`、`state.flags.getField(key)`(或既有 flag 访问入口)、`state.hero.followers.getFollower(0)` 三个引用;`saveState` → 改动活状态(装备修饰器/flag 值/follower 位置)→ `loadState` → 断言三个引用 `===` 读档前的实例(按 Q1 裁决的子系统),且其值/位置等于存档点。用例中文单行注释说明覆盖内容。
(2) **门禁(D-12 / D-44 文件级)**:对 `files_modified` 中每个改动的源码/测试文件执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误;`pnpm test:ci` 全绿(对比基线:66 文件 / 690 通过 / 1 跳过,跳过项仍只能是 `equipment.test.ts:306` 码 147)。
(3) **原子提交(D-13)**:Task 1 / 2 / 3 / 4 各自独立提交(`fix(07-11): …`,携带 `#06-17-4` / `#06-17-5` / `#06-17-6` 锚点)。
(4) **缺口登记**:SUMMARY 的 findings/ledger 段落逐条标注 `#06-17-4/5/6`(审计 D/E/F)已闭合(附 commit),并登记 review 的 `IN-02`(空存档不重置 `nextUid`)为「本轮不修,保留在册」;`WINDOWS.md` 不新建、不 `fixed`/`waive`。
(5) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`;或顶层集成断言显示读档前后三个引用任一不是同一实例(Q1 判保留的子系统)—— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 顶层 `CoreState` 三档往返中,按裁决保留的容器实例跨读档为同一实例且状态等于存档点
- 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条
- Task 1–4 各有独立提交,含对应锚点;SUMMARY 完成缺口登记(D/E/F 闭合 + IN-02 保留)与 Phase 7 验证失效说明
- `WINDOWS.md` 未被本计划改动
</acceptance_criteria>
<done>顶层集成断言证明三个容器的外部引用跨读档仍然有效;D-44 三步门禁全绿、原子提交完成、缺口登记与验证失效说明写入 SUMMARY</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 存档数据 → 容器实例 | 读档是「外部数据定义内存状态」的边界;容器若整表替换,外部持有者的引用即脱钩 |
| 数据层 → 渲染端/legacy 持有者 | 渲染端与 legacy 侧持有 `IEquipmentState` / `IFlagCommonField` / `IHeroFollower` 引用,跨读档的引用有效性由本计划定义 |
| load 钩子 → 渲染端同步 | `onRemoveFollower`/`onAddFollower` 是数据端向渲染端广播增删的通道,保留式恢复可能改变其触发频率 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-25 | Tampering | `HeroEquipsStore.loadState`(`equipStore.ts:267-288`) | medium | mitigate | Task 1:按 uid 复用 `EquipmentState` 并原地 `loadState`;同 uid 但 `item.num` 不同时新建(`item` 为构造期 readonly),多余 uid 按裁决处置 |
| T-7-26 | Tampering | `FlagSystem.loadState`(`flag/system.ts:65-72`) | medium | mitigate | Task 2:按 key 复用 `FlagCommonField` 并 `fromStructured`(与 `toStructured` 对称),多余 key 按裁决处置 |
| T-7-27 | Tampering | `HeroState.loadState` followers 段(`state.ts:148-152`) | medium | mitigate | Task 3:索引对齐原地 `loadState`(或按裁决的等价口径),保留实例;`num`/数量变化按裁决替换并显式处理钩子 |
| T-7-28 | Repudiation | 读档丢弃/保留实例无诊断 | low | mitigate | Task 0 Q4 裁决决定是否诊断;若选新码则登记文案并带类别+标识,若静默则在 jsDoc 与 SUMMARY 明写 |
| T-7-29 | Elevation of Privilege | 保留实例后旧状态残留(合并语义) | medium | mitigate | Task 0 Q3 裁决;若选合并语义,必须在 jsDoc + SUMMARY 明写「读档不再等价于回到存档点」的后果,并有断言覆盖 |
| T-7-30 | Denial of Service | `HeroState.loadState` 改异步导致调用点行为变化 | low | mitigate | A4:保留同步;Task 3 `<fails_when>` 显式拦截 async 化 |
| T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 |
</threat_model>
<verification>
1. `pnpm exec vitest run packages-user/data-base/src/hero/equipStore.test.ts packages-user/data-base/src/flag/system.test.ts packages-user/data-base/src/hero/follower.test.ts packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-state/test/saveablesRoundTrip.test.ts` 全绿。
2. `pnpm test:ci` 全绿:66 文件 / 690 通过 / 1 跳过(仅 `equipment.test.ts:306` 码 147)。
3. `pnpm exec eslint <files_modified 中每个改动文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。
4. Task 0 的 `<record>` 中 Q1..Q4 均有裁决,代码与裁决逐条一致(SUMMARY 对照表)。
5. 无新增依赖、无新建源码文件、`WINDOWS.md` 未改动、`data-state/src/core.ts` 未改动(除集成断言所在测试文件外)。
6. Phase 7 验证失效与重跑要求已在 `07-11-SUMMARY.md` 明写,`ROADMAP.md` 已更新。
</verification>
<success_criteria>
- `#06-17-4/5/6`(审计 D/E/F)按用户裁决闭合:装备实例 / flag 字段 / follower 的外部引用跨读档不再脱钩(或按裁决明写为已接受契约),并有回归断言。
- 保留实例不改变恢复结果:三档压缩下的值、修饰器、槽位、位置仍等于存档点。
- 存档缺失实例的处置与诊断符合 Q3/Q4 裁决,且有断言与文档。
- `pnpm test:ci` 全绿、无新增 `it.skip`、既有断言未弱化;D-44 三步门禁全绿。
- Phase 7 验证失效与重跑要求已记录;`IN-02` 已登记为不在本轮修复。
</success_criteria>
<output>
Create `.planning/phases/07-data-fixes/07-11-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,390 @@
---
phase: 07-data-fixes
plan: 12
type: execute
wave: 12
depends_on:
- 07-11
files_modified:
- packages-user/data-base/src/hero/equipStore.ts
- packages-user/data-base/src/hero/types.ts
- packages-user/data-base/src/hero/attribute.ts
- packages-user/data-base/src/hero/equipment.ts
- packages-user/data-base/src/hero/saveLoad.test.ts
- packages-user/data-base/src/hero/attribute.test.ts
- packages-user/data-base/src/hero/equipment.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 58000
raw_tokens: 58000
tasks: 5
confidence: low
must_haves:
truths:
- "装备存档以**活修饰器值**为准(按 Task 0 Q1 裁决):运行时对装备修饰器的 `setValue(...)` 在三档压缩下都能读回(WR-04 关闭),或按裁决明写「运行时修饰器变更不入存档」并在 `IEquipmentState` 上留下契约说明"
- "`HeroAttribute.deleteModifierByIndex` 与 `deleteModifier` 的簿记一致:`bindAttribute(null)`、清理 `modifierName`/`modifierNosave`、`markDirty(name)`;被删修饰器可被再次 `addModifier`(不再因残留 `owner` 被码 108 拒绝)(WR-05 关闭)"
- "`HeroAttribute.iterateModifiers()` / `getModifierIndex()` 在 `deleteModifierByIndex` 之后不再产出/解析已删除的修饰器;`finalAttribute` 不再停留在陈旧值"
- "`HeroEquipment.loadState` 不再经 `equip()` 追加 `Equip` 录像指令:读档期间 `replaySystem.route.add` 不被调用(WR-06 关闭,按 Task 0 Q3 裁决口径)"
- "`pnpm test:ci` 全绿,不新增 `it.skip`;`equipment.test.ts:306`(码 147,D-06)保持原样"
artifacts:
- path: packages-user/data-base/src/hero/equipStore.ts
provides: "`EquipmentState` 的三档 save/load 改为以活修饰器值为存档源(按 Q1 裁决),保持 `IEquipmentStateSave` 形状或按裁决调整"
- path: packages-user/data-base/src/hero/types.ts
provides: "`IEquipmentState`/`IEquipmentStateSave` 的契约 jsDoc(存档语义:活修饰器值 vs 原始定义差异;或按 Q1 裁决写入「运行时修饰器变更不入存档」)"
- path: packages-user/data-base/src/hero/attribute.ts
provides: "`deleteModifierByIndex` 的完整簿记(与 `deleteModifier` 对齐),返回被删除的修饰器"
- path: packages-user/data-base/src/hero/equipment.ts
provides: "`HeroEquipment.loadState` 的无录像副作用还原(按 Q3 裁决:disable/revert 包裹或内部无记录装备助手);按 Q4(ii) 裁决处理 `equipment.test.ts` 相邻缺陷"
- path: packages-user/data-base/src/hero/saveLoad.test.ts
provides: "WR-04 三档回归(活值往返或契约说明)、WR-06 读档无录像副作用回归、WR-05 相关容器级断言"
- path: packages-user/data-base/src/hero/attribute.test.ts
provides: "WR-05 回归:`deleteModifierByIndex` 后 iterate/getModifierIndex/final 值/可重新挂载"
- path: packages-user/data-base/src/hero/equipment.test.ts
provides: "WR-06 回归:`loadState` 期间 `route.add` 未被调用(基于既有 `replaySystem` 桩)"
key_links:
- "`EquipmentState.modifiers` 与 `this.value`/`this.percentage`(`equipStore.ts:19-36`、`:46-55` `rebuildModifiers`)↔ `saveNoCompression`/`saveDiff`(`:63-102`)——存档源必须与活修饰器一致,否则 `saveDiff` 恒为空(WR-04 根因)"
- "`BaseHeroModifier.saveState`/`loadState`/`getValue`(`modifier.ts:30-44`)↔ `EquipmentState` 的存档——修饰器自带值序列化能力,无需新增修饰器 API"
- "`deleteModifierByIndex`(`attribute.ts:230-239`)↔ `deleteModifier`(`:213-228`)——两套簿记必须同构,否则 `modifierName`/`owner` 残留"
- "`HeroEquipment.loadState`(`equipment.ts:337-347`)→ `equip()`(`:153-198`)→ `replay.route.add(ReplayCommandCode.Equip, [uid])`(`:195`)——读档经公开入口产生了真实录像副作用"
- "`HeroEquipment.equip/unequip` 的 `replay.disable()`/`revert()`(`:169-170`/`:194`/`:211-212`)↔ `ReplaySystem.route`(`data-common/src/replay/system.ts:89-95`,`disable`/`revert` 直接转发到 `ReplayArray`)——Q3 选项 A 依赖该既有通道"
- "`compareEquip` 的 `this.attribute.getModifierIndex(modifier)` + `clone.deleteModifierByIndex(name, index)`(`equipment.ts:281-284`)——索引取自原属性却作用于克隆属性(相邻缺陷,按 Q4(ii) 裁决处置)"
assumptions:
- "A1(D-10 例外,本计划授权):本计划为修复计划。Q1 若选「活修饰器值为存档源」,则 `saveLoad.test.ts:343-380` 中「读档后回到原始定义值」的既有断言必须改为「回到存档点的活值」——该改写是本计划唯一被授权的既有断言变更,必须在 SUMMARY 逐条说明;其余既有断言一字不改"
- "A2(项目未发布,与 07-09 的 A2 同口径):`IEquipmentStateSave` 形状可变更,不保留旧形状兼容分支;本计划优先保持形状、只改语义(Q1 选项 A 可在现有 `value`/`percentage` 两个 Map 上表达活值与定义的差异)"
- "A3(不新增修饰器 API):`BaseHeroModifier` 已有 `saveState`/`loadState`/`getValue`/`setValue`,本计划**不**修改 `modifier.ts`;若实现中发现必须改修饰器基类,暂停并回到 Task 0 关卡追加裁决,不得自行扩大改动面"
- "A4(范围守卫):仅修 WR-04 / WR-05 / WR-06 三条;`IN-02`(空存档不重置 `nextUid`)与 `IN-04`/`IN-05` 不在本轮清单,只登记不修;`ReplayArray.setReplayArray` 的 `disabled = 0` 重置(`array.ts:807`)属 07-10 的文件,本计划不改"
prohibitions:
- "不改 `ReplayArray`/`ReplaySandbox`/`ReplaySystem`(`packages-user/data-common/src/replay/*`)——Q3 的修法只能在 `equipment.ts` 侧实现"
- "不改 `modifier.ts` 的 `BaseHeroModifier`(A3);不改 `attribute.ts` 的 `addModifier`/`deleteModifier`(只动 `deleteModifierByIndex` 与其余按 Q2 裁决点名的部分)"
- "不改 `HeroState`/`equipStore` 的存读档**实例身份**语义(07-11 已定);本计划不重新引入 `clear()` + 重建"
- "不改 `data-state`(`core.ts` / saveables 顺序)、不改渲染端 `@user/client-*` 与 legacy 接线"
- "不引入新依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言"
- "不新增 `it.skip`;不为了转绿而弱化断言(A1 授权的两处除外)"
---
<objective>
闭合装备/属性子系统的三条存档正确性缺陷:装备数值加成的**存档源**(WR-04)、修饰器删除的**簿记**(WR-05)、读档期的**录像副作用**(WR-06)。
现状(逐条锚点):
1. **WR-04(warning,`equipStore.ts:34-36, 67-102, 259-288`)** `this.value`/`this.percentage` 只在构造期从 `item.equip` 拷贝、此后只被另一次读档覆盖;活修饰器值(`BaseHeroModifier.setValue`,`modifier.ts:25-28`)从不回流。后果:`saveDiff()`(`:80-102`)与 `item.equip` 比较恒相等 → 低/高压缩档「无差异」→ 装备状态**一个字节都不存**;`saveNoCompression()`(`:67-74`)存的也是 `this.value` 而非修饰器值 → 无压缩档同样丢弃运行时变更。`07-REVIEW.md` 指出 `saveLoad.test.ts:343-380` 恰好把这个错误行为写成了断言(改修饰器到 0.9/99,断言读回 0.5/5)。
2. **WR-05(warning,`attribute.ts:230-239`)** `deleteModifierByIndex()` 只 `splice`:不 `bindAttribute(null)`、不清理 `modifierName`/`modifierNosave`、不 `markDirty`。后果:`iterateModifiers()`/`getModifierIndex()` 仍能看到已删除的修饰器;被删修饰器 `owner` 残留 → 再次 `addModifier` 被码 108 拒绝(永久不可用);`finalAttribute[name]` 陈旧。唯一调用点 `compareEquip`(`equipment.ts:281-284`)。
3. **WR-06(warning,`equipment.ts:336-346`)** `loadState()` 通过公开 `equip(uid, index)`(`:168-196`,末尾 `:195` 有 `replay.route.add(ReplayCommandCode.Equip, [uid])`)恢复槽位,读档因此向活录像追加 `Equip` 指令。今日只被 `CoreState.loadState` 的 saveable 顺序掩盖(hero 在 replay 之前,随后被覆盖),英雄单独读档或存档缺 `@system/replay` 时污染录像。
Purpose: 装备是属性加成的来源,装备存档一旦不落地,「读档后数值正确」就只在无压缩档的部分路径上成立;删除簿记不全则修饰器图与访问器视图分叉;读档写录像会让「读档」这个只读语义产生可回放的历史。三者都在存档边界上,属同一主题。
Output: `equipStore.ts`(存档源)+ `attribute.ts`(簿记)+ `equipment.ts`(读档无副作用)+ `types.ts`(契约 jsDoc)+ 三份测试文件的回归。
**Artifacts this phase produces(本计划产出物)**:修改文件见 frontmatter `files_modified`;回归用例见 `must_haves.artifacts`;无新建源码文件、无新增依赖。
**Phase 7 验证已失效**:`07-VERIFICATION.md`(2026-09-16T06:04:11Z)已因 07-09 与本轮 07-10..07-14 失效。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论。
Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个计划,承接 `07-REVIEW.md` 的 WR-04/WR-05/WR-06。D-02: 属 hero 系统(`packages-user/data-base/src/hero`),与 07-04(hero 存读档与属性/槽位)、07-09(属性同引用)、07-11(容器同引用)同系统、按严重度递减排序。D-03/D-04/D-05/D-06: 其它系统契约裁决,不涉及(`HeroEquipment` 的码 146/147 语义不变,147 保持 D-06 的保留码状态)。D-07: path 接线,不涉及。D-08: enemy,不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划,回归用例为必需项(A1)。D-11: 存档源语义变更必须同步 `types.ts` 的 jsDoc。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按根因原子提交(Task 1 / 2 / 3)。D-14: 三条均无 `WINDOWS.md` 条目,只登记不新建。
执行序:`depends_on: 07-11`(Wave 12)。除门禁型依赖(D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录)外,还有**文件重叠的隐式依赖**:`hero/equipStore.ts`、`hero/types.ts`、`hero/saveLoad.test.ts` 与 07-11 的 `files_modified` 重叠,同波并行会互相覆盖,故必须排在 07-11 之后。
**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147,D-06)。执行前重录当日基线并与本行对比。
</objective>
<execution_context>
@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/book/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-REVIEW.md
@.planning/phases/07-data-fixes/07-09-PLAN.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@dev.md
@packages-user/data-base/src/hero/equipStore.ts
@packages-user/data-base/src/hero/modifier.ts
@packages-user/data-base/src/hero/attribute.ts
@packages-user/data-base/src/hero/equipment.ts
@packages-user/data-base/src/hero/types.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 契约裁决关卡(D-09)——装备存档源(活值 vs 定义差异)、`deleteModifierByIndex` 簿记范围、读档禁录方式、相邻缺陷(`compareEquip` 索引错配 / `getModifiers` 暴露内部数组)是否纳入</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState`::19-24 `modifiers`/`value`/`percentage`、:26-41 构造器、:46-55 `rebuildModifiers`、:63-102 `saveNoCompression`/`saveDiff`、:104-164 `saveState`/`loadNoCompression`/`loadDiff`/`loadState`)
- packages-user/data-base/src/hero/modifier.ts(`BaseHeroModifier` 的 `value`/`setValue`/`getValue`/`saveState`/`loadState`)
- packages-user/data-base/src/hero/attribute.ts(:190-211 `addModifier` 的 `modifier.owner` 拒绝与码 108、:213-228 `deleteModifier` 完整簿记、:230-239 `deleteModifierByIndex` 缺陷本体、:241-249 `markDirty`/`markModifierDirty`)
- packages-user/data-base/src/hero/equipment.ts(:153-198 `equip`(:169-170 disable、:194 revert、:195 route.add)、:200-218 `unequip`(:211-212 route.add)、:255-326 `compareEquip`(:281-284 索引错配)、:330-347 `saveState`/`loadState`)
- packages-user/data-base/src/hero/types.ts(:622-631 `IEquipmentStateSave`、:638-650 `IEquipmentState`、:24-68 `IHeroModifier` 与 `modifier.saveState` 契约)
- packages-user/data-base/src/hero/saveLoad.test.ts(:220-240 `roundTripPercentageModifier`、:341-380 三档百分比与数值加成用例 —— 「读档回到原始定义值」的断言所在)
- packages-user/data-base/src/hero/equipment.test.ts(:102-117 `createEnv` 的 `replaySystem` 桩:`route.add`/`disable`/`revert` 均为 `vi.fn()`)
- .planning/phases/07-data-fixes/07-REVIEW.md(WR-04、WR-05、WR-06 原文与建议修法)
- packages-user/data-common/src/replay/system.ts(:89-95 `disable`/`revert` 转发到 `ReplayArray`)
</read_first>
<decision>
装备/属性存档正确性三条修复中的契约点:
(Q1)装备存档的**事实源**是什么(活修饰器值 vs `this.value`/`this.percentage` 同步 vs 明确不持久化);(Q2)`deleteModifierByIndex` 的簿记范围;(Q3)读档期间**禁用录像记录**的方式;(Q4)两处相邻缺陷是否在本计划一并修复。
</decision>
<context>
Q1 决定装备加成的存档语义与**既有断言的命运**:`saveLoad.test.ts:341-380` 现在把「运行时 `setValue` 不落地」写成断言(改到 0.9/99,读回 0.5/5)。若选「活值为源」,这两条正确预期必须翻转为「读回 0.9/99」——属 D-10 的授权例外;若选「不持久化」,则这两条保持并需在 `IEquipmentState` 写明契约。
Q2 决定删除 API 的可观测副作用(`iterateModifiers`/`getModifierIndex`/`final`/能否重新挂载)。Q3 决定读档期间「录像不记录」是**借用既有 disable 通道**(会把 `disabled` 计数短暂置高)还是**改走无记录助手**(`equip` 与 `loadState` 共用逻辑,行为面更大)。Q4 是执行期阅读发现的两处相邻缺陷,按「不得擅自扩大修复面」的约定须由用户决定纳入或登记。
</context>
<options>
<option id="q1-live">
<name>Q1 选项 A:【建议默认】以**活修饰器值**为存档源:`saveState` 从 `[...this.getModifiers()]` 取 `modifier.saveState(compression)`(`modifier.ts:38-44`)生成 `value`/`percentage` 映射;压缩档与 `item.equip` 原始定义比较后只存差异;`loadState` 先按定义重置再叠加差异并 `modifier.loadState`</name>
<pros>运行时装备修饰器变更(`setValue`)能经三档压缩完整往返;`saveDiff` 不再恒为空;`IEquipmentStateSave` 形状可保持不变(只改 `value`/`percentage` 的语义为「活值 / 活值与定义的差异」);复用修饰器自带的 `saveState`/`loadState`,无需新 API</pros>
<cons>需翻转 `saveLoad.test.ts:341-380` 的既有断言(D-10 授权例外,A1);`modifiers` 与 `value`/`percentage` 两个表将存在「谁是事实源」的冗余,需在实现中明确以修饰器为准并在 jsDoc 写明</cons>
</option>
<option id="q1-sync">
<name>Q1 选项 B:保持 `this.value`/`this.percentage` 为事实源,在修饰器被修改时同步回写这两个表</name>
<pros>存档形状与既有断言完全不变(读档仍回到「定义值 + 已同步的变更」)</pros>
<cons>`setValue` 是 `BaseHeroModifier` 的公开入口(`modifier.ts:25-28`),要感知变更必须改修饰器基类(A3 明确不改)或给 `EquipmentState` 加订阅机制——改动面反而更大且引入双向同步</cons>
</option>
<option id="q1-document">
<name>Q1 选项 C:明确「运行时装备修饰器变更不入存档」并在 `IEquipmentState`/`IEquipmentStateSave` 写明契约(代码行为不变)</name>
<pros>零行为变更、零断言翻转;把今日的隐含语义变成明写契约</pros>
<cons>WR-04 判定的「低/高压缩档不持久化任何装备加成」问题**依旧存在**(`saveDiff` 恒空是缺陷还是设计需用户拍定);与「本轮修复 15 项」的指令冲突(清单第 10 条)</cons>
</option>
<option id="q2-mirror">
<name>Q2 选项 A:【建议默认】`deleteModifierByIndex` 与 `deleteModifier` 完全同构:`bindAttribute(null)` + `modifierName.delete` + `modifierNosave.delete` + `markDirty(name)`,并返回被删除的修饰器</name>
<pros>两套删除路径簿记一致,`iterateModifiers`/`getModifierIndex`/`final`/重新挂载全部自洽;WR-05 四条后果一次消除;`compareEquip` 的克隆属性也能正常工作</pros>
<cons>被删修饰器 `owner` 被清空后无法「原样挂回」(需重新 `addModifier`,符合既有 `deleteModifier` 语义)</cons>
</option>
<option id="q2-minimal">
<name>Q2 选项 B:只补 `bindAttribute(null)`(使修饰器可再次挂载),不动 `modifierName`/`modifierNosave`/`markDirty`</name>
<pros>改动最小,解决「永久不可用」这一最严重后果</pros>
<cons>`iterateModifiers()`/`getModifierIndex()` 仍产出已删除的修饰器(访问器视图与 `modifier` 表分叉)、`finalAttribute` 仍陈旧——WR-05 只闭合一半</cons>
</option>
<option id="q2-delegate">
<name>Q2 选项 C:`deleteModifierByIndex` 改为按索引取出修饰器后委托 `deleteModifier(name, modifier)`(复用同一簿记实现)</name>
<pros>彻底消除两套簿记;日后 `deleteModifier` 变更自动生效</pros>
<cons>`deleteModifier` 通过 `indexOf` 再定位一次(O(n),语义等价);返回值需额外保留(`deleteModifier` 无返回值)</cons>
</option>
<option id="q3-disable">
<name>Q3 选项 A:【建议默认】`HeroEquipment.loadState` 用既有 `replay.disable()` / `replay.revert()` 包裹重新装备循环(与 `equip()` 内部同一通道)</name>
<pros>复用既有机制(`equipment.ts:169-170/194`),改动最小;`equipment.test.ts` 的 `replaySystem` 桩已含 `disable`/`revert`,可直接断言调用;不改变 `equip()` 的公开行为</pros>
<cons>依赖 `disabled` 计数配对(异常路径下需保证 `revert`);`ReplayArray.setReplayArray` 会把 `disabled` 归零(`array.ts:807`),若读档顺序为「先英雄后录像」,包裹窗口与 `setReplayArray` 不重叠,不影响本场景</cons>
</option>
<option id="q3-helper">
<name>Q3 选项 B:抽出无录像副作用的内部装备助手(如 `applyEquip`/`unapplyEquip`),`equip()`/`unequip()` 在完成业务后自行记录,`loadState()` 只调用助手</name>
<pros>读档路径根本不触发记录,不依赖 `disabled` 计数;职责更清晰(「施加/解除装备效果」与「记录录像」分离)</pros>
<cons>要重构 `equip()`/`unequip()` 的记录位置(`:194-195`/`:210-212`),回归面比选项 A 大;既有 `equip`/`unequip` 的录像记录顺序(disable → 业务 → revert → add)需逐条保持</cons>
</option>
<option id="q3-document">
<name>Q3 选项 C:不改行为,改为在 `loadState` 的 jsDoc 写明「须在录像读档之前调用」并补一条顺序断言</name>
<pros>零代码风险</pros>
<cons>WR-06 指出的污染路径(英雄单独读档 / 存档缺 `@system/replay`)仍然存在;契约靠顺序约束而非实现约束</cons>
</option>
<option id="q4-fix-both">
<name>Q4 选项 A:两处相邻缺陷都在本计划修:(i)`compareEquip` 的索引错配(`equipment.ts:281-284` 用原属性的 `getModifierIndex` 结果去删克隆属性的修饰器);(ii)`EquipmentState.getModifiers()`(`equipStore.ts:57-61`)返回内部数组引用,外部可改动修饰器图</name>
<pros>一次把装备对比路径与修饰器暴露面收口,避免下一轮再开修复计划</pros>
<cons>超出用户点名的 15 项清单(属执行期发现的相邻问题),改动面扩大,需要用户明确授权</cons>
</option>
<option id="q4-fix-index">
<name>Q4 选项 B:只修(i)索引错配(`compareEquip` 改为在克隆属性上按修饰器对象定位,或用 `clone` 的 `getModifierIndex`),(ii)`getModifiers()` 暴露内部数组只登记</name>
<pros>修掉真实影响对比结果的那一处,避免顺手扩大改动</pros>
<cons>`getModifiers()` 仍可被外部改动(登记在册)</cons>
</option>
<option id="q4-register">
<name>Q4 选项 C:两处都只登记,不修</name>
<pros>严格守住 15 项清单的边界</pros>
<cons>(i)会影响 `compareEquip` 的对差值(当克隆与原件修饰器顺序不同时删错索引);需在 SUMMARY 明写为已知缺陷</cons>
</option>
<option id="revise">
<name>(兜底)修订方案后再执行</name>
<pros>避免带错误契约落地</pros>
<cons>按 D-09 退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法</cons>
</option>
</options>
<record>
待用户在关卡处逐条填写(Q1..Q4:勾选选项 id 或给出裁决文字;Q4 若选 B/C 请写清具体哪一处修、哪一处登记)。
**空的 `<record>` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。
填写后 Task 1–3 一律按本记录执行;若裁决与 Task 1–3 的分支描述冲突,暂停并请用户澄清。
</record>
<resume-signal>逐条给出 Q1..Q4 的裁决(例如「Q1=A, Q2=A, Q3=A, Q4=B」);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md</resume-signal>
<action>向用户汇报上述 4 个契约点与各自的直接后果(尤其 Q1 对 `saveLoad.test.ts:341-380` 断言的影响、Q3 对录像语义的影响)。不得在裁决前修改任何文件。</action>
<verify>
<human-check>`<record>` 中 Q1..Q4 均有明确裁决(Q4 已点名具体处置),且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进</fails_when>
</verify>
<acceptance_criteria>
- Q1(装备存档事实源)、Q2(`deleteModifierByIndex` 簿记范围)、Q3(读档禁录方式)、Q4(相邻缺陷处置)均有用户裁决并写入 `<record>`
- Q1 的既有断言影响已明示(翻转 `saveLoad.test.ts:341-380` 属 A1 授权例外)
- 未在裁决前修改任何文件
</acceptance_criteria>
<done>Q1..Q4 裁决已完整写入 `<record>`;执行者据记录直接进入 Task 1,不再重复询问</done>
</task>
<task type="auto">
<name>Task 1: WR-04 —— 装备存档源改为活修饰器值(按 Q1 裁决),三档压缩下运行时变更可往返或契约明写</name>
<files>packages-user/data-base/src/hero/equipStore.ts, packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState` 全量:`value`/`percentage` 私有表、`rebuildModifiers`、三档 save/load)
- packages-user/data-base/src/hero/modifier.ts(`saveState`/`loadState`/`getValue` 签名)
- packages-user/data-base/src/hero/types.ts(:622-631 `IEquipmentStateSave` —— 形状与 jsDoc 位置)
- packages-user/data-base/src/hero/saveLoad.test.ts(:220-240 百分比往返夹具、:341-380 三档用例 —— Q1=A 时需翻转的断言)
- packages-user/data-common/src/save/types.ts(`SaveCompression` 三档枚举)
</read_first>
<action>
按 Task 0 的 Q1 裁决实现。**若 Q1 = 选项 A(活值为源)**:
(1) `saveNoCompression()`(`:67-74`):`value`/`percentage` 两个 Map 改为由 `[...this.getModifiers()]` 生成——按修饰器类型分流(`ValueModifier` → `value`、`PercentageModifier` → `percentage`;判定方式沿用 `rebuildModifiers` 的既有分类口径或用修饰器 `modifier.saveState(compression)` 的返回值),键为属性名、值为 `modifier.saveState(compression)`;
(2) `saveDiff()`(`:80-102`):以 `item.equip.value`/`item.equip.percentage`(原始定义)为基准,仅写入与**当前活值**不同的条目;
(3) `loadNoCompression`/`loadDiff`(`:112-153`):保持「清空 → 按(定义或空)+ 存档差异重建表 → `rebuildModifiers()`」的结构(`loadDiff` 的定义回退是 `#06-09-1` 的修复成果,不得回退),使重建出的修饰器值即为存档点的活值;如需在重建后逐个 `modifier.loadState(value, compression)` 保证与 `IEquipmentStateSave` 的形状解耦,则按该方式实现并保持 `rebuildModifiers` 的一致性;
(4) `IEquipmentStateSave` 的 `value`/`percentage` jsDoc(`types.ts:627-630`)改写为「当前/差异的加成值(事实源为活修饰器)」;若 `IEquipmentState` 的 jsDoc 已描述存档语义则同步(`types.ts:638-650`);
(5) **A1 授权的断言翻转**:`saveLoad.test.ts` 的 `roundTripPercentageModifier`(`:220-240`)与 `:341-380` 三档用例由「读回原始定义值(0.5 / 5)」改为「读回存档点的活值(0.9 / 99)」,并在 SUMMARY 逐条说明该改写及其理由。
**若 Q1 = 选项 B**:不实现上述 (1)-(3),改为同步方案(按记录指定的机制),并保持既有断言。
**若 Q1 = 选项 C**:不修改行为,仅在 `types.ts` 写明契约并在 SUMMARY 登记「用户裁决维持现状」。
回归用例:(a) 三档压缩下 `modifier.setValue(新值)` → `saveState` → 再改 → `loadState` → 断言读回存档点的活值;(b) 未修改的加成在 Low/High 档仍经定义回退恢复(`#06-09-1` 不回归);(c) 无修饰器/空 `item.equip` 的装备实例三档往返不报错。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/equipStore.test.ts</automated>
<fails_when>Q1=A 时三档压缩读回的修饰器值不等于存档点的活值(例如 Low/High 档仍为定义值);或 `#06-09-1`(未修改加成在压缩档经定义回退恢复)出现回归 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- Q1 裁决落地:三档压缩的装备存档事实源与裁决一致(活值 / 同步 / 契约明写),并有断言
- `#06-09-1` 的定义回退语义在 Low/High 档不回归
- Q1=A 时 `saveLoad.test.ts:341-380` 的断言翻转已在 SUMMARY 逐条说明(A1)
- `IEquipmentStateSave`/`IEquipmentState` 的 jsDoc 与最终语义一致
</acceptance_criteria>
<done>装备修饰器的运行时变更与原始定义的差异在三档压缩下语义明确且被断言(WR-04 按裁决闭合或明写为契约)</done>
</task>
<task type="auto">
<name>Task 2: WR-05 —— `HeroAttribute.deleteModifierByIndex` 簿记与 `deleteModifier` 对齐(含迭代器/索引/final/可重新挂载回归),按 Q4(i) 裁决处理 `compareEquip`</name>
<files>packages-user/data-base/src/hero/attribute.ts, packages-user/data-base/src/hero/attribute.test.ts, packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/equipment.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/attribute.ts(:169-188 `iterateModifiers`/`getModifiers`/`getModifierIndex`、:190-211 `addModifier`(:195-199 码 108 拒绝)、:213-228 `deleteModifier`、:230-239 `deleteModifierByIndex`、:241-249 `markDirty`/`markModifierDirty`)
- packages-user/data-base/src/hero/attribute.test.ts(现有 modifier 用例夹具与 `logger.catch` 口径)
- packages-user/data-base/src/hero/equipment.ts(:255-326 `compareEquip`,尤其 :281-284 的索引错配)
- packages-user/data-base/src/hero/equipment.test.ts(`diffs the final attributes of two equipment instances` 用例,:318-335)
- packages-user/data-base/src/hero/types.ts(:136-165 `iterateModifiers`/`getModifierIndex`/删除相关契约、:229 附近 `deleteModifierByIndex` 的 jsDoc 位置)
</read_first>
<action>
(1) 按 Task 0 的 Q2 裁决修改 `deleteModifierByIndex`(`attribute.ts:230-239`):选项 A(镜像 `deleteModifier`)→ 取出 `removed[0]` 后依次 `modifier.bindAttribute(null)`、`this.modifierName.delete(modifier)`、`this.modifierNosave.delete(modifier)`、`this.markDirty(name)`,返回值不变;选项 C(委托)→ 取 `index` 对应修饰器后调用 `this.deleteModifier(name, modifier)` 并返回该修饰器;
(2) 同步更新 `types.ts` 中 `deleteModifierByIndex` 的 jsDoc(说明与 `deleteModifier` 同构、修饰器将被解绑且不再是本属性成员);
(3) 按 Task 0 的 Q4(i) 裁决处理 `compareEquip`(`equipment.ts:281-284`):选项 A/B → 把「用原属性 `getModifierIndex` 的结果去删克隆属性」改为在**克隆属性**上定位(例如 `clone.getModifierIndex(modifier)`;若克隆未复制 `modifierName` 记录则按记录指定的等价方式,如按 `name` + 修饰器对象在克隆数组中 `indexOf`),保证删除的是克隆上对应的修饰器;选项 C → 不改,在 SUMMARY 登记为已知缺陷;
(4) 按 Q4(ii) 裁决处理 `EquipmentState.getModifiers()`(`equipStore.ts:57-61`):若裁决纳入,则返回副本或只读视图(不改 `IHeroEquipment`/`IEquipmentState` 的对外签名语义时优先返回元素副本数组);若裁决只登记,则不改。
回归用例:
(a) `attribute.test.ts`:`addModifier` 挂两个修饰器 → `deleteModifierByIndex` 删第一个 → 断言 `iterateModifiers()` 不再产出它、`getModifierIndex(removed) === -1`、`getFinalAttribute(name)` 等于按剩余修饰器重算的值、且该修饰器可再次 `addModifier` 而不触发码 108(`logger.catch` 口径);
(b) `equipment.test.ts`:既有 `diffs the final attributes of two equipment instances` 用例保持通过;按 Q4(i) 裁决补一条「克隆与原件修饰器顺序不同」的对差用例(或按裁决登记)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts packages-user/data-base/src/hero/equipment.test.ts</automated>
<fails_when>`deleteModifierByIndex` 之后 `iterateModifiers()` 仍产出该修饰器、或 `getModifierIndex` 仍能解析到它、或再次 `addModifier` 被码 108 拒绝、或 `finalAttribute` 未随删除刷新 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- `deleteModifierByIndex` 的簿记与 Q2 裁决一致,且四条后果(迭代器产出、索引解析、final 陈旧、owner 残留阻止重挂)都有断言覆盖
- `compareEquip` 的索引来源按 Q4(i) 裁决处理(修或登记)
- `types.ts` 的 jsDoc 与最终语义一致;既有断言未弱化
</acceptance_criteria>
<done>修饰器删除后属性图与访问器视图一致、final 值刷新、被删修饰器可重新挂载(WR-05 关闭)</done>
</task>
<task type="auto">
<name>Task 3: WR-06 —— `HeroEquipment.loadState` 读档不再追加录像指令(按 Q3 裁决)</name>
<files>packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/equipment.test.ts, packages-user/data-base/src/hero/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/equipment.ts(:153-198 `equip`(:168-170 注释与 disable、:194 revert、:195 route.add)、:200-218 `unequip`(:210-212 route.add)、:330-347 `saveState`/`loadState`)
- packages-user/data-base/src/hero/equipment.test.ts(:102-117 `createEnv` 的 `replaySystem` 桩、:171-316 装备/卸下用例、:318-361 对比与告警用例)
- packages-user/data-common/src/replay/system.ts(:89-95 `disable`/`revert`)
- packages-user/data-base/src/hero/saveLoad.test.ts(`createEquipHero` 夹具与其 `state.replaySystem` 形态)
</read_first>
<action>
按 Task 0 的 Q3 裁决实现。
**若 Q3 = 选项 A(disable/revert 包裹)**:在 `HeroEquipment.loadState`(`:337-347`)的 `equip(...)` 循环外包裹 `this.state.replaySystem.disable()` / `this.state.replaySystem.revert()`(用 `try/finally` 或保证所有路径都 `revert`,与 `equip()` 内部的配对风格一致),使读档期间的 `route.add` 不产生录像命令;
**若 Q3 = 选项 B(无记录助手)**:把 `equip()` 的「业务部分」(`checkEuipped`/`getCouldEquipSlot`/`unequip`/`equips.set`/`loadEquipEffect`)抽为内部助手,`equip()` 在助手返回后自行 `replay.route.add(ReplayCommandCode.Equip, [uid])`,`loadState()` 只调用助手;`unequip()` 的 `route.add` 位置保持现状(其记录语义不变);助手抽取必须保持 `equip()` 的既有可观测行为(返回值、码 146/147、`disable`/`revert` 配对);
**若 Q3 = 选项 C(登记)**:不改行为,只在 `loadState` 的 jsDoc 写明顺序约束并补一条顺序断言。
回归用例:
(a) `equipment.test.ts`:在 `createEnv()` 的 `replaySystem` 桩上,`loadState` 前后断言 `route.add` **未被调用**(选项 A/B)或按选项 C 断言顺序约束;再断言 `loadState` 之后 `equip()`/`unequip()` 的 `route.add` 仍被调用(记录能力未被永久破坏);
(b) `saveLoad.test.ts`:`createEquipHero` 的读档往返后,`state.replaySystem.route.length`(或等价录像步数)不因读档增加——若夹具的 `replaySystem` 是桩则用 `route.add` 调用次数断言;
(c) 断言读档后槽位/加成恢复结果与既有断言一致(不得以「不记录」换取行为变化)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts packages-user/data-base/src/hero/saveLoad.test.ts</automated>
<fails_when>`HeroEquipment.loadState` 仍调用 `replaySystem.route.add`(Q3 选 A/B 时);或读档后槽位/加成恢复与既有断言不一致;或 `equip()`/`unequip()` 的正常录像记录被一并关闭 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 按 Q3 裁决:`loadState` 期间不产生录像命令(A/B)或顺序约束明写并有断言(C)
- `equip()`/`unequip()` 的正常录像记录语义不变(仍有断言)
- 未修改 `packages-user/data-common` 下任何文件
</acceptance_criteria>
<done>英雄装备读档不再污染录像路由,装备/卸下的正常记录能力保持(WR-06 按裁决闭合)</done>
</task>
<task type="auto">
<name>Task 4: D-12/D-44 门禁 + 全量套件 + 缺口登记(WR-04/05/06 + Q4 处置 + IN-02/IN-04/IN-05)与 Phase 7 验证失效说明</name>
<files>.planning/phases/07-data-fixes/07-12-SUMMARY.md</files>
<read_first>
- .planning/phases/07-data-fixes/07-REVIEW.md(WR-04/WR-05/WR-06 与 IN-02/IN-04/IN-05 的编号,便于逐条对照闭合/登记)
- .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status)
- .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表)
- .planning/WINDOWS.md(确认三条无入账 id)
</read_first>
<action>
(1) **门禁(D-12 / D-44 文件级)**:对 `files_modified` 中每个改动的文件执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误;`pnpm test:ci` 全绿(对比基线 66 文件 / 690 通过 / 1 跳过,跳过项仍只能是 `equipment.test.ts:306` 码 147)。
(2) **原子提交(D-13)**:Task 1 / 2 / 3 各自独立提交(`fix(07-12): …`,携带 `WR-04` / `WR-05` / `WR-06` 与必要的 `#06-09-1` 锚点)。
(3) **缺口登记**:SUMMARY 的 findings/ledger 段落逐条标注 WR-04/WR-05/WR-06 已闭合(附 commit)、Q4 两处相邻缺陷的处置(已修/已登记)、`IN-02`/`IN-04`/`IN-05` 保留在册;若 A1 授权的断言翻转发生,在此逐条列出「旧断言 → 新断言 → 理由」。`WINDOWS.md` 不新建、不 `fixed`/`waive`。
(4) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条
- Task 1–3 各有独立提交并含对应锚点
- SUMMARY 完成缺口登记(WR-04/05/06、Q4 处置、IN-02/IN-04/IN-05、A1 断言翻转清单)与 Phase 7 验证失效说明
- `WINDOWS.md` 未被本计划改动
</acceptance_criteria>
<done>D-44 三步门禁全绿、原子提交完成、缺口与断言变更逐条登记、Phase 7 验证失效说明写入 SUMMARY</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 存档数据 → 装备/属性状态 | 读档决定修饰器的值与成员集合;错误的事实源会让「读档后数值」静默偏离 |
| 公开 API 调用方 → 修饰器图 | `deleteModifierByIndex` / `getModifiers()` / `compareEquip` 允许外部改动或误删修饰器,簿记不全即产生与访问器视图分叉的状态 |
| 读档 → 录像路由 | `loadState` 经 `equip()` 触发录像记录,把只读语义变成可回放历史 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-31 | Tampering | `EquipmentState.saveDiff` / `saveNoCompression`(`equipStore.ts:67-102`) | medium | mitigate | Task 1:按 Q1 裁决以活修饰器值为存档源(或明写契约),消除「压缩档零持久化」与「运行时变更丢失」 |
| T-7-32 | Tampering | `HeroAttribute.deleteModifierByIndex`(`attribute.ts:230-239`) | medium | mitigate | Task 2:与 `deleteModifier` 同构的簿记(解绑 + 清理两表 + `markDirty`),并有四条后果的回归断言 |
| T-7-33 | Tampering | `compareEquip` 的索引错配(`equipment.ts:281-284`) | medium | mitigate | Task 2 按 Q4(i) 裁决:在克隆属性上定位修饰器(修)或不修并登记为已知缺陷 |
| T-7-34 | Repudiation | `HeroEquipment.loadState` 追加 `Equip` 指令(`equipment.ts:195` 经 `:345`) | medium | mitigate | Task 3:按 Q3 裁决禁用记录或无记录助手;断言 `route.add` 在读档期未被调用,且正常 `equip`/`unequip` 仍记录 |
| T-7-35 | Tampering | `EquipmentState.getModifiers()` 返回内部数组(`equipStore.ts:57-61`) | low | mitigate | 按 Q4(ii) 裁决:纳入则返回副本/只读视图,否则登记在册 |
| T-7-36 | Denial of Service | `replay.disable()`/`revert()` 配对失衡(Q3 选项 A) | low | mitigate | Task 3:用 `try/finally` 或与 `equip()` 一致的配对路径保证 `revert` 必达;`<fails_when>` 拦截「正常记录被永久关闭」 |
| T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 |
</threat_model>
<verification>
1. `pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/attribute.test.ts packages-user/data-base/src/hero/equipment.test.ts packages-user/data-base/src/hero/equipStore.test.ts` 全绿。
2. `pnpm test:ci` 全绿:66 文件 / 690 通过 / 1 跳过(仅 `equipment.test.ts:306` 码 147)。
3. `pnpm exec eslint <files_modified 中每个改动文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。
4. Task 0 的 `<record>` 中 Q1..Q4 均有裁决,代码与裁决逐条一致(SUMMARY 对照表);A1 授权的断言翻转已逐条列出。
5. 无新增依赖、无新建源码文件、`packages-user/data-common` 下零改动、`WINDOWS.md` 未改动。
6. Phase 7 验证失效与重跑要求已在 `07-12-SUMMARY.md` 明写,`ROADMAP.md` 已更新。
</verification>
<success_criteria>
- WR-04 按 Q1 裁决闭合:装备存档的事实源与裁决一致,三档压缩下有对应断言(或契约明写并有测试锚定)。
- WR-05 闭合:`deleteModifierByIndex` 后迭代器/索引/final/重新挂载四条后果全部自洽。
- WR-06 按 Q3 裁决闭合:读档不产生录像命令,正常装备/卸下记录能力不变。
- Q4 两处相邻缺陷按裁决修或登记,并在 SUMMARY 逐条记录。
- `pnpm test:ci` 全绿、无新增 `it.skip`;D-44 三步门禁全绿;A1 授权的断言变更已逐条登记。
- Phase 7 验证失效与重跑要求已记录。
</success_criteria>
<output>
Create `.planning/phases/07-data-fixes/07-12-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,342 @@
---
phase: 07-data-fixes
plan: 13
type: execute
wave: 13
depends_on:
- 07-12
files_modified:
- packages-user/data-system/src/combat/mapDamage.ts
- packages-user/data-system/src/combat/mapDamage.test.ts
- packages-user/data-system/src/combat/types.ts
- packages-user/data-base/src/map/mapLayer.ts
- packages-user/data-base/src/map/mapLayer.test.ts
- packages-user/data-base/src/map/saveLoad.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 52000
raw_tokens: 52000
tasks: 4
confidence: low
must_haves:
truths:
- "怪物来源的地图伤害在来源消失或范围收缩后不再残留:`markEnemyDirty` 路径下 `removeEnemyAffecting` 触达过的每个坐标索引都被标脏并按现有 `refreshIndex` 重新聚合(CR-02 关闭),`getReducedDamage`/`getSeparatedDamage` 不再返回幽灵伤害"
- "转换视图集变为空时(伤害来源被移除,如光环/特殊属性消失)仍完成缓存失效,且该怪物的反向索引被显式登记,后续 `markEnemyDirty` 不走整表 `refreshAll()`(按 Task 0 Q1 裁决)"
- "范围收缩(同一怪物产生的伤害点位减少)时,跌出范围的坐标不再保留包含已移除贡献的缓存值"
- "`MapLayer.setMapRef` 的失效契约按 Task 0 Q2 裁决明确:`getMapRef()` 的持有者能据 `expired`/对象身份判断何时重取(`#06-17-8` / 审计 G 关闭或明写契约)"
- "`MapLayer.loadDynamics` 不再让旧动态图块累积:读档后该图层的动态图块集合与存档一致(按 Task 0 Q3 裁决:清空旧块 / 只清存档外坐标 / 明写合并语义)"
- "`pnpm test:ci` 全绿,不新增 `it.skip`、不弱化既有断言(既有 `mapDamage.test.ts` 的 102/103/104、`deleteEnemy`、`markEnemyDirty` 用例全部保持通过)"
artifacts:
- path: packages-user/data-system/src/combat/mapDamage.ts
provides: "`removeEnemyAffecting` 返回/登记被移除的坐标索引并逐个 `markDirtyIndex`;`refreshEnemyAndClearCache` 在视图集为空时仍完成失效与反向索引登记(按 Q1 裁决)"
- path: packages-user/data-system/src/combat/types.ts
provides: "`IMapDamage.markEnemyDirty`/`deleteEnemy` 的失效契约 jsDoc(何时保证缓存失效、空视图集语义)"
- path: packages-user/data-system/src/combat/mapDamage.test.ts
provides: "CR-02 回归:来源消失(空视图集)与范围收缩两条路径的幽灵伤害断言"
- path: packages-user/data-base/src/map/mapLayer.ts
provides: "`setMapRef`/`loadState`/`loadDynamics` 的失效与旧动态块处置(按 Q2/Q3 裁决)"
- path: packages-user/data-base/src/map/mapLayer.test.ts
provides: "`#06-17-8` 回归:`setMapRef` 的 `expired`/对象身份契约;旧动态块不再累积(实例级)"
- path: packages-user/data-base/src/map/saveLoad.test.ts
provides: "`#06-17-8` 回归:`MapLayer.loadState` 三档往返后动态图块集合等于存档(不累积)"
key_links:
- "`removeEnemyAffecting`(`mapDamage.ts:240-256`)↔ `markDirtyIndex`(`:126-129`)——移除端不标脏是 CR-02 的根因;`refreshEnemyAndClearCache`(`:290-333`)与 `refreshEnemy`(`:338-372`)都经由它"
- "`refreshEnemyAndClearCache` 的 `if (set.size === 0) return;`(`:300`)↔ `enemyStore.delete(view)`(`:255`)—— 视图集为空时不登记反向索引,后续 `markEnemyDirty`(`:135-148`)会因 `enemyStore.get(view)` 为空而走 `refreshAll()`(`:142`)"
- "`refreshIndex`(`:395-427`)↔ `viewStore.get(store.sourceView)`(`:406-407`)——重新聚合只在 `viewStore` 仍登记的视图上重建,故被移除视图的贡献随 `point.affectedBy.delete(viewItem)`(`:249`)自然消失"
- "`MapLayer.setMapRef`(`mapLayer.ts:371-391`)↔ `getMapRef()`(`:393-395`)/`MapLayerHookController.getMapData`(`:950-952`)——旧 `mapData` 被标 `expired` 后整对象被替换(`:380-386`)"
- "`MapLayer.loadDynamics`(`:841-849`)↔ `iterateDynamicTiles`(`:512-514`)/`tilePosMap`/`posTileMap`(`:48-51`)/`removeTile`(`:118-124`)——读档只创建不清理,旧动态块累积"
- "`MapLayer.deleteDynamic`(`:498-507`)的 `syncStaticEvent(tile, false)` + `removeTile` + `onDeleteDynamic` 钩子 ↔ 读档清理是否复用同一路径(决定静态事件同步与渲染端通知是否触发)"
assumptions:
- "A1(D-10 例外,本计划授权):本计划为修复计划,为 CR-02 与 `#06-17-8` 新增回归用例是必需项;既有断言不得弱化(`mapDamage.test.ts` 的 102/103/104/deleteEnemy/markEnemyDirty 用例、`map/saveLoad.test.ts` 的三档往返用例全部保持)"
- "A2(不引入新状态语义):CR-02 的修法只在既有结构(`dirtyIndexes`/`reducedCache`/`viewStore`/`damageStore`/`enemyStore`)内完成,不新增缓存层、不改 `IMapDamageReducer`/`IMapDamageConverter` 契约"
- "A3(项目未发布,与 07-09 的 A2 同口径):`IMapLayerSave`/存档形状不因本计划变更;动态块清理属读档行为而非格式变更"
- "A4(范围守卫):仅修 CR-02 与 `#06-17-8`;`07-REVIEW.md` 的 IN-01(删除后 `point.damages`/`affectedBy` 残留、`sourcelessDamage` 空点残留)与 IN-04/IN-05 不在本轮 15 项清单,只在 Task 0 作为可选项列出、默认登记不修"
prohibitions:
- "不改 `packages-user/data-base/src/map/gameMap.ts` / `mapState.ts`(审计判定 `GameMap`/`MapState.loadState` 为原地读档、安全)"
- "不改 `packages-user/data-base/src/map/dynamicTile.ts`(07-05 已修 num 恢复)"
- "不改 `packages-user/data-system/src/combat/context.ts` 的 `refreshEnemy`/`setEnemyAt` 调用点(只在 `mapDamage.ts` 内修复)"
- "不改渲染端 `@user/client-*` 与 legacy 接线;不新增依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言"
- "不新增 `it.skip`;不为转绿改写既有断言"
---
<objective>
闭合地图侧两条缺陷:`MapDamage` 的幽灵伤害(CR-02)与 `MapLayer` 的读档失效/旧动态块契约(`#06-17-8` / 审计 G)。
现状(逐条锚点):
1. **CR-02(critical,`data-system/src/combat/mapDamage.ts:240-256, 290-333`)** `refreshEnemyAndClearCache()` 先 `removeEnemyAffecting(view)`(`:294`)——它删除该怪物的伤害对象并 `enemyStore.delete(view)`(`:255`),但**从不把被移除的坐标索引标脏、也不删对应 `reducedCache`**;随后只对新登记的索引做失效(`:329-332`),且视图集为空时在 `:300` 提前 `return`。后果:怪物光环/特殊属性消失(视图集变空)或范围收缩时,旧坐标的 `reducedCache` 永久停留在「含已移除贡献」的合并值——`getReducedDamage()`/`getSeparatedDamage()` 因索引不在 `dirtyIndexes` 而永不重算。`deleteEnemy()`(`:150-167`)标脏掩盖了整怪删除路径,`markEnemyDirty()`(`:135-148`,由 `EnemyContext.refreshEnemy()`/`setEnemyAt()` 触达)是真实可达路径。
2. **`#06-17-8`(低,审计 G)** `MapLayer.setMapRef()`(`mapLayer.ts:371-391`)把旧 `mapData` 标 `expired` 后整对象替换、清 `staticTileCache`;`loadDynamics()`(`:841-849`)只按存档 `createDynamic` 新块、从不清理既有动态块——同一图层多次读档或读一份动态块更少的存档时旧块累积;`expired` 契约(持有者何时重取 `getMapRef()`)未成文。
Purpose: 地图伤害是「怪物在哪里、谁该受伤」的唯一事实源,缓存残留会让玩家在光环消失后仍持续受伤(或显示已不存在的伤害);动态块累积会让读档后的地图与存档点不一致。两者都在「读档/刷新」的失效边界上,属同一主题。
Output: `mapDamage.ts`(失效补全)+ `combat/types.ts`(失效契约 jsDoc)+ `mapDamage.test.ts`(CR-02 回归);`mapLayer.ts`(`setMapRef`/`loadDynamics` 按裁决)+ `mapLayer.test.ts` + `saveLoad.test.ts`(`#06-17-8` 回归)。
**Artifacts this phase produces(本计划产出物)**:修改文件见 frontmatter `files_modified`;回归用例见 `must_haves.artifacts`;无新建源码文件、无新增依赖。
**Phase 7 验证已失效**:`07-VERIFICATION.md`(2026-09-16T06:04:11Z)已因 07-09 与本轮 07-10..07-14 失效。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论。
Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 13 个计划,承接 `07-REVIEW.md` CR-02 与 `07-LOADSTATE-AUDIT.md` G 行(`#06-17-8`)。D-02: 属 map 系统(`data-base/src/map`)与 combat 系统(`data-system/src/combat`)的地图伤害子系统,按严重度递减排序。D-03: `#06-01-3` before 语义,不涉及。D-04: `#06-06-1` 越图码 128,不涉及(本计划不改 `transferToDynamic`/`transferToStatic` 的诊断码)。D-05: 码 178 语义,不涉及。D-06: 码 147 保留,不涉及。D-07: path 接线,不涉及。D-08: enemy 复用映射,不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划,回归用例为必需项(A1)。D-11: 失效契约与 `setMapRef` 语义变更需同步 jsDoc。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按缺陷原子提交(Task 1 / Task 2)。D-14: 两条均无 `WINDOWS.md` 条目,只登记不新建。
执行序:`depends_on: 07-12`(Wave 13)。门禁型依赖(D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录);源码层面与 07-12、07-11 无耦合,但因 `pnpm test:ci` 基线需逐计划重录,不并波执行。
**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147,D-06)。执行前重录当日基线并与本行对比。
</objective>
<execution_context>
@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/book/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-REVIEW.md
@.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@dev.md
@packages-user/data-system/src/combat/mapDamage.ts
@packages-user/data-system/src/combat/mapDamage.test.ts
@packages-user/data-base/src/map/mapLayer.ts
@packages-user/data-base/src/map/saveLoad.test.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 契约裁决关卡(D-09)——CR-02 失效补全口径(标脏 + 空视图集是否登记)、`setMapRef` 失效契约(替换对象 vs 原地变更)、读档旧动态块处置、IN-01 是否纳入</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-system/src/combat/mapDamage.ts(:58-72 内部结构、:114-133 `deleteMapDamage`/`markDirtyIndex`/`markDirty`、:135-148 `markEnemyDirty`、:150-167 `deleteEnemy`、:169-222 读取与合并缓存、:224-256 `clearSourceState`/`removeEnemyAffecting`、:258-285 `registerSourcedDamage`、:290-333 `refreshEnemyAndClearCache`、:338-372 `refreshEnemy`、:374-390 `refreshAll`、:395-427 `refreshIndex`)
- packages-user/data-system/src/combat/types.ts(`IMapDamage` 的 `markEnemyDirty`/`deleteEnemy` 声明与 jsDoc 位置)
- packages-user/data-system/src/combat/mapDamage.test.ts(:1-2 文件头覆盖说明、:19-90 夹具、后半段 `deleteEnemy`/`markEnemyDirty` 用例,注意文件头是否需同步更新)
- packages-user/data-base/src/map/mapLayer.ts(:44-51 私有映射、:100-124 `removeTileFromPosMap`/`removeTile`、:371-395 `setMapRef`/`getMapRef`、:418-433 `createDynamic`、:461-507 删除动态块(含 `deleteDynamic` 的 `syncStaticEvent`/钩子)、:512-514 `iterateDynamicTiles`、:600-681 `isEqualToRef`/`compareWith`/`resize`/`resize2`(另两处 `mapData.expired`)、:841-849 `loadDynamics`、:855-931 三档 `load*`)
- packages-user/data-base/src/map/mapLayer.test.ts、packages-user/data-base/src/map/saveLoad.test.ts(:176-220 `MapLayer save and load round trips` 与 124 告警用例)
- .planning/phases/07-data-fixes/07-REVIEW.md(CR-02 与 IN-01 原文、建议修法)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(G 行与「未确定项 2」)
</read_first>
<decision>
地图侧两条修复的契约点:
(Q1)CR-02 的失效补全口径(是否标脏;视图集为空时是否登记反向索引以免触发 `refreshAll()`);(Q2)`MapLayer.setMapRef` 的失效契约(替换 `mapData` 对象 + 旧对象标 `expired`,还是原地变更);(Q3)读档时旧动态块的处置(全清 / 只清存档外坐标 / 明写合并);(Q4)`IN-01`(删除后的点残留与 `sourcelessDamage` 空点)是否纳入本计划。
</decision>
<context>
两个决定都直接改变对外可观测行为:
`refreshEnemyAndClearCache` 的 `set.size === 0` 早退(`:300`)与 `enemyStore.delete(view)`(`:255`)组合出一个隐式后果——**视图集为空的怪物在下次 `markEnemyDirty` 时会走 `refreshAll()` 整表重算**(`:142`)。修复 CR-02 时是否顺手登记空视图集(`enemyStore.set(view, new Set())`)会决定这是「按需重算」还是「整表重算」。
`setMapRef` 现在把旧 `mapData` 标 `expired` 再整对象替换(`:380-386`):这是外部持有者唯一的失效信号,但持有者拿到的对象上 `array` 仍是旧缓冲。是否改成原地变更(同引用原则的另一种形式)会决定外部持有者是「必须重取」还是「自动看到新缓冲」,两者不能同时成立。
动态块清理涉及是否复用 `deleteDynamic` 的 `syncStaticEvent(tile, false)` 与 `onDeleteDynamic` 钩子(渲染端可能在监听),故必须由用户点名。
</context>
<options>
<option id="q1-mark-dirty">
<name>Q1 选项 A:【建议默认】`removeEnemyAffecting` 收集被移除的坐标索引并逐个 `markDirtyIndex(index)`;`refreshEnemyAndClearCache`(与 `refreshEnemy`)在视图集为空时**仍登记 `enemyStore.set(view, emptySet)`** 并完成失效,不再提前 `return` 跳过登记</name>
<pros>与 `07-REVIEW.md` 的建议修法一致;幽灵伤害在两条路径(来源消失 / 范围收缩)上一次消除;空视图集被登记后,后续 `markEnemyDirty` 走局部刷新而不是 `refreshAll()` 整表重算(避免性能与行为双重抖动)</pros>
<cons>`enemyStore` 会开始保存「空集合」条目,`markEnemyDirty`/`deleteEnemy` 的读法需相应容错(`Set.size === 0` 分支);需在 jsDoc 写明空集语义</cons>
</option>
<option id="q1-cache-only">
<name>Q1 选项 B:只在移除端删除 `reducedCache`(不标脏、不登记空集),让 `getReducedDamage` 下次按剩余伤害重算</name>
<pros>改动最小(一行 `reducedCache.delete` 的循环),不动 `dirtyIndexes`/`enemyStore` 结构</pros>
<cons>`refreshIndex` 的重新聚合不再执行,`point.damages` 中的其它陈旧项不会被清理;`enemyStore` 为空的行为(下次 `markEnemyDirty` → `refreshAll()`)保持不变;CR-02 的「范围收缩」场景只被间接修复</cons>
</option>
<option id="q1-document">
<name>Q1 选项 C:不改行为,只把「`markEnemyDirty` 之后旧坐标缓存可能残留」写进 `IMapDamage` 契约并登记</name>
<pros>零风险</pros>
<cons>CR-02 是 review 判定的 correctness 缺陷(幽灵伤害可被玩家观测),与「本轮修复 15 项」的指令冲突(清单第 13 条)</cons>
</option>
<option id="q2-replace">
<name>Q2 选项 A:【建议默认】保持「旧 `mapData` 标 `expired` + 替换为新对象」的既有契约,并把该契约写进 `IMapLayerData`/`getMapRef` 的 jsDoc(持有者见 `expired === true` 必须重取;`array` 引用可能失效)</name>
<pros>零行为变更、零风险;`expired` 语义与 `resize`/`resize2`(`:625`/`:668`)保持一致(三处都是替换 + 标旧)</pros>
<cons>持有者必须主动轮询/检查 `expired`,契约靠文档而非类型约束</cons>
</option>
<option id="q2-inplace">
<name>Q2 选项 B:改为原地变更(`this.mapData.expired = false; this.mapData.array = this.mapArray`),不再替换对象</name>
<pros>外部持有者的引用始终有效、自动看到新缓冲(同引用原则的又一处应用)</pros>
<cons>`expired` 失效信号将**永不生效**(没有可观测的过期窗口),现有依赖该信号重取的调用方必须改成其它判据;`resize`/`resize2` 需一并改,改动面扩大</cons>
</option>
<option id="q3-clear-all">
<name>Q3 选项 A:【建议默认】`loadState`(三档)在读入动态块之前**清空本图层全部既有动态块**(复用内部 `removeTile` 解除两张映射,并按裁决决定是否 `syncStaticEvent(tile, false)` 与触发 `onDeleteDynamic`),使读档后动态块集合等于存档</name>
<pros>读档语义与「回到存档点」一致;多次读档不再累积;与 `MapLayer` 其它读档字段(静态块原地 `loadState`、矩阵 `setMapRef`)口径统一</pros>
<cons>读档会销毁旧动态图块实例(外部持有者脱钩——与「读档即替换」语义一致,但需在 jsDoc 写明);若勾选触发 `onDeleteDynamic`,渲染端会在读档期收到一批删除通知</cons>
</option>
<option id="q3-clear-missing">
<name>Q3 选项 B:只清理「存档中不存在」的坐标上的动态块(合并语义),存档里出现的坐标保留并复用</name>
<pros>旧实例尽量保留,减少脱钩</pros>
<cons>同一坐标上「存档块数 < 现有块数」的场景(如同一坐标叠了 3 块、存档只有 1 块)仍需逐块比对,逻辑复杂且容易出现半合并状态;实现成本明显高于收益</cons>
</option>
<option id="q3-document">
<name>Q3 选项 C:不改行为,把「`loadState` 会与既有动态块合并/累积」写进 `IMapLayer`/`IMapLayerSave` 契约并登记</name>
<pros>零代码风险</pros>
<cons>`#06-17-8` 未修(审计 G 明确「旧动态块累积」);读档结果与存档点不一致,属可观测的正确性问题</cons>
</option>
<option id="q4-include">
<name>Q4 选项 A:把 `IN-01`(`deleteMapDamage`/`deleteEnemy` 后的 `point.damages`/`affectedBy` 残留与 `sourcelessDamage` 空点)纳入本计划一并清理</name>
<pros>CR-02 的清理路径与 IN-01 相邻(都在 `removeEnemyAffecting`/`deleteEnemy` 的清理语句上),一次改动收敛</pros>
<cons>超出用户点名的 15 项清单(属 review Info 级),需要明确授权;会扩大 CR-02 的回归面</cons>
</option>
<option id="q4-register">
<name>Q4 选项 B:【建议默认】`IN-01` 只登记不修(本轮清单未含)</name>
<pros>严格守住清单边界;CR-02 的改动面最小(只补失效,不动移除语义)</pros>
<cons>`point.affectedBy`/`sourcelessDamage` 的空条目泄漏保留在册(review 已判定为 lazy cleanup 而非错误结果)</cons>
</option>
<option id="revise">
<name>(兜底)修订方案后再执行</name>
<pros>避免带错误契约落地</pros>
<cons>按 D-09 退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法</cons>
</option>
</options>
<record>
待用户在关卡处逐条填写(Q1..Q4:勾选选项 id 或给出裁决文字)。
**空的 `<record>` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。
填写后 Task 1–3 一律按本记录执行;若裁决与 Task 1–3 的分支描述冲突,暂停并请用户澄清。
</record>
<resume-signal>逐条给出 Q1..Q4 的裁决(例如「Q1=A, Q2=A, Q3=A, Q4=B」);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md</resume-signal>
<action>向用户汇报上述 4 个契约点,重点说明 Q1 对 `refreshAll()` 触发频率的影响、Q2 对 `getMapRef()` 持有者的影响、Q3 对渲染端 `onDeleteDynamic` 通知的影响。不得在裁决前修改任何文件。</action>
<verify>
<human-check>`<record>` 中 Q1..Q4 均有明确裁决,且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进</fails_when>
</verify>
<acceptance_criteria>
- Q1(CR-02 失效口径)、Q2(`setMapRef` 失效契约)、Q3(旧动态块处置)、Q4(IN-01 纳入与否)均有用户裁决并写入 `<record>`
- Q3 已明确是否复用 `deleteDynamic` 的 `syncStaticEvent`/`onDeleteDynamic` 行为
- 未在裁决前修改任何文件
</acceptance_criteria>
<done>Q1..Q4 裁决已完整写入 `<record>`;执行者据记录直接进入 Task 1,不再重复询问</done>
</task>
<task type="auto">
<name>Task 1: CR-02 —— `MapDamage` 失效补全(移除端标脏 + 空视图集登记),含「来源消失」与「范围收缩」两条幽灵伤害回归</name>
<files>packages-user/data-system/src/combat/mapDamage.ts, packages-user/data-system/src/combat/types.ts, packages-user/data-system/src/combat/mapDamage.test.ts</files>
<read_first>
- packages-user/data-system/src/combat/mapDamage.ts(:126-148 `markDirtyIndex`/`markEnemyDirty`、:150-167 `deleteEnemy` 的标脏参照、:224-256 `clearSourceState`/`removeEnemyAffecting`、:290-333 `refreshEnemyAndClearCache`、:338-372 `refreshEnemy`、:395-427 `refreshIndex`)
- packages-user/data-system/src/combat/types.ts(`IMapDamage` 方法声明与 jsDoc 位置)
- packages-user/data-system/src/combat/mapDamage.test.ts(:1-2 文件头覆盖说明、夹具(假视图/转换器/reducer)、`deleteEnemy` 与 `markEnemyDirty` 现有用例)
</read_first>
<action>
按 Task 0 的 Q1 裁决修复。**若 Q1 = 选项 A**:
(1) `removeEnemyAffecting`(`:240-256`):在内层 `store.damages.forEach((dam, index) => …)`(`:246-252`)中把 `index` 收集进一个 `Set<number>`;方法结束前对每个收集到的索引调用 `this.markDirtyIndex(index)`;返回类型与调用点按实现需要调整(两个调用点:`:294`、`:339`);
(2) `refreshEnemyAndClearCache`(`:290-333`)与 `refreshEnemy`(`:338-372`):把 `if (set.size === 0) return;` 改为——**先** `this.enemyStore.set(view, set)`(空集也登记,使后续 `markEnemyDirty` 走局部路径而非 `refreshAll()`),再在 `set.size === 0` 时直接结束(无需继续遍历视图);非空时保持既有登记与新索引失效(`:329-332` 的 `dirtyIndexes.delete`/`reducedCache.delete` 不变);
(3) `enemyStore` 的读法容错:`markEnemyDirty`(`:135-148`)与 `deleteEnemy`(`:150-167`)在 `store` 为**空集合**时不得视为「未登记」——`markEnemyDirty` 对空集合应直接结束(无视图可刷),`deleteEnemy` 对空集合应只做 `enemyStore.delete(view)`;`removeEnemyAffecting` 对空集合的遍历保持无副作用;
(4) `IMapDamage`(`combat/types.ts`)的 `markEnemyDirty`/`deleteEnemy` jsDoc 补写失效契约:调用后该怪物此前影响的坐标索引缓存保证失效;该怪物的反向索引(含空集)保持登记,后续刷新为局部而非整表。
**若 Q1 = 选项 B**:只在 `removeEnemyAffecting` 中收集索引并 `this.reducedCache.delete(index)`(不动 `dirtyIndexes`、不登记空集),并按裁决在 jsDoc 写明其局限。
**若 Q1 = 选项 C**:不改行为,仅在 jsDoc 写明已知限制并在 SUMMARY 登记。
回归用例(`mapDamage.test.ts`,中文单行注释;文件头覆盖说明同步更新):
(a) **来源消失**:注册带伤害的怪物视图 → `getReducedDamage(loc)` 预热缓存 → 使该怪物的转换视图集变为空(例如让假转换器返回空数组后调用 `refreshEnemy`/`markEnemyDirty`,或移除其特殊属性)→ 再次 `getReducedDamage(loc)` 断言为 `null`(或不再包含已移除贡献)、`getSeparatedDamage(loc)` 为空;
(b) **范围收缩**:假视图的范围/命中点位在第二次转换时变小 → `markEnemyDirty(view)` 后断言跌出范围的坐标 `getReducedDamage` 不再包含已移除贡献(用「多点位 + 局部收缩」构造,避免与 (a) 重复);
(c) **整怪删除回归不破**:既有 `removes enemy-sourced damage when the enemy is deleted`(`:106-…` 附近)用例保持通过;
(d) **不触发整表重算**(Q1 选项 A 时):在空视图集场景下再次 `markEnemyDirty(view)`,断言行为仍为局部刷新(例如通过 reducer/转换器调用次数或 `enemyStore` 可观测状态断言,不使用对私有字段的反射)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts</automated>
<fails_when>来源消失或范围收缩后 `getReducedDamage` 仍返回含已移除贡献的缓存值(幽灵伤害);或既有 `deleteEnemy`/102/103/104 用例出现回归 —— 按 D-09 退出并修订计划,不得改用「让调用方手动 markDirty」的替代方案</fails_when>
</verify>
<acceptance_criteria>
- 移除端触达的每个坐标索引都被失效(Q1 选项 A:`markDirtyIndex`;选项 B:`reducedCache.delete`)
- 视图集为空时按裁决完成失效与反向索引处置,并有 `markEnemyDirty` 重复调用不触发 `refreshAll()` 的断言(选项 A)
- `IMapDamage` 的 jsDoc 与最终契约一致;既有 mapDamage 用例全部保持通过
</acceptance_criteria>
<done>怪物光环/特殊属性消失或范围收缩后不再有幽灵地图伤害,且失效路径保持局部刷新(CR-02 按裁决关闭)</done>
</task>
<task type="auto">
<name>Task 2: `#06-17-8` —— `MapLayer.setMapRef` 失效契约 + 读档旧动态块处置(按 Q2/Q3 裁决)</name>
<files>packages-user/data-base/src/map/mapLayer.ts, packages-user/data-base/src/map/mapLayer.test.ts, packages-user/data-base/src/map/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/map/mapLayer.ts(:44-51 私有映射、:100-124 `removeTileFromPosMap`/`removeTile`、:133-145 `syncStaticEvent`、:371-395 `setMapRef`/`getMapRef`、:418-433 `createDynamic`、:461-507 删除动态块、:512-514 `iterateDynamicTiles`、:600-681 `resize`/`resize2`(另两处 `expired`)、:814-822 `saveState`、:841-849 `loadDynamics`、:855-931 三档 `load*`)
- packages-user/data-base/src/map/mapLayer.test.ts(现有动态块与 `setMapRef` 用例)
- packages-user/data-base/src/map/saveLoad.test.ts(:176-220 `MapLayer save and load round trips` 与码 124 用例)
- packages-user/data-base/src/map/types.ts(`IMapLayerData`/`IMapLayer`/`IMapLayerSave` 的 jsDoc 位置)
</read_first>
<action>
(1) **Q2 裁决落地(`setMapRef` 契约)**:选项 A → 保持替换 + 标旧对象 `expired`(`:380-386`)的实现不变,在 `IMapLayerData`/`getMapRef()` 的 jsDoc 写明「持有者见 `expired === true` 必须重取;`array` 引用可能失效」,并确保 `resize`/`resize2`(`:625`/`:668`)的三处行为与文档一致(不修改其行为,只核对口径);选项 B → 三处一并改为原地变更(`this.mapData.expired = false` + `this.mapData.array = this.mapArray`),并同步更新 jsDoc 与任何依赖 `expired` 信号的既有用例(逐条在 SUMMARY 说明)。
(2) **Q3 裁决落地(`loadDynamics`/`loadState`)**:选项 A → 在 `loadDynamics`(`:841-849`)入口先清空本图层全部既有动态块:遍历 `iterateDynamicTiles()` 收集快照 → 逐个复用内部 `removeTile(tile)`(`:118-124`,解除 `tilePosMap`/`posTileMap`),并按裁决决定是否对每个块执行 `syncStaticEvent(tile, false)`;再按存档创建新块(既有逻辑)。**注意**:`setMapRef` 已清 `staticTileCache`,`syncStaticEvent` 在清理时是否有意义由裁决决定;若裁决要求触发 `onDeleteDynamic`,须用 `forEachHook` 与 `deleteDynamic`(`:498-507`)保持一致的参数顺序(`hook.onDeleteDynamic?.(tile)`)。选项 B → 只清理存档中不存在坐标上的动态块;选项 C → 不改行为,只在 jsDoc 写明合并/累积语义并登记。
(3) **三档一致性**:清理逻辑放在 `loadDynamics` 内(三档共用 `:862-863`/`:886-887`/`:917-918` 三处调用点),避免三档行为分叉;若裁决要求「无 `dynamicBlocks` 字段时也清理」,则在三档 `load*` 的 `loadDynamics` 调用处按存档字段缺失情况补齐。
回归用例:
(a) `mapLayer.test.ts`:`createDynamic` 若干块 → 记录实例 → `setMapRef`(按 Q2 裁决断言 `expired`/对象身份/`array` 可见性);读档前动态块数为 N(按 Q3 裁决断言清理后的集合与实例状态);
(b) `saveLoad.test.ts`:同一图层先创建多于存档的动态块 → `loadState`(三档各自循环)→ 断言 `[...iterateDynamicTiles()].length` 等于存档中的块数(不累积);再 `loadState` 第二次(模拟重复读档)→ 数量仍等于存档块数;既有 `restores the matrix and point events across all compressions` 与码 124 用例保持通过。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts</automated>
<fails_when>重复读档(或读到动态块更少的存档)后 `iterateDynamicTiles()` 的块数大于存档块数(累积);或 `setMapRef` 的失效契约与 Q2 裁决/文档不一致;或既有矩阵/事件/码 124 用例出现回归 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- Q2 裁决落地:`setMapRef`(及 `resize`/`resize2` 的口径)与 jsDoc 一致,并有 `getMapRef()` 失效行为的断言
- Q3 裁决落地:读档后动态图块集合等于存档(不累积)并有断言;重复读档的断言通过
- 三档压缩的清理行为一致;既有 `mapLayer.test.ts`/`saveLoad.test.ts` 断言未弱化
</acceptance_criteria>
<done>`MapLayer` 的失效契约成文且读档不再累积旧动态块(`#06-17-8` / 审计 G 按裁决关闭)</done>
</task>
<task type="auto">
<name>Task 3: D-12/D-44 门禁 + 全量套件 + 缺口登记(CR-02 / `#06-17-8` / IN-01 处置)与 Phase 7 验证失效说明</name>
<files>.planning/phases/07-data-fixes/07-13-SUMMARY.md</files>
<read_first>
- .planning/phases/07-data-fixes/07-REVIEW.md(CR-02 与 IN-01 编号)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(G 行)
- .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status)
- .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表)
- .planning/WINDOWS.md(确认两条无入账 id)
</read_first>
<action>
(1) **门禁(D-12 / D-44 文件级)**:对 `files_modified` 中每个改动的文件执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误;`pnpm test:ci` 全绿(对比基线 66 文件 / 690 通过 / 1 跳过,跳过项仍只能是 `equipment.test.ts:306` 码 147)。
(2) **原子提交(D-13)**:Task 1(CR-02)与 Task 2(`#06-17-8`)各自独立提交(`fix(07-13): …`)。
(3) **缺口登记**:SUMMARY 逐条标注 CR-02 与 `#06-17-8` 已闭合(附 commit);`IN-01` 按 Q4 裁决标注「已修」或「保留在册」;`IN-04`/`IN-05` 标注「本轮清单外,保留在册」。`WINDOWS.md` 不新建、不 `fixed`/`waive`。
(4) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条
- Task 1/2 各有独立提交并含对应锚点
- SUMMARY 完成缺口登记(CR-02、`#06-17-8`、IN-01/IN-04/IN-05 处置)与 Phase 7 验证失效说明
- `WINDOWS.md` 未被本计划改动
</acceptance_criteria>
<done>D-44 三步门禁全绿、原子提交完成、缺口登记与 Phase 7 验证失效说明写入 SUMMARY</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 怪物状态变更 → 地图伤害缓存 | 光环/特殊属性增删与范围变化由战斗侧驱动,缓存失效不全即产生与真实状态不一致的伤害 |
| 存档数据 → 地图图层 | 读档决定矩阵、静态块、动态块;清理不全即与存档点不一致 |
| 图层内部状态 → 外部持有者 | `getMapRef()` / `iterateDynamicTiles()` / 图块实例被外部(渲染端、事件层)持有 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-37 | Tampering | `MapDamage.removeEnemyAffecting`(`mapDamage.ts:240-256`) | high | mitigate | Task 1:收集被移除索引并逐个 `markDirtyIndex`,使旧坐标按 `refreshIndex` 重新聚合(CR-02 主修复) |
| T-7-38 | Tampering | `refreshEnemyAndClearCache` 的空视图集早退(`:300`) | high | mitigate | Task 1:空集也登记 `enemyStore.set(view, set)` 并完成失效,避免缓存永久残留与 `refreshAll()` 级联 |
| T-7-39 | Repudiation | 空集登记后的 `markEnemyDirty`/`deleteEnemy` 读法 | low | mitigate | Task 1:`markEnemyDirty`/`deleteEnemy`/`removeEnemyAffecting` 对空集合显式容错,并有重复调用断言 |
| T-7-40 | Tampering | `MapLayer.loadDynamics` 不清理旧动态块(`:841-849`) | medium | mitigate | Task 2:按裁决在读入前清理既有动态块(复用内部 `removeTile`),三档一致,并有重复读档断言 |
| T-7-41 | Repudiation | `setMapRef` 的失效信号 `expired` 无成文契约(`:371-391`) | low | mitigate | Task 2 按 Q2 裁决:保持替换+标过期并写入 jsDoc,或原地变更并同步全部依赖点(逐条登记) |
| T-7-42 | Denial of Service | 清空动态块时触发大量 `onDeleteDynamic`/`syncStaticEvent` | low | mitigate | Task 0 Q3 已把「是否复用 `deleteDynamic` 的同步与钩子」列为裁决点;实现按裁决执行并在测试中断言通知口径 |
| T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 |
</threat_model>
<verification>
1. `pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts` 全绿。
2. `pnpm test:ci` 全绿:66 文件 / 690 通过 / 1 跳过(仅 `equipment.test.ts:306` 码 147)。
3. `pnpm exec eslint <files_modified 中每个改动文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。
4. Task 0 的 `<record>` 中 Q1..Q4 均有裁决,代码与裁决逐条一致(SUMMARY 对照表)。
5. 无新增依赖、无新建源码文件、`WINDOWS.md` 未改动、`gameMap.ts`/`mapState.ts`/`dynamicTile.ts`/`context.ts` 未改动。
6. Phase 7 验证失效与重跑要求已在 `07-13-SUMMARY.md` 明写,`ROADMAP.md` 已更新。
</verification>
<success_criteria>
- CR-02 按 Q1 裁决闭合:来源消失与范围收缩两条路径都不再有幽灵伤害,且失效路径保持局部刷新。
- `#06-17-8` 按 Q2/Q3 裁决闭合:失效契约成文;读档后动态块集合等于存档(不累积),三档一致。
- `IN-01`/`IN-04`/`IN-05` 按裁决登记或修复,并在 SUMMARY 逐条记录。
- `pnpm test:ci` 全绿、无新增 `it.skip`、既有断言未弱化;D-44 三步门禁全绿。
- Phase 7 验证失效与重跑要求已记录。
</success_criteria>
<output>
Create `.planning/phases/07-data-fixes/07-13-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,301 @@
---
phase: 07-data-fixes
plan: 14
type: execute
wave: 14
depends_on:
- 07-13
files_modified:
- packages-user/data-fallback/src/hero.ts
- packages-user/data-fallback/src/hero.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 40000
raw_tokens: 40000
tasks: 3
confidence: low
must_haves:
truths:
- "`core.status.hero` 代理的属性读写始终作用于**当前活属性**:按 Task 0 Q1 裁决,代理不再闭包持有 `getModifiableAttribute()` 的一次性结果(或按裁决在加载后重绑),并有回归断言"
- "`patchHero` 的既有可观测语义不变:`set` 触发告警 56 并写入属性、`get` 触发告警 56 并返回 {Q2 裁决的} 属性值;`core.status.hero.flags` 仍被替换为拒绝写入/读取并告警 54 的代理,`window.flags` 同样指向它"
- "`resetHero` 钩子触发时仍完成 patch 安装(既有行为不变),新增回归用例锚定「活属性跟随」"
- "`pnpm test:ci` 全绿,不新增 `it.skip`;本计划为该包首个测试文件,不得因引入测试而改动其它包的既有断言"
artifacts:
- path: packages-user/data-fallback/src/hero.ts
provides: "`core.status.hero` 代理按 Q1 裁决改为活属性读取/重绑;`patchHero` 的 jsDoc 写清代理的属性解析口径与 legacy 侧的约束"
- path: packages-user/data-fallback/src/hero.test.ts
provides: "该包首个测试文件:`resetHero` → 代理安装、活属性跟随(换属性实例/换 hero 后读写落到新实例)、告警 56/54 口径、`window.flags` 绑定"
key_links:
- "`patchHero` 的 `const attr = state.hero.getModifiableAttribute()`(`hero.ts:8`)↔ 代理的 `set`/`get` 陷阱(`:14`/`:20`)——闭包持有一次性结果是 `#06-17-7` 的根因"
- "`hook.on('resetHero', …)`(`hero.ts:6`)↔ `GameEvent` 的 `resetHero: [hero: any]`(`data-base/src/game.ts:151-152`)与 `hook.on('loadData')`(`game.ts:149-150`,由 legacy `project/functions.js` 发出)——Q1 选项 B 的重绑触发点"
- "`core.status.hero.flags` 代理(`hero.ts:26-36`)与 `window.flags`(`:37`)↔ 码 54 的拒绝语义"
- "`state.hero.getModifiableAttribute()`(`data-base/src/hero/state.ts:77-79`,07-09 后返回 `readonly attribute` 同一实例)——数据侧已保证属性实例稳定,本计划处理的是「hero/属性实例被整体替换」的兜底"
assumptions:
- "A1(D-10 例外,本计划授权):本计划为修复计划,为 `#06-17-7` 新增回归是必需项;同时为该包建立首个测试文件(此前 `packages-user/data-fallback` 零测试)"
- "A2(不改数据侧):数据侧 `HeroState`/`HeroAttribute` 的行为由 07-09 已定(属性原地读档、实例稳定),本计划**不**修改 `packages-user/data-base`/`data-state` 的任何文件;若实现中发现必须新增数据侧钩子,暂停并回到 Task 0 关卡追加裁决"
- "A3(legacy 边界):`data-fallback` 属数据端(`packages-user` 内、非渲染端),其 patch 面向仓外旧引擎;本计划不引入新依赖、不改 legacy 插件与渲染端"
- "A4(测试环境):该包测试需 stub 全局 `main`(`{ replayChecking: true }`,避免 `data-base/src/game.ts` 模块级 `GameListener` 触碰 `window`)与 `core`/`window`;`hook` 为 `@user/data-base` 导出的模块级单例(`game.ts:155`),可 `emit('resetHero', hero)`"
prohibitions:
- "不改 `packages-user/data-fallback/src/flag.ts`(`patchFlags` 闭包的是稳定的 `FlagSystem` 实例,审计未列为缺陷);若 Q4 裁决要求一并处理,须在 `<record>` 明确授权后才可改"
- "不改 `packages-user/data-fallback/src/index.ts` 的 `patchAll` 组成,除非 Q3 裁决明确要求"
- "不改任何数据侧源码(`data-common`/`data-base`/`data-system`/`data-state`)与渲染端 `@user/client-*`"
- "不引入新依赖(含测试框架依赖:复用仓库既有 vitest)、不新增 `import type`、不使用连续 `as` 断言、`package.json` 不改(vitest 默认含 `*.test.ts`,无需新增脚本)"
- "不新增 `it.skip`;不改其它包的既有断言;不为转绿弱化断言"
---
<objective>
闭合审计条目 C(`#06-17-7`):`packages-user/data-fallback/src/hero.ts` 的 `core.status.hero` 代理闭包持有 `state.hero.getModifiableAttribute()` 的一次性结果(`hero.ts:8`),旧引擎侧对 `core.status.hero` 的读写会落到**被替换掉的旧属性实例**上。
现状(锚点):`patchHero`(`hero.ts:5-39`)在 `resetHero` 钩子里取 `const attr = state.hero.getModifiableAttribute()`(`:8`),代理的 `set`(`:14`)与 `get`(`:20`)都引用该闭包变量。07-09 已让 `HeroAttribute` 在自身实例上原地读档,因此**常态路径**下该闭包仍然有效;但若 `state.hero` 整体被替换(或仓外旧引擎在读档后未重发 `resetHero`),代理会持续读写孤儿属性——审计把该条判为「低-中,取决于仓外 `resetHero` 是否重发」,属未封口的兜底缺口。
Purpose: legacy 数据接口(编辑器与旧引擎样板)是数据端对外的一层薄壳,它的引用一旦脱钩不会报错、只会静默写错对象;把「每次访问都解析活属性」作为契约,可以让这层薄壳不依赖任何调用顺序假设,与 07-09/07-11 建立的同引用原则收尾一致。
Output: `hero.ts` 的代理解析口径调整(按 Task 0 Q1 裁决)+ 该包首个测试文件 `hero.test.ts`(活属性跟随回归 + 告警 54/56 口径)。
**Artifacts this phase produces(本计划产出物)**:修改 `packages-user/data-fallback/src/hero.ts`;**新建** `packages-user/data-fallback/src/hero.test.ts`(该包首个测试);无新增依赖、无新增脚本。
**Phase 7 验证已失效**:`07-VERIFICATION.md`(2026-09-16T06:04:11Z)已因 07-09 与本轮 07-10..07-14 失效。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论。
Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 14 个计划,承接 `07-LOADSTATE-AUDIT.md` C 行(`#06-17-7`)。D-02: 属 legacy 兼容层(`packages-user/data-fallback`)。D-03/D-04/D-05/D-06: 其它系统契约裁决,不涉及。D-07: path 接线为用户负责项,不涉及(本计划不改 `path/system.ts`)。D-08: enemy,不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划,回归用例为必需项(A1)。D-11: 代理契约变更需同步 `patchHero` 的 jsDoc。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 单一根因,一个 fix commit + 一个 test commit(或合并为一个 `fix(07-14)` 提交,按 D-13 的原子粒度判断)。D-14: 该条无 `WINDOWS.md` 条目,只登记不新建。
执行序:`depends_on: 07-13`(Wave 14)。门禁型依赖(D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录);源码层面与 07-10..07-13 无耦合(本计划只碰 `data-fallback`)。
**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147,D-06)。本计划执行后文件数应变为 67(新增 `packages-user/data-fallback/src/hero.test.ts`),通过数相应增加;这是唯一被允许的基线变化,必须在 SUMMARY 说明。
</objective>
<execution_context>
@C:/Users/book/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/book/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md
@.planning/phases/07-data-fixes/07-09-PLAN.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/codebase/TESTING.md
@dev.md
@packages-user/data-fallback/src/hero.ts
@packages-user/data-fallback/src/index.ts
@packages-user/data-fallback/src/flag.ts
@packages-user/data-base/src/game.ts
@packages-user/data-base/src/hero/state.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 契约裁决关卡(D-09)——代理解析口径(每次读活属性 vs 加载后重绑 vs 仅文档)、`get` 返回基础属性还是最终属性、测试落点与测试入口、`patchFlags` 是否一并处理</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-fallback/src/hero.ts(全文件::8 `attr` 闭包、:9-22 代理陷阱、:23 `core.status.hero` 赋值、:26-37 flags 代理与 `window.flags`)
- packages-user/data-fallback/src/index.ts(`patchAll` 组成)
- packages-user/data-fallback/src/flag.ts(`patchFlags` 闭包的是稳定 `FlagSystem` 实例;`switchName` 使用全局 `core`)
- packages-user/data-fallback/package.json(依赖与脚本现状:无 test 脚本,由根 vitest 默认发现 `*.test.ts`)
- packages-user/data-base/src/game.ts(:147-155 `loadData` / `resetHero` 事件声明与 `export const hook`;:166-184 `GameListener` 构造器对 `main`/`window` 的依赖)
- packages-user/data-base/src/hero/state.ts(:77-79 `getModifiableAttribute`、:138-153 `loadState`——07-09 后属性实例原地读档)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(C 行、触发序列、未确定项 1)
- .planning/codebase/TESTING.md(vitest 约定、`logger.catch` 断言模式、`vi.hoisted` stub 全局的既有写法)
</read_first>
<decision>
`#06-17-7` 的修复口径与测试落点:
(Q1)代理的活属性解析方式(每次访问解析 vs 依赖 `loadData`/`resetHero` 重绑 vs 仅文档约束);(Q2)`get` 陷阱返回基础属性还是最终属性;(Q3)回归测试的落点与入口(经真实 `patchHero` + 全局 stub,还是抽出具名工厂直接测);(Q4)`patchFlags` 的同类闭包是否一并处理。
</decision>
<context>
Q1 决定这层薄壳是否依赖调用顺序:选项 A 让代理在每次 `get`/`set` 时解析 `state.hero.getModifiableAttribute()`,任何替换都自动跟随,代价是每次访问多一次方法调用;选项 B 复用既有事件(`hook.on('loadData')` 由 legacy `project/functions.js` 发出 / `resetHero`)在加载后重绑,代价是依赖 legacy 侧真的发出该事件(审计的「未确定项 1」正是这一点无法在仓内确证)。
Q2 是行为问题:今日 `get` 返回 `attr.getBaseAttribute(p)`(基础值),若改为 `getFinalAttribute`(含加成),旧引擎读到的数值会变化——必须由用户拍定。
Q3 是工程问题:`data-fallback` 目前零测试,经真实 `patchHero` 测需要 stub 全局 `core`/`window`/`main` 并 `emit('resetHero')`(贴近真实装配路径);抽出口工厂则测试更简单但会新增一个模块级导出(`dev.md` 的模块/导出原则)。
Q4 是范围问题:`patchFlags` 闭包的是稳定的 `FlagSystem` 实例(审计判定安全),但其中 `switchName` 读全局 `core.status.floorId`,与本次同族。
</context>
<options>
<option id="q1-live">
<name>Q1 选项 A:【建议默认】代理陷阱内每次访问解析活属性(`const attr = state.hero.getModifiableAttribute()` 移入 `set`/`get` 内部)</name>
<pros>不依赖任何调用顺序与仓外事件;`state.hero` 或属性实例被替换后自动跟随;改动极小(一处提取移动)且无新增导出;与 07-09/07-11 的同引用原则互补(数据侧保证不替换,兼容层不假设不替换)</pros>
<cons>每次属性读写多一次 `getModifiableAttribute()` 调用(薄壳路径,非热点循环);若未来 `state.hero` 在单次读写之间被替换,读写会落在不同实例上(语义上正是期望行为)</cons>
</option>
<option id="q1-rebind">
<name>Q1 选项 B:保留闭包,但在 `hook.on('loadData')` / `resetHero` 时重建代理并重新赋值 `core.status.hero`</name>
<pros>每次访问零额外开销;代理只在装配点创建一次</pros>
<cons>依赖 legacy 侧真的发出 `loadData`(仓内无法确证,审计「未确定项 1」);遗漏任一加载路径即回到原缺陷;`core.status.hero` 指向的对象会在加载时变化(外部若缓存过该代理会脱钩)</cons>
</option>
<option id="q1-document">
<name>Q1 选项 C:不改行为,在 `patchHero` 的 jsDoc 写明「读档后必须重发 `resetHero`,否则代理指向旧属性」并登记为已接受契约</name>
<pros>零代码风险、零测试环境成本</pros>
<cons>把缺陷转为「调用方必须记住」的文档约定;与「本轮修复 15 项」的指令冲突(清单第 15 条,用户已把该条列入修复范围)</cons>
</option>
<option id="q2-base">
<name>Q2 选项 A:【建议默认】保持 `get` 返回 `attr.getBaseAttribute(p)`(基础值)与 `set` 写 `attr.set(p, v)`,仅把解析目标改为活属性</name>
<pros>行为完全不变(只修引用解析),既有 legacy 语义与数值口径零回归;测试只需断言「读写落在活属性上」</pros>
<cons>旧引擎经该代理读不到装备/修饰器加成(现状即如此,属既有契约)</cons>
</option>
<option id="q2-final">
<name>Q2 选项 B:`get` 改为返回 `attr.getFinalAttribute(p)`(含修饰器/装备加成)</name>
<pros>旧引擎读到的数值与游戏内最终数值一致</pros>
<cons>改变 legacy 可观测行为(旧引擎若用基础值做存档/编辑器换算会出现数值跳变);需要额外的回归基线来确定哪些旧接口受影响</cons>
</option>
<option id="q3-real">
<name>Q3 选项 A:【建议默认】新建 `packages-user/data-fallback/src/hero.test.ts`,经真实 `patchHero` 测试:`vi.hoisted` stub `main`/`location`,stub 全局 `core`(含 `status.hero` 与 `flags`)与 `window`,用 `@user/data-base` 导出的 `hook` 发出 `resetHero`</name>
<pros>测的是真实装配路径(含 `hook` 订阅、`core.status.hero` 赋值、`window.flags` 绑定),回归价值最高;不改生产导出面</pros>
<cons>需要构造若干全局桩;类型上对全局 `core`/`window` 的赋值可能需要 `// @ts-expect-error`(按 `dev.md:91` 说明原因,不使用连续 `as`)</cons>
</option>
<option id="q3-factory">
<name>Q3 选项 B:从 `hero.ts` 抽出具名工厂(如同文件内导出的 `createHeroProxy(state, hero)`),`patchHero` 调用它,测试直接测工厂</name>
<pros>测试无需全局桩,断言更聚焦;代理构造逻辑可复用</pros>
<cons>新增模块级导出(`dev.md` 的模块/导出原则要求导出内容属于本包,技术上合规但扩大公开面);测试不再覆盖 `hook`/`core`/`window` 装配路径</cons>
</option>
<option id="q3-both">
<name>Q3 选项 C:既抽工厂又保留一条经真实 `patchHero` 的装配用例</name>
<pros>覆盖最全</pros>
<cons>文件与用例数量翻倍,超出本条缺陷需要的回归强度</cons>
</option>
<option id="q4-skip">
<name>Q4 选项 A:【建议默认】`patchFlags` 不处理(其闭包指向稳定的 `FlagSystem` 实例,审计未列为缺陷)</name>
<pros>严格守住清单边界(第 15 条只提 hero 代理)</pros>
<cons>`switchName` 读全局 `core.status.floorId` 的耦合保留(不属本次缺陷)</cons>
</option>
<option id="q4-include">
<name>Q4 选项 B:一并把 `patchFlags` 的 `state.flags` 解析改为活引用(若 `state.flags` 可被替换)</name>
<pros>同族模式一并封口</pros>
<cons>超出清单;`FlagSystem` 实例在 07-11 后本身是稳定对象(字段实例也可保留),改动收益低、回归面增加</cons>
</option>
<option id="revise">
<name>(兜底)修订方案后再执行</name>
<pros>避免带错误契约落地</pros>
<cons>按 D-09 退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法</cons>
</option>
</options>
<record>
待用户在关卡处逐条填写(Q1..Q4:勾选选项 id 或给出裁决文字)。
**空的 `<record>` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。
填写后 Task 1–2 一律按本记录执行;若裁决与 Task 1–2 的分支描述冲突,暂停并请用户澄清。
</record>
<resume-signal>逐条给出 Q1..Q4 的裁决(例如「Q1=A, Q2=A, Q3=A, Q4=A」);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md</resume-signal>
<action>向用户汇报上述 4 个契约点,重点说明 Q2 对 legacy 可观测数值的影响、Q1 对「是否依赖仓外事件」的取舍、Q3 对测试环境(全局 stub)的影响。不得在裁决前修改任何文件。</action>
<verify>
<human-check>`<record>` 中 Q1..Q4 均有明确裁决,且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进</fails_when>
</verify>
<acceptance_criteria>
- Q1(解析口径)、Q2(`get` 语义)、Q3(测试落点与入口)、Q4(`patchFlags` 是否处理)均有用户裁决并写入 `<record>`
- Q3 已明确测试文件路径与全局 stub 范围
- 未在裁决前修改任何文件
</acceptance_criteria>
<done>Q1..Q4 裁决已完整写入 `<record>`;执行者据记录直接进入 Task 1,不再重复询问</done>
</task>
<task type="auto">
<name>Task 1: `#06-17-7` —— `core.status.hero` 代理跟随活属性(按 Q1/Q2/Q3 裁决实现)+ 该包首个回归测试文件</name>
<files>packages-user/data-fallback/src/hero.ts, packages-user/data-fallback/src/hero.test.ts</files>
<read_first>
- packages-user/data-fallback/src/hero.ts(全文件)
- packages-user/data-base/src/game.ts(:147-155 `loadData`/`resetHero` 事件与 `export const hook`、:166-184 `GameListener` 对 `main`/`window` 的依赖)
- packages-user/data-base/src/hero/state.ts(:77-79 `getModifiableAttribute` 返回类型 `IHeroAttribute<THero>`)
- packages-user/data-base/src/hero/types.ts(`IHeroAttribute` 的 `getBaseAttribute`/`getFinalAttribute`/`set` 签名;`IReadonlyHeroAttribute`)
- .planning/codebase/TESTING.md(`vi.hoisted` stub 全局、`logger.catch` 断言模式的既有写法)
- 参考既有测试的 stub 写法:packages-user/data-common/src/replay/array.test.ts:7-25、packages-user/data-system/src/combat/mapDamage.test.ts:19-43
</read_first>
<action>
(1) **按 Q1 裁决修改 `hero.ts`**:选项 A → 删除 `:8` 的 `const attr = state.hero.getModifiableAttribute()` 闭包,改为在 `set`(`:10-16`)与 `get`(`:17-21`)陷阱体内各自 `const attr = state.hero.getModifiableAttribute();` 后使用(保持告警 56 的调用与 `set` 的 `target[p] = newValue` 顺序不变);选项 B → 保留代理构造,按裁决监听 `hook.on('loadData')`(或 `resetHero`)重建并重新赋值 `core.status.hero`;选项 C → 不改行为,把契约写进 jsDoc 并在 SUMMARY 登记;
(2) **按 Q2 裁决**:`get` 返回 `attr.getBaseAttribute(p)` 或 `attr.getFinalAttribute(p)`,并同步 `patchHero` 的中文 jsDoc(写在函数上方,说明该代理解析的是活属性、以及返回值口径);
(3) 其余行为逐条保持:`:11` 与 `:18` 的 `logger.warn(56, 'core.status.hero', 'state.hero')`、`:12` 的 `target[p] = newValue`、`:23` 的 `core.status.hero = proxy`、`:26-37` 的 flags 代理与 `window.flags` 绑定(按 Q4 裁决决定是否触碰 `flag.ts`,默认不动);
(4) **新建 `hero.test.ts`**(按 Q3 裁决;文件头用中文单行注释说明覆盖内容,与仓库其它测试一致):
- `vi.hoisted(() => { vi.stubGlobal('main', { replayChecking: true }); vi.stubGlobal('location', { origin: 'http://localhost' }); })` + `afterAll(() => vi.unstubAllGlobals())`,与既有测试同款;
- 构造假 `core`(`{ status: { hero: { flags: {} } } }` 形态)与假 `window` 并 `vi.stubGlobal`,取 `hook`(`@user/data-base` 导出)后 `hook.emit('resetHero', fakeHero)` 触发 `patchHero` 的装配;
- 假 `state`:`{ hero: { getModifiableAttribute: () => currentAttr } }`(`currentAttr` 可切换),断言:
(a) 装配后 `core.status.hero` 是代理对象且 `core.status.hero.flags` 为拒绝写入/读取的代理、`window.flags` 指向同一对象;
(b) **活属性跟随**:切换 `currentAttr`(模拟属性实例被替换)后经 `core.status.hero.xxx` 读写 → 断言落在**新**属性实例上(旧实例零改动),并按 Q2 裁决断言 `get` 的返回值口径;
(c) 告警口径:`logger.catch(() => { core.status.hero.atk = 1; })` 含 56;对 flags 代理的读写含 54;
- 类型处理遵循 `dev.md`:不使用连续 `as`;无法避免时用 `// @ts-expect-error` 并写明原因;
- 若 Q3 选了 B/C(抽工厂),则按裁决同时/改为直接测工厂,并保留装配路径用例(若裁决要求)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-fallback/src/hero.test.ts</automated>
<fails_when>切换活属性实例后经代理读写仍落在旧属性上(Q1 选 A 时);或告警 54/56 口径变化;或 `core.status.hero.flags` / `window.flags` 绑定丢失 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 代理按 Q1 裁决跟随活属性(选项 A)或按裁决重绑/文档化(B/C),并有断言
- `get` 返回值口径与 Q2 裁决一致
- `hero.test.ts` 覆盖:装配、活属性跟随、告警 56、flags 代理与 `window.flags`(告警 54)
- 仅改动 `hero.ts` 与本计划新增的 `hero.test.ts`;`flag.ts`/`index.ts`/数据侧文件零改动(除非 Q4 授权)
</acceptance_criteria>
<done>`core.status.hero` 代理不再闭包过期属性,读写始终作用于活属性;该包首个测试文件锚定装配与跟随语义(`#06-17-7` / 审计 C 关闭)</done>
</task>
<task type="auto">
<name>Task 2: D-12/D-44 门禁 + 全量套件(含新增测试文件后的基线核对)+ 缺口登记与 Phase 7 验证失效说明</name>
<files>.planning/phases/07-data-fixes/07-14-SUMMARY.md</files>
<read_first>
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(C 行与处置段)
- .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status)
- .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表)
- .planning/WINDOWS.md(确认该条无入账 id)
- .planning/codebase/TESTING.md(`pnpm test:ci` 口径与文件数说明)
</read_first>
<action>
(1) **门禁(D-12 / D-44 文件级)**:对 `hero.ts` 与新增的 `hero.test.ts` 执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这两个文件的类型错误;`pnpm test:ci` 全绿——**基线与执行前对比**:文件数由 66 变为 67(新增该包首个测试文件),通过数相应增加,跳过项仍只能是 `equipment.test.ts:306` 码 147;若新增文件未被 vitest 发现(分母仍为 66),记录原因并确认该用例确实被根 vitest 默认 include 覆盖(必要时在 SUMMARY 说明)。
(2) **原子提交(D-13)**:`hero.ts` 的实现与 `hero.test.ts` 可合并为一个 `fix(07-14): …`(单一根因)分别提交亦可;提交信息携带 `#06-17-7` 锚点。
(3) **缺口登记**:SUMMARY 逐条标注 `#06-17-7`(审计 C)已闭合(附 commit)与 Q4 的 `patchFlags` 处置;同时说明审计「未确定项 1(仓外 `resetHero` 是否重发)」在本次修复后不再影响正确性(选项 A)或仍依赖该事件(选项 B,须明写)。`WINDOWS.md` 不新建、不 `fixed`/`waive`。
(4) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步;这是 C–H + CR-01/CR-02 全部收口后的**最后一个**计划,SUMMARY 应给出本轮 07-10..07-14 的完整清单与逐条闭合状态,便于 `07-VERIFICATION.md` 重跑核对。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`;或新增测试文件未被纳入运行;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- `hero.ts`/`hero.test.ts` 的 `eslint` 0 错误、`vue-tsc --noEmit` 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条
- 基线变化(文件数 66→67 与通过数)已在 SUMMARY 说明
- SUMMARY 完成 `#06-17-7` 闭合登记、Q4 处置、"未确定项 1" 结论,以及 07-10..07-14 的逐条闭合清单与 Phase 7 验证失效说明
- `WINDOWS.md` 未被本计划改动
</acceptance_criteria>
<done>D-44 三步门禁全绿、提交完成、`#06-17-7` 闭合登记与 Phase 7 验证失效说明写入 SUMMARY,本轮 5 个计划有完整清单</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 仓外旧引擎 → 数据端 | `core.status.hero` 是 legacy 侧唯一入口,其代理的后端引用必须始终有效 |
| 数据端 → 全局对象 | `patchHero` 写入全局 `core.status.hero` 与 `window.flags`,装配时机由 `resetHero` 决定 |
| 测试环境 → 生产代码路径 | 新增测试需 stub `main`/`core`/`window`,stub 不当会掩盖真实装配差异 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-43 | Tampering | `patchHero` 的 `attr` 闭包(`hero.ts:8`) | medium | mitigate | Task 1 按 Q1 裁决:每次访问解析活属性(或按裁决重绑),并有「切换实例后读写落到新实例」的断言 |
| T-7-44 | Repudiation | legacy 读到的数值口径(基础值 vs 最终值) | low | mitigate | Task 0 Q2 裁决 + Task 1 断言;`patchHero` jsDoc 写明口径 |
| T-7-45 | Tampering | flags 代理(`hero.ts:26-37`)与 `window.flags` 绑定 | low | mitigate | Task 1 断言 flags 代理拒绝读写且发码 54、`window.flags` 指向同一代理(既有语义不回归) |
| T-7-46 | Information Disclosure | 告警 56 携带的字段名 | low | accept | 码 56 的既有文案只携带 `'core.status.hero'` 与 `'state.hero'` 两个常量标签,不输出用户数据 |
| T-7-47 | Denial of Service | 测试 stub 泄漏到其它测试文件 | low | mitigate | Task 1 使用 `vi.hoisted` + `afterAll(() => vi.unstubAllGlobals())`(与既有测试同款),`hook` 的单例订阅需注意跨文件隔离(在 SUMMARY 记录) |
| T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 |
</threat_model>
<verification>
1. `pnpm exec vitest run packages-user/data-fallback/src/hero.test.ts` 全绿。
2. `pnpm test:ci` 全绿:67 文件(+1 为本计划新增测试)/ 通过数与执行前基线一致地增加 / 1 跳过(仅 `equipment.test.ts:306` 码 147)。
3. `pnpm exec eslint packages-user/data-fallback/src/hero.ts packages-user/data-fallback/src/hero.test.ts` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这两个文件的类型错误。
4. Task 0 的 `<record>` 中 Q1..Q4 均有裁决,代码与裁决逐条一致(SUMMARY 对照表)。
5. 仅 `hero.ts` 与新增 `hero.test.ts` 被改动;`flag.ts`/`index.ts`/`package.json`/数据侧包零改动。
6. Phase 7 验证失效与重跑要求已在 `07-14-SUMMARY.md` 明写,`ROADMAP.md` 已更新,且 SUMMARY 含 07-10..07-14 的完整闭合清单。
</verification>
<success_criteria>
- `#06-17-7`(审计 C)按 Q1 裁决闭合:`core.status.hero` 代理的属性读写始终落在活属性上(或按裁决明写契约),并有回归断言。
- `get` 语义与 Q2 裁决一致;告警 54/56 与 `window.flags` 绑定等既有语义零回归。
- `packages-user/data-fallback` 拥有首个测试文件,覆盖装配路径与活属性跟随。
- `pnpm test:ci` 全绿、无新增 `it.skip`、其它包断言未改;D-44 三步门禁全绿;基线变化已说明。
- Phase 7 验证失效与重跑要求已记录,且 SUMMARY 给出 07-10..07-14 的完整闭合清单。
</success_criteria>
<output>
Create `.planning/phases/07-data-fixes/07-14-SUMMARY.md` when done
</output>