docs(07): create phase plan

This commit is contained in:
unanmed 2026-09-15 15:55:24 +08:00
parent 46248dc7f8
commit 5a186d4a24
10 changed files with 3214 additions and 2 deletions

View File

@ -266,7 +266,51 @@ Plans:
3. pnpm test:ci 全绿且不新增跳过用例,数据范围 check:type / check:circular 门禁通过
4. 改动仅限数据端(packages 与 packages-user/data-*),不改动渲染端 @user/client-* 与 legacy 渲染接线,双端分离约束保持
**Plans**: TBD
**Plans**: 8 plans(按 D-02 一系统一计划;每个计划以 D-09 预执行汇报关卡开头,`autonomous: false`)
Plans:
- [ ] 07-01-PLAN.md — combat:`#06-01-1` / `#06-01-2` / `#06-01-3` / `#06-01-4`(含 `#06-15-1`)
- [ ] 07-02-PLAN.md — enemy:`#06-03-1` 创建入口接入复用映射
- [ ] 07-03-PLAN.md — replay:`#06-04-1` / `#06-04-2` / `#06-04-3` / `#06-04-4`
- [ ] 07-04-PLAN.md — hero:`#06-09-1`(高)/ `#06-09-2` / `#06-05-1` / `#06-05-2` / `#06-05-3`(D-06 保留)
- [ ] 07-05-PLAN.md — map:`#06-06-1`(D-04 改发 128)/ `#06-09-3`
- [ ] 07-06-PLAN.md — flag+common:`#06-08-1` 后退基准修正
- [ ] 07-07-PLAN.md — save:`#06-09-5`(D-05 差集方向取反 + 既有用例纠偏)
- [ ] 07-08-PLAN.md — path:`#06-07-1`(D-07 用户接线后取消 skip 验证)
**Wave 1**
- [ ] 07-01-PLAN.md — combat 四条根因(含既有 3 条绿用例纠偏)
**Wave 2** *(blocked on Wave 1)*
- [ ] 07-02-PLAN.md — enemy 复用映射
**Wave 3** *(blocked on Wave 2)*
- [ ] 07-03-PLAN.md — replay 编解码与索引编辑
**Wave 4** *(blocked on Wave 3)*
- [ ] 07-04-PLAN.md — hero 存读档与属性/槽位
**Wave 5** *(blocked on Wave 4)*
- [ ] 07-05-PLAN.md — map 诊断码与动态图块读档
**Wave 6** *(blocked on Wave 5)*
- [ ] 07-06-PLAN.md — flag+common 后退基准与契约注释
**Wave 7** *(blocked on Wave 6)*
- [ ] 07-07-PLAN.md — save 码 178 语义与既有用例纠偏
**Wave 8** *(blocked on Wave 7 + 用户完成 D-07 接线)*
- [ ] 07-08-PLAN.md — path 顶层录像瞬移验证(用户负责接线,AI 仅取消 skip)
## Progress
@ -281,4 +325,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. 单元测试 | 15/15 | In Progress| |
| 7. 数据端缺陷修复 | 0/TBD | Not started | - |
| 7. 数据端缺陷修复 | 0/8 | Not started | - |

View File

@ -0,0 +1,361 @@
---
phase: 07-data-fixes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- packages-user/data-system/src/combat/damage.ts
- packages-user/data-system/src/combat/mapDamage.ts
- packages-user/data-system/src/combat/combat.ts
- packages-user/data-system/src/combat/context.ts
- packages-user/data-system/src/combat/damage.test.ts
- packages-user/data-system/src/combat/mapDamage.test.ts
- packages-user/data-system/src/combat/combat.test.ts
- packages-user/data-system/src/combat/context.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 70000
raw_tokens: 70000
tasks: 6
confidence: low
must_haves:
truths:
- "取消 skip 后 combat/damage.test.ts 的 critical info 对齐用例与重复 buildup 用例转绿"
- "取消 skip 后 combat/context.test.ts 的 addAura→deleteAura 回退用例转绿"
- "取消 skip 后 combat/mapDamage.test.ts 的 deleteEnemy 来源伤害清理用例转绿"
- "取消 skip 后 combat/combat.test.ts 的 before 返回 false 放弃战斗用例转绿,且 3 条既有完整流程用例纠偏后仍绿"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-system/src/combat/damage.ts
provides: "findNextCritical 的 targetInfo 与 yield 的 nextValue 同源"
- path: packages-user/data-system/src/combat/context.ts
provides: "buildup 全量构建前置重置全部 EnemyView"
- path: packages-user/data-system/src/combat/mapDamage.ts
provides: "有来源地图伤害的双向索引在写入/删除/重算三处自洽"
- path: packages-user/data-system/src/combat/combat.ts
provides: "战前脚本返回 false 才停止后续脚本并放弃战斗,与 types.ts jsdoc 一致"
key_links:
- "combat.ts:179 的返回值判定 ↔ combat/types.ts:772 的 jsdoc(D-03 指定文档为契约事实源)"
- "context.ts buildup() ↔ combat/enemy.ts:15 EnemyView.reset()(复用既有重置 API,不新造)"
- "mapDamage.ts 写入 viewStore/damageStore ↔ deleteEnemy/removeEnemyAffecting/refreshIndex 的读取与删除"
- "damage.ts findNextCritical 的 targetInfo ↔ calculateCritical 的 info 消费点(damage.ts:182-189)"
assumptions:
- "FIX-01 在无 SPEC 的探针下为 unclassified/unresolved:本计划以显式假设承接其可判定部分,即 FIX-01 = 「06-TEST-FINDINGS.md 登记条目的正确预期 skip 用例转绿、不新增跳过、不弱化断言」;若用户对 combat 四条中的任一条期望与之不同,在 D-09 预执行汇报时裁决,AI 不自行改判"
prohibitions:
- "不新增测试用例(D-10);只取消 it.skip 与 D-09 点名的既有断言纠偏"
- "不弱化断言,不把失败用例改写为可跑绿假象(06-15 已确立纪律)"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线(FIX-01 边界)"
- "不引入新依赖、不新建文件、不修改 package.json/pnpm-lock.yaml"
- "不修改本计划 files_modified 之外的源码;相邻未登记缺陷(含 array.ts set)只登记不修"
---
<objective>
修复 combat 系统(`@user/data-system` L2)的 4 条根因 / 5 条登记缺陷:`#06-01-1` 临界点 info 与 nextValue 不匹配、`#06-01-2` `deleteEnemy` 残留来源伤害、`#06-01-3` 战前脚本短路语义与文档相反、`#06-01-4` 重复 `buildup` 属性累加(同比 `#06-15-1`),使 Phase 6 建立的正确预期 `it.skip` 用例全部取消 skip 并转绿。
Purpose: 战斗是引擎「开局到结局」主链路的核心;这 4 条缺陷会让伤害预览、地图伤害残留、战前脚本放弃语义与光环回退在真实玩法中产生错误数值。
Output: 4 个生产文件的最小修复 + 4 个测试文件的 skip 取消与 3 条既有用例纠偏 + 按缺陷的原子提交 + WINDOWS.md 19/20/21/27 结清。
执行序:本计划在 Phase 7 中第一个执行。D-09 要求每个计划开始前暂停汇报修复方案并获用户确认(本计划 Task 0),因此不得与其他 Phase 7 计划并发执行(`depends_on` 为空仅表示源码层面无前置)。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/WINDOWS.md
@dev.md
@packages-user/data-system/src/combat/types.ts
@packages-user/data-system/src/combat/enemy.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 combat 四条修复方案并获用户确认</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-system/src/combat/damage.ts(:206-242 findNextCritical,重点 :229-234)
- packages-user/data-system/src/combat/context.ts(:684-716 buildup,重点 :690-695)
- packages-user/data-system/src/combat/mapDamage.ts(:150-167 deleteEnemy、:261-299、:304-333、:356-378)
- packages-user/data-system/src/combat/combat.ts(:178-181)
- packages-user/data-system/src/combat/types.ts(:771-779 before 契约)
- packages-user/data-system/src/combat/combat.test.ts(:114-129 FakeScript、:289-312、:426-458、:460-481、:483-501)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding Analysis combat 四小节、§Open Questions D、§Assumptions Log A1/A3)
</read_first>
<action>
向用户逐条汇报以下四项修复方案,等待明确确认后才进入 Task 1;未获确认不得执行任何源码修改。汇报必须包含每条的 file:line 根因、最小补丁、必须同步变动的既有绿用例清单与回滚点(本计划起始 git 提交)。
① `#06-01-1`:`damage.ts:229-234` 把 `targetInfo = middleInfo` 从 `else`(非临界点)分支移入 `damage < referenceDamage` 分支,`else` 只保留 `left = middle`。
② `#06-01-4` + `#06-15-1`(同根因,D-01 合并):在 `context.ts` 的 `buildup()` 清空拓扑之后(`:695` 之后)、各效果阶段之前,无条件遍历 `this.enemyViewMap.values()` 调用既有 `view.reset()`;不新造重置逻辑。
③ `#06-01-2`(**必须二选一请用户裁决**,对应 RESEARCH §Open Questions D 与 Assumptions A1):
- 方案 A(研究推荐,完整设计):在 `refreshEnemy`(`:326-330`)与 `refreshEnemyAndClearCache`(`:287-292`)的 `if (damage)` 分支内登记 `this.damageStore.set(damage, { sourceView: viewItem, sourceEnemy: view, index })` 与 `this.viewStore.getOrInsertComputed(viewItem, ...)` 的 `viewStore.damages.set(index, damage)`,并在 `refreshIndex`(`:372-377`)重算 `point.damages` 后重新登记;`deleteEnemy`/`removeEnemyAffecting`/`refreshIndex` 既有读取路径不改即变正确。代价:触及 3 个方法(可提取私有登记助手),消除 `viewStore`/`damageStore` 死结构。
- 方案 B(最小自足):不动 store,改写 `deleteEnemy`,用 `viewItem.getRange()`/`getRangeParam()` 枚举受影响点位,删 `affectedBy` 后按剩余来源就地重算该点 `damages`;`removeEnemyAffecting` 可复用同款逻辑。代价:`viewStore`/`damageStore`/`IViewStore`/`IDamageStore` 成为无写入者的死结构(D-06 只覆盖错误码语义,私有字段处置需用户明确)。
④ `#06-01-3`:`combat.ts:178-181` 改为 `const proceed = await script.before(...)` 且 `if (!proceed) return damage;`(对齐 `types.ts:772` jsdoc)。**必须同时纠偏 3 条既有绿用例**(研究新发现 1,D-10 明确允许,须点名):`combat.test.ts:289-312` 与 `:426-458` 显式传 `beforeResult = true`(`FakeScript` 默认 false,修后会在第一个 before 处放弃);`:460-481` 改为互补分支断言并更名为 `runs hooks and after scripts when before returns truthy`;`:114` 的 `beforeResult` 注释同步为「假值表示放弃战斗」(D-11)。`:399-424` 不注册脚本、`:369-396` 的 false 短路由断言 `info` 不变,均不受影响。
同时向用户说明回滚与失败纪律:每个缺陷一个原子提交(D-13);任一任务方案失败时**必须退出本次修改、修订本 PLAN.md 后重新执行**,禁止自行另辟他法(D-09)。
</action>
<decision>是否按本计划的 combat 四条修复方案执行,以及 `#06-01-2` 采用方案 A(补齐 store 写入)还是方案 B(只让 deleteEnemy 自足)</decision>
<context>
combat 是「开局到结局」主链路核心。`#06-01-3` 的修复会连带打红 3 条既有绿用例,必须由用户确认该断言纠偏属 D-10 允许范围;`#06-01-2` 的根因不止登记所述(viewStore/damageStore 全文件无写入者),两条路线互斥且都须用户裁决。D-09 要求方案先确认;确认后若修复失败,执行者必须退出并修订计划,不得另辟他法。
</context>
<options>
<option id="approve-route-a">
<name>确认四条方案 + `#06-01-2` 方案 A(补齐 store 写入)</name>
<pros>完整消除 viewStore/damageStore 死结构;deleteEnemy/removeEnemyAffecting/refreshIndex 的既有读取路径不改即变正确;顺带修掉局部刷新的陈旧伤害累积</pros>
<cons>触及 3 个方法(含一个私有登记助手),变更面大于方案 B;refreshIndex 的登记逻辑无既有测试直接断言</cons>
</option>
<option id="approve-route-b">
<name>确认四条方案 + `#06-01-2` 方案 B(只让 deleteEnemy 自足)</name>
<pros>改动面最小、skip 即转绿;removeEnemyAffecting 可复用同款点位重算逻辑</pros>
<cons>viewStore/damageStore/IViewStore/IDamageStore 成为无写入者的死结构,需用户明确私有字段处置</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行,增加一轮往返</cons>
</option>
</options>
<resume-signal>回复 approve-route-a / approve-route-b,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 approve-route-a 或 approve-route-b</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户回复中明确包含 `#06-01-2` 的选定路线(A 或 B)
- 用户确认 `#06-01-3` 的 3 条既有用例纠偏属 D-10 允许范围
</acceptance_criteria>
<done>用户在对话中给出 approve-route-a 或 approve-route-b;本计划的后续任务以该选定路线为准</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-01-1 —— findNextCritical 的 targetInfo 移入临界分支</name>
<files>packages-user/data-system/src/combat/damage.ts, packages-user/data-system/src/combat/damage.test.ts</files>
<read_first>
- packages-user/data-system/src/combat/damage.ts(:206-242 findNextCritical,消费点 :160-193 calculateCritical)
- packages-user/data-system/src/combat/damage.test.ts(:579-594 目标 skip 用例、:508-576 同文件既有绿用例)
</read_first>
<action>
按 D-03 类契约「实现对齐文档」的最小补丁修正二分搜索的 info 归属:在 `damage.ts` 的 `findNextCritical`(:206-242)中,把 `targetInfo = middleInfo` 从 `else` 分支移入 `middleInfo.damage < referenceDamage` 分支(该分支同时设置 `right = middle`),`else` 分支只保留 `left = middle`。`:220-221` 的初始 `targetInfo`(`upperLimit` 处)与 `:238-241` 返回的 `value: right` 保持不变——当循环从未进入 `<` 分支时两者天然一致。
按 D-10 只做一类测试改动:把 `damage.test.ts:579-594` 的 `it.skip` 改为 `it`,断言一字不改;同时更新该用例前的中文单行注释,使其描述修复后的行为(dev.md 要求每个 it 前有说明当前覆盖内容的注释,且范围变化时同步更新)。
不做的事:不改 `maximumIterations`/`upperLimit`/早退条件;不改任何相邻方法;不新增用例。
验证顺序(全部通过后才提交):聚焦用例 → 同文件全量 → D-44 门禁三步。提交信息:`fix(07-01): #06-01-1 align critical info with the yielded next value`(D-13 原子提交,仅含 damage.ts 与本任务测试改动)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/damage.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed",或输出出现 "no tests found"(skip 未取消或文件未收集)</fails_when>
</verify>
<acceptance_criteria>
- `damage.test.ts` 中 `reports the damage info matching the yielded critical value` 不再以 `it.skip` 存在(源码断言:该行不含 `.skip`)
- `damage.ts` 的 `findNextCritical` 内 `targetInfo = middleInfo` 位于 `damage < referenceDamage` 分支
- `pnpm exec vitest run packages-user/data-system/src/combat/damage.test.ts` 全绿
</acceptance_criteria>
<done>damage.test.ts 全绿且目标用例已取消 skip;damage.ts 与 damage.test.ts 已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: 修复 #06-01-4 与 #06-15-1 同根因 —— buildup 前置重置全部视图</name>
<files>packages-user/data-system/src/combat/context.ts, packages-user/data-system/src/combat/damage.test.ts, packages-user/data-system/src/combat/context.test.ts</files>
<read_first>
- packages-user/data-system/src/combat/context.ts(:684-716 buildup、:780-787 refreshEnemy 的既有 view.reset() 范式)
- packages-user/data-system/src/combat/enemy.ts(:15-17 EnemyView.reset)
- packages-user/data-system/src/combat/damage.test.ts(:677-718 目标 skip、:612-676 多重 buildup 既有绿用例)
- packages-user/data-system/src/combat/context.test.ts(:625-663 目标 skip、:593-716 与 1026-1486 的既有绿用例群)
</read_first>
<action>
在 `context.ts` 的 `buildup()`(:684-716)中、`:690-695` 的拓扑清空之后与 `hasAura`/`hasSpecialQuery` 分支之前,插入一个**无条件**循环:对 `this.enemyViewMap.values()` 的每个视图调用既有 `view.reset()`。必须无条件执行——`buildupQuery`/`buildupFinal` 也会在上一轮构建的数值上叠加,挂到 `hasAura || hasSpecialQuery` 之下会漏修。复用既有 `EnemyView.reset()`(内部 `computedEnemy.copyFrom(baseEnemy)`),不新造重置逻辑、不新增字段。
按 D-11 在插入处写一条有价值的中文注释,说明「全量重建前必须先把各视图退回原始怪物,否则重复构建会累加」。
按 D-10 取消两条 skip:`damage.test.ts:677-718`(`recomputes a repeat buildup from the base enemy without compounding`)与 `context.test.ts:625-663`(`applies a global aura after addAura and stops applying it after deleteAura`),断言均一字不改,并同步两处 it 前的中文注释。
不做的事:不改 `refreshEnemy`(已是正确范式);不改 `clear()`/`resize()`;不新增用例。
验证顺序:`pnpm exec vitest run packages-user/data-system/src/combat/context.test.ts` 与 `pnpm exec vitest run packages-user/data-system/src/combat/damage.test.ts packages-user/data-system/src/combat/mapDamage.test.ts packages-user/data-system/src/combat/combat.test.ts` 均需全绿(后者覆盖 damage.test.ts 的既有绿用例群与 #06-01-4 用例)→ D-44 门禁三步。提交信息:`fix(07-01): #06-01-4 reset enemy views before a full buildup`(同时解除 `#06-15-1`)。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/context.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(含告警 97/98/99/100/101 用例回归)</fails_when>
</verify>
<acceptance_criteria>
- `context.ts` 的 `buildup()` 在 `this.requestedCommonContext.clear();` 之后包含对 `this.enemyViewMap.values()` 的 `view.reset()` 循环,且该循环不在任何 `if` 之下
- `damage.test.ts` 的 `recomputes a repeat buildup from the base enemy without compounding` 与 `context.test.ts` 的 `applies a global aura after addAura and stops applying it after deleteAura` 均已取消 skip(源码断言:两处 `it(` 不含 `.skip`)
- `pnpm exec vitest run packages-user/data-system/src/combat/context.test.ts` 全绿
- `pnpm exec vitest run packages-user/data-system/src/combat/damage.test.ts packages-user/data-system/src/combat/mapDamage.test.ts packages-user/data-system/src/combat/combat.test.ts` 全绿
</acceptance_criteria>
<done>两条 skip 取消并转绿,context.test.ts 与 combat 集成命令全绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 3: 修复 #06-01-2 —— 按用户选定路线清理有来源地图伤害</name>
<files>packages-user/data-system/src/combat/mapDamage.ts, packages-user/data-system/src/combat/mapDamage.test.ts</files>
<read_first>
- packages-user/data-system/src/combat/mapDamage.ts(全文 379 行;:22-36 IViewStore/IDamageStore、:150-167 deleteEnemy、:240-256 removeEnemyAffecting、:261-333 两条刷新路径、:356-378 refreshIndex)
- packages-user/data-system/src/combat/context.ts(:256-299 setEnemyAt/deleteEnemyAt 的「写入即登记、删除即反向清理」范式)
- packages-user/data-system/src/combat/mapDamage.test.ts(:532-545 目标 skip、:410-590 既有绿用例群)
</read_first>
<action>
按 Task 0 用户选定的路线实施(**只实施选定路线,不得混用,不得在失败后自行改走另一路线**)。
路线 A(补齐 store 写入):在 `refreshEnemy`(:326-330 的 `if (damage)` 内)与 `refreshEnemyAndClearCache`(:287-292 的 `if (damage)` 内)同步登记两类反向索引——`this.damageStore.set(damage, { sourceView: viewItem, sourceEnemy: view, index })` 与 `this.viewStore.getOrInsertComputed(viewItem, () => ({ damages: new Map(), enemy: view }))` 后 `viewStore.damages.set(index, damage)`;并在 `refreshIndex`(:372-377)重建 `point.damages` 之后重新登记同样的两项(否则下一次 `deleteEnemy` 仍会漏)。两处重复的登记代码提取为一个私有方法(D-09 授权的必要内部重构),按 dev.md 把私有方法放在调用它的方法之前并置于合理 `#region`;插入处写一条有价值的中文注释说明「登记来源以便反向清理」。`deleteEnemy`/`removeEnemyAffecting`/`refreshIndex` 的既有读取与删除路径不改。
路线 B(最小自足):不动 `viewStore`/`damageStore`,改写 `deleteEnemy`(:150-167)——保留 `const store = this.enemyStore.get(view); if (!store) return;` 早退,改为遍历 `store` 的每个 `viewItem`,用其 `getRange()`/`getRangeParam()`(先 `range.bindHost(this.context)`)经 `range.iterateLoc(param)` 枚举受影响点位,`point.affectedBy.delete(viewItem)` 命中后就地 `point.damages.clear()` 并由剩余 `affectedBy` 用 `getDamageWithoutCheck(locator)` 重建 `point.damages`,最后 `collection.add(index)` 走既有 `markDirtyIndex`;`removeEnemyAffecting` 复用同款点位重算逻辑。`:152` 的 `if (!store) return;` 必须保留。
按 D-10 取消 `mapDamage.test.ts:532-545` 的 skip(`removes enemy-sourced damage when the enemy is deleted`),断言一字不改,同步 it 前的中文注释。
不做的事:不改 `addMapDamage`/`deleteMapDamage`/`getReducedDamage` 的公开语义;不改 `markEnemyDirty`/`refreshAll` 的控制流;不新增用例。
验证顺序:`pnpm exec vitest run packages-user/data-system/src/combat/damage.test.ts packages-user/data-system/src/combat/mapDamage.test.ts packages-user/data-system/src/combat/combat.test.ts` 全绿(覆盖 `:410-590` 既有绿用例群,尤其 `:517-529` 的未登记怪物早退与 `:502-514` 的 dirty 刷新)→ D-44 门禁三步。提交信息:`fix(07-01): #06-01-2 clear enemy-sourced map damage on delete`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(既有 gated 用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `mapDamage.test.ts` 的 `removes enemy-sourced damage when the enemy is deleted` 已取消 skip(源码断言:该 `it(` 不含 `.skip`)
- 选中路线 A 时:`mapDamage.ts` 内 `viewStore.set(` 或 `getOrInsertComputed(` 后存在 `viewStore.damages.set(`,且 `refreshIndex` 内含同样的登记调用
- 选中路线 B 时:`deleteEnemy` 内出现 `getRange()` 与按剩余 `affectedBy` 重建 `point.damages` 的实现,且 `if (!store) return;` 仍在
- `pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts` 全绿
</acceptance_criteria>
<done>选定路线落地、目标用例取消 skip 并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 4: 修复 #06-01-3 —— before 返回 false 才放弃战斗,并纠偏 3 条既有用例</name>
<files>packages-user/data-system/src/combat/combat.ts, packages-user/data-system/src/combat/combat.test.ts</files>
<read_first>
- packages-user/data-system/src/combat/combat.ts(:164-194 combatFlow)
- packages-user/data-system/src/combat/types.ts(:767-779 before 契约逐字)
- packages-user/data-system/src/combat/combat.test.ts(:105-140 FakeScript、:289-312、:369-396、:399-421、:426-458、:460-481、:483-501)
</read_first>
<action>
按 D-03 让实现对齐 `types.ts:772` 的 jsdoc:把 `combat.ts:178-181` 循环改为读取返回值到语义自解释的变量(`proceed`),判定改为 `if (!proceed) return damage;`——只有 `false` 才停止后续战前脚本并放弃战斗。循环之后钩子与 after 脚本的既有顺序不变。
按 D-10 的断言纠偏范围(Task 0 已点名确认),在 `combat.test.ts` 内做三类改动:
① `:114` 的 `FakeScript.beforeResult` 注释从「真值表示短路」改为「假值表示放弃战斗」(D-11,实现与注释同步);
② `:289-312`(优先级/重复脚本顺序)与 `:426-458`(await 顺序)显式传 `beforeResult = true`——`FakeScript` 构造器默认 `false`,修后默认值会在第一个 `before` 处放弃战斗,导致这两条既有完整流程断言变红;只改传参,断言内容不改;
③ `:460-481` 改为互补分支断言:用例更名为 `runs hooks and after scripts when before returns truthy`,断言改为完整四步调用顺序(`script.before` → `hooks.onBeforeCombat` → `script.after` → `hooks.onAfterCombat`)且 `info` 仍为 `fixture.info`;这是对固化错误语义的既有断言的纠正,不新增用例。
按 D-10 取消 `:483-501` 的 skip(`abandons the battle when the before script returns false`),断言一字不改,`false` 必须显式传入,同步 it 前的中文注释。
不做的事:不改 `:369-396`(内联 `return false` 且断言 `info` 不变)、不改 `:399-421`(未注册脚本);不改告警 139/140/141 分支。
验证顺序:`pnpm exec vitest run packages-user/data-system/src/combat/combat.test.ts` 全绿(含 3 条纠偏后的既有用例)→ D-44 门禁三步。提交信息:`fix(07-01): #06-01-3 abandon the battle only when before returns false`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/combat.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(既有用例未纠偏或纠偏后仍红),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `combat.ts:178-181` 的判定为 `if (!proceed) return damage;`,且不再存在以真值短路的写法
- `combat.test.ts` 的 `abandons the battle when the before script returns false` 已取消 skip
- `combat.test.ts` 的 `runs hooks and after scripts when before returns truthy` 用例存在且断言完整四步顺序
- `combat.test.ts` 全绿(`:289`、`:426`、`:460` 三条纠偏后不红)
</acceptance_criteria>
<done>契约实现与 `types.ts:772` 一致、目标用例取消 skip、3 条既有用例纠偏后全绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 5: D-44 门禁、全量套件与 WINDOWS.md 19/20/21/27 结清</name>
<files>packages-user/data-system/src/combat/damage.ts, packages-user/data-system/src/combat/mapDamage.ts, packages-user/data-system/src/combat/combat.ts, packages-user/data-system/src/combat/context.ts, packages-user/data-system/src/combat/damage.test.ts, packages-user/data-system/src/combat/mapDamage.test.ts, packages-user/data-system/src/combat/combat.test.ts, packages-user/data-system/src/combat/context.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Validation Architecture 的「既有绿用例不回归」表、§Common Pitfalls 1/4/5/6/7)
- .planning/WINDOWS.md(id 19/20/21/27 行)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§WINDOWS.md 收口映射的「需澄清」三点)
</read_first>
<action>
对本计划全部 8 个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:
① `pnpm exec eslint --fix <8 个文件>`,随后 `pnpm exec eslint <8 个文件>` 必须 0 错误(CRLF/Prettier 由 `--fix` 处理,不手工改换行);
② `pnpm exec vue-tsc --noEmit` 后按本计划 8 个文件的相对路径过滤输出,必须 0 类型错误——**不得**以整仓退出码非 0 判定失败(既有渲染/legacy 诊断不属本阶段);
③ `pnpm test:ci` 全绿。
随后按 D-14 结清账本:对本计划关闭的 4 条 `skipped-test` 条目执行 `node "%CLAUDE_CONFIG_DIR%/gsd-core/bin/gsd-tools.cjs" windows fixed 19`、`windows fixed 20`、`windows fixed 21`、`windows fixed 27`(或执行环境中的等价 `gsd_run windows fixed <id>`)。**不要为 `#06-01-4` 新建账本条目**——研究已核对 19–27 的 9 条与本阶段登记的映射关系,`#06-01-4` 无对应条目;如用户要求,先在汇报中确认再动(新增会抬高 `open_count` 门禁)。
最后在 SUMMARY 中记录:本计划实际关闭的 windows id、跳过的 `pnpm test:ci` 计数变化(修复前基线 66 文件 / 649 passed / 28 skipped),以及「若任一任务方案失败即退出并修订计划」的执行结果。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed";或 `windows status` 显示 19/20/21/27 仍为 open</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对 8 个改动文件 0 错误
- `pnpm exec vue-tsc --noEmit` 输出按 8 个改动文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增任何 `it.skip`
- WINDOWS.md 中 id 19、20、21、27 状态为 `fixed`(`gsd_run windows status` 可验证)
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、4 条 windows 条目结清并在 SUMMARY 记录</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 存档/运行时输入 → combat 索引 | 怪物视图与地图伤害信息经外部注册进入 `MapDamage` 内部索引,索引与伤害对象必须始终成对 |
| 契约文档 → 实现 | `types.ts` 的 jsdoc 是战前脚本语义的唯一事实源,实现偏离即契约破坏 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-01 | Tampering | `mapDamage.ts` `deleteEnemy`/`removeEnemyAffecting`/`refreshIndex` | medium | mitigate | Task 3 补齐双向索引或就地重算,使被删怪物的来源伤害不再残留(幽灵伤害会污染后续合并结果) |
| T-7-05 | Tampering | `context.ts` `buildup()` | medium | mitigate | Task 2 全量重建前无条件 `view.reset()`,消除重复构建的属性累加(会放大伤害数值) |
| T-7-06 | Repudiation | `combat.ts` 战前脚本语义 | low | mitigate | Task 4 以文档为契约事实源改实现,并同步 `FakeScript` 注释,使语义不可被两种解读 |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变),Package Legitimacy Gate 不触发;若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-system/src/combat/context.test.ts` 全绿
- `pnpm exec vitest run packages-user/data-system/src/combat/damage.test.ts packages-user/data-system/src/combat/mapDamage.test.ts packages-user/data-system/src/combat/combat.test.ts` 全绿
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 8 个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
- WINDOWS.md id 19/20/21/27 为 fixed
</verification>
<success_criteria>
1. `#06-01-1`、`#06-01-2`、`#06-01-3`、`#06-01-4`、`#06-15-1` 五条的正确预期用例全部取消 skip 并转绿
2. `combat.test.ts` 的 3 条既有用例经 D-09 点名纠偏后全绿,无一条因修复而失败
3. 4 个生产文件各有且仅有对应缺陷的最小改动,无越界重构
4. 每个缺陷一个原子提交(`#06-01-4` 与 `#06-15-1` 合并为一个提交)
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-system/src/combat/damage.ts` | `findNextCritical` 的 `targetInfo` 归属修正 |
| 生产源码(修改) | `packages-user/data-system/src/combat/context.ts` | `buildup()` 前置 `view.reset()` 循环 |
| 生产源码(修改) | `packages-user/data-system/src/combat/mapDamage.ts` | 有来源伤害双向索引自洽(A 路线)或 `deleteEnemy` 自足重算(B 路线) |
| 生产源码(修改) | `packages-user/data-system/src/combat/combat.ts` | `before` 返回 `false` 才放弃战斗 |
| 测试(修改) | `packages-user/data-system/src/combat/damage.test.ts` | 2 条 skip 取消 |
| 测试(修改) | `packages-user/data-system/src/combat/context.test.ts` | 1 条 skip 取消 |
| 测试(修改) | `packages-user/data-system/src/combat/mapDamage.test.ts` | 1 条 skip 取消 |
| 测试(修改) | `packages-user/data-system/src/combat/combat.test.ts` | 1 条 skip 取消 + 3 条既有用例纠偏 + `FakeScript` 注释同步 |
| 账本 | `.planning/WINDOWS.md` | id 19/20/21/27 → fixed |
| 摘要 | `.planning/phases/07-data-fixes/07-01-SUMMARY.md` | 逐条修复记录、选定路线、门禁结果、windows 对照 |
<output>
Create `.planning/phases/07-data-fixes/07-01-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,224 @@
---
phase: 07-data-fixes
plan: 02
type: execute
wave: 2
depends_on:
- 07-01
files_modified:
- packages-user/data-base/src/enemy/manager.ts
- packages-user/data-base/src/enemy/manager.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 22000
raw_tokens: 22000
tasks: 3
confidence: low
must_haves:
truths:
- "取消 skip 后 enemy/manager.test.ts 的两条复用映射用例转绿"
- "未注册复用映射的 code/id 仍返回模板克隆,行为与修复前一致"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-base/src/enemy/manager.ts
provides: "所有按 code/id 取模板的公开入口统一经 internalGetPrefab 解析复用映射"
key_links:
- "createEnemy/createEnemyById ↔ manager.ts:131 internalGetPrefab ↔ reuseByCode/reuseById(与 deletePrefab/modifyPrefabAttribute 既有用法一致)"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;若用户对 #06-03-1 的期望不同,在 D-09 预执行汇报时裁决"
prohibitions:
- "不新增测试用例(D-10);只取消 it.skip"
- "不弱化断言,不改写为可跑绿假象"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
- "不修改本计划 files_modified 之外的源码;`getPrefab` 的 IReadonlyEnemy 返回类型与 `canEquipTo` 类相邻逻辑不动"
---
<objective>
修复 enemy 数据模型(`@user/data-base` L1)的 `#06-03-1`:`EnemyManager.createEnemy`/`createEnemyById` 绕过复用映射(`reuseByCode`/`reuseById`)直接查 `prefabByCode`/`prefabById`,与 `getPrefab`/`deletePrefab`/`modifyPrefabAttribute` 的既有行为不一致,使同一模板复用出的多个朝向 code 生成不出独立怪物。
Purpose: 复用映射是编辑器「一个模板多朝向 code」的表达方式;创建入口绕开它会让运行时生成错误的怪物(拿不到模板或拿到错误模板)。
Output: `manager.ts:119-129` 两方法改用既有私有 `internalGetPrefab` + `manager.test.ts` 两条 skip 取消 + WINDOWS.md id 24 结清。
执行序:本计划在 07-01(combat)之后串行执行。`depends_on` 表示 D-09 逐步用户确认与 D-12c 全量门禁的串行约束,源码层面与本系统无耦合;不得与其它 Phase 7 计划并发执行。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/WINDOWS.md
@dev.md
@packages-user/data-base/src/enemy/enemy.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 #06-03-1 修复方案并获用户确认</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-base/src/enemy/manager.ts(:119-129 错误实现、:131-139 internalGetPrefab、:165-196 getPrefab/deletePrefab/modifyPrefabAttribute)
- packages-user/data-base/src/enemy/manager.test.ts(:272-279 与 :282-343 两条目标 skip)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding #06-03-1)
</read_first>
<action>
向用户逐条汇报 `#06-03-1` 的修复方案,等待明确确认后才进入 Task 1。
① 根因(file:line):`manager.ts:119-129` 的 `createEnemy` 直接 `this.prefabByCode.get(code)`、`createEnemyById` 直接 `this.prefabById.get(id)`,完全跳过 `reuseByCode`/`reuseById`;同文件 `internalGetPrefab`(:131-139)已同时处理两种键型与复用解析,且 `deletePrefab`(:176)与 `modifyPrefabAttribute`(:218)已在用它。
② 最小补丁(D-08 明确允许「改用 internalGetPrefab」):两个方法各自改为 `const prefab = this.internalGetPrefab(code | id); if (!prefab) return null; return prefab.clone();`。`internalGetPrefab` 返回 `IEnemy<TEnemy> | null`,可直接 `.clone()`(`getPrefab` 返回 `IReadonlyEnemy` 不可用),**不需要 `as`**(dev.md 禁连续 `as`);类成员提升使 `:131` 的声明在 `:119` 之后仍可用,无需调整顺序。
③ 既有用例影响:**无**。对未注册复用映射的 code/id,`internalGetPrefab` 退化为原查找,行为完全一致;调用方包括 `data-state/test/dataClosure.test.ts:104`、`enemyCombination.test.ts:214/230`、`manager.test.ts:194/203/205/219/277/307/332` 与 `enemy/saveLoad.test.ts:241-242`(`addPrefab` 后 `createEnemy(9)` 期望 null)。
④ 顺带事项(可选,须用户确认才做):在 `createEnemyById` 上方加一条有价值的中文注释,说明「所有按 code/id 取模板的公开入口必须经 internalGetPrefab」这一不变式(D-11)。
⑤ 回滚与失败纪律:本计划一个原子提交;若方案失败必须退出本次修改、修订本 PLAN.md 后重新执行,禁止自行另辟他法(D-09)。
</action>
<decision>是否按本计划把 createEnemy/createEnemyById 改用 internalGetPrefab,并是否加一条不变式注释</decision>
<context>
`#06-03-1` 是「公开入口绕过复用映射」的一致性缺陷;D-08 已明确授权改用 `internalGetPrefab`,故本例只剩「是否加注释」这一可选项,以及确认修复不影响未注册复用映射的既有行为。
</context>
<options>
<option id="approve">
<name>确认方案,含不变式注释</name>
<pros>把「所有按 code/id 取模板的公开入口必须经 internalGetPrefab」写入源码,避免后续再引入不一致</pros>
<cons>略增注释量,需确保注释有信息量而非解释下一行</cons>
</option>
<option id="approve-no-comment">
<name>确认方案,但不加注释</name>
<pros>改动面严格最小</pros>
<cons>不变式只存在于计划文本,后续易被再次绕过</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 approve / approve-no-comment,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 approve 或 approve-no-comment</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户明确确认 `createEnemy`/`createEnemyById` 改用 `internalGetPrefab`
- 用户就「是否加入不变式注释」给出明确答复
</acceptance_criteria>
<done>用户确认方案;本计划后续任务按该确认执行</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-03-1 —— 创建入口接入复用映射并取消两条 skip</name>
<files>packages-user/data-base/src/enemy/manager.ts, packages-user/data-base/src/enemy/manager.test.ts</files>
<read_first>
- packages-user/data-base/src/enemy/manager.ts(:110-160 上下文、:119-129、:131-139、:165-196)
- packages-user/data-base/src/enemy/manager.test.ts(:272-279、:282-343 两条 skip、:185-235 与 :300-345 既有绿用例)
</read_first>
<action>
在 `manager.ts` 中把 `createEnemy(code: number)`(:119-123)与 `createEnemyById(id: string)`(:125-129)的模板查找改为调用既有私有方法 `this.internalGetPrefab(code)` / `this.internalGetPrefab(id)`,保留 `if (!prefab) return null;` 与 `return prefab.clone();` 两行不变。不新增类型断言、不改变返回值类型、不调整方法顺序。
若 Task 0 用户选择加入注释,则在两方法上方写一条有价值的中文注释说明该不变式(不得是「调用 internalGetPrefab」这类解释下一行的无价值注释)。
按 D-10 取消 `manager.test.ts:272-279`(`creates enemies for reused codes and ids through the reuse mapping`)与 `:282-343`(`creates four independent enemies from one prefab reused by four facing codes`)两处 `it.skip` 的 skip,断言一字不改,并同步两处 it 前的中文注释使其描述修复后行为。
不做的事:不改 `internalGetPrefab`/`getPrefab`/`getPrefabById` 的实现与返回类型;不改 `addPrefab`/`addPrefabFromLegacy`/`deletePrefab`/`modifyPrefabAttribute`;不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/enemy/manager.test.ts` 全绿(覆盖 `:185-235` 与 `:300-345` 的既有绿用例群)→ D-44 门禁三步。提交信息:`fix(07-02): #06-03-1 resolve the reuse mapping in enemy creation`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/enemy/manager.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(复用后仍非独立怪物),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `manager.ts` 的 `createEnemy` 与 `createEnemyById` 体内均出现 `this.internalGetPrefab(`,且不再出现 `this.prefabByCode.get(` / `this.prefabById.get(`
- `manager.test.ts` 的 `creates enemies for reused codes and ids through the reuse mapping` 与 `creates four independent enemies from one prefab reused by four facing codes` 均已取消 skip(源码断言:两处 `it(` 不含 `.skip`)
- `pnpm exec vitest run packages-user/data-base/src/enemy/manager.test.ts` 全绿
</acceptance_criteria>
<done>两方法接入复用映射、两条 skip 取消并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: D-44 门禁、全量套件与 WINDOWS.md id 24 结清</name>
<files>packages-user/data-base/src/enemy/manager.ts, packages-user/data-base/src/enemy/manager.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Validation Architecture、§Common Pitfalls 4/5/6/7)
- .planning/WINDOWS.md(id 24 行)
</read_first>
<action>
对两个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按两文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
按 D-14 结清账本:`gsd_run windows fixed 24`(id 24 描述「受阻塞缺口 G-06-03-A:复用映射未接入 createEnemy/createEnemyById」,其两条 skip 已在本计划转绿)。
在 SUMMARY 中记录:实际关闭的 windows id、`pnpm test:ci` 计数变化、以及「方案失败即退出并修订计划」的执行结果。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed";或 `windows status` 显示 id 24 仍为 open</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对两文件 0 错误
- `pnpm exec vue-tsc --noEmit` 按两文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`
- WINDOWS.md 中 id 24 状态为 `fixed`
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、id 24 结清并在 SUMMARY 记录(提交粒度按 D-13 一个缺陷一个原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 复用映射注册表 → 怪物创建 | 外部注册的 code/id 别名经复用映射解析到模板,解析被绕过会产出错误或不存在的怪物 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-07 | Tampering | `manager.ts` `createEnemy`/`createEnemyById` | low | mitigate | Task 1 统一经 `internalGetPrefab` 解析,消除「公开入口绕过复用映射」的不一致入口 |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-base/src/enemy/manager.test.ts` 全绿
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 两个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
- WINDOWS.md id 24 为 fixed
</verification>
<success_criteria>
1. `#06-03-1` 两条正确预期用例取消 skip 并转绿
2. `createEnemy`/`createEnemyById` 与 `getPrefab`/`deletePrefab`/`modifyPrefabAttribute` 使用同一复用解析入口
3. 未注册复用映射的 code/id 行为不变(既有绿用例全绿)
4. 单个原子提交
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-base/src/enemy/manager.ts` | 创建入口接入 `internalGetPrefab` |
| 测试(修改) | `packages-user/data-base/src/enemy/manager.test.ts` | 2 条 skip 取消 |
| 账本 | `.planning/WINDOWS.md` | id 24 → fixed |
| 摘要 | `.planning/phases/07-data-fixes/07-02-SUMMARY.md` | 修复记录、门禁结果、windows 对照 |
<output>
Create `.planning/phases/07-data-fixes/07-02-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,336 @@
---
phase: 07-data-fixes
plan: 03
type: execute
wave: 3
depends_on:
- 07-02
files_modified:
- packages-user/data-common/src/replay/array.ts
- packages-user/data-common/src/replay/array.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 48000
raw_tokens: 48000
tasks: 6
confidence: low
must_haves:
truths:
- "取消 skip 后 replay/array.test.ts 的 int64 往返用例转绿"
- "取消 skip 后多字节 bigint 往返用例与 bigint/int64 异质混排用例转绿"
- "取消 skip 后 delete 中间步与异质路线删除用例转绿"
- "取消 skip 后 insert 与异质路线插入用例转绿,且插入后 array.get(i) 读到正确参数"
- "delete(0)、insert 零参数、set、位宽切换与扩容等既有绿用例不回归"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-common/src/replay/array.ts
provides: "编解码精确往返(int64/bigint)与命令索引/参数字节偏移严格区分"
key_links:
- "array.ts:621 解码乘数 ↔ array.ts:369-370 编码侧的 2147483648"
- "array.ts:628 长度前缀读取 ↔ array.ts:378 以无符号写入的 arr.length"
- "indexArray[i] = 第 i 条命令的参数起始字节(array.ts:405 写入、:749-767 rebuildIndexArray 重建)↔ delete/insert 的位移与回退起点"
- "末条命令的终点哨兵 ↔ paramUsed(array.ts:63/406/456/495/766 维护)"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;若用户对 replay 四条的期望不同,在 D-09 预执行汇报时裁决"
- "A6:`#06-04-4` 同时修 `:424`(paramArray 方向)与 `:425`(indexArray 方向)属同一根因的完整修复;只修 `:424` 也能让两条 skip 转绿,但 array.get(i) 的插入语义仍错——该取舍须在 D-09 汇报中点名"
prohibitions:
- "不新增测试用例(D-10);只取消 it.skip 与 D-09 点名的既有断言纠偏"
- "不弱化断言,不改写为可跑绿假象"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
- "不修改本计划 files_modified 之外的源码;相邻未登记的同族缺口(ReplayArray.set 的末步 nextParam 与回退起点、insert(index === length) 的 paramStart、负 int64/负 bigint 不可逆)一律只登记不修"
---
<objective>
修复录像数组(`@user/data-common` L0)的 4 条编解码 / 索引编辑缺陷:`#06-04-1` int64 解码乘数写成 `2^31 - 1`、`#06-04-2` 多字节 bigint 编解码错误(编码循环体 + 有符号读取长度前缀与字节)、`#06-04-3` `delete` 以参数字节偏移当作命令索引回退起点且末步缺终点哨兵、`#06-04-4` `insert` 的两处 `copyWithin` 位移方向相反。
Purpose: 录像数组是回放一致性的唯一数据载体;编解码不可逆或索引错位会让回放在首个分歧处失败,且诊断码(151/152/154/155/2001–2008)无法反映真实原因。
Output: `array.ts` 四处最小修复(其中 `#06-04-1`/`#06-04-2` 与 `#06-04-3`/`#06-04-4` 各成一族)+ 7 条 skip 取消 + WINDOWS.md id 23 结清 + 同族未登记缺口登记。
执行序:本计划在 07-02(enemy)之后串行执行;`depends_on` 表示 D-09 逐步用户确认与 D-12c 全量门禁的串行约束,源码层面与本系统无耦合。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/WINDOWS.md
@dev.md
@packages-user/data-common/src/replay/types.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 replay 四条 + 共同根因家族与越界清单</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-common/src/replay/array.ts(:240-300 编码、:347-513 add/insert/delete/set、:586-656 解码、:708-794 读流与 rebuildIndexArray)
- packages-user/data-common/src/replay/array.test.ts(:82-96 注释、:185-234、:259-304、:484-539)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding replay 四小节、§跨条目共享模式、§Open Questions A/B/C、§Assumptions Log A6)
</read_first>
<action>
向用户逐条汇报以下内容,等待明确确认后才进入 Task 1。
① `#06-04-1`:`array.ts:621` 解码乘数 `2147483647` 改为 `2147483648`(与 `:369-370` 编码侧一致)。
② `#06-04-2`:编码循环体(`:264-270`)改为按位取字节(`(param >> (8n * BigInt(i))) & 0xffn`),解码的长度前缀(`:628`)与字节(`:631`)由 `getInt8` 改为 `getUint8`;`byteLength: arr.length + 2`(`:274`)与 `arr.length` 计算(`:261-263`)不变。
③ `#06-04-3`:`delete` 的终点(`:447`)改为末步感知(`index + 1 < this.length ? this.indexArray[index + 1] : this.paramUsed`),回退循环起点(`:469`)由参数字节偏移 `paramStart` 改为命令索引 `index`;并引入一个私有 `getParamRange(index)` 助手统一 `[start, end)`(以 `paramUsed` 作末步终点哨兵),`delete` 改用它。**`set()`(`:476-513`)属同族的未登记缺陷,本计划不改**,只在 SUMMARY 登记。
④ `#06-04-4`:**两处**位移方向取反——`paramArray.copyWithin(paramStart + length, paramStart)` 与 `indexArray.copyWithin(index + 1, index)`;`:435-437` 的尾段补偿循环在修正方向上已正确,不再改。
⑤ **必须点名的覆盖真相(A6)**:两个 insert 目标用例只用 `createReadStream` 顺序读,读流的 `currParam` 顺序累加、不依赖 `indexArray` 绝对值,因此只修 `:424` 也能让它们转绿;但 `array.get(i)`(`:697-706`)依赖 `indexArray[i]`,只修一处时插入后的 `get` 语义仍错。建议两处同修(完整修复)。
⑥ **越界清单(默认不改,请用户确认是否纳入)**:负 int64 不可逆(`-2147483649` → `-4294967297`);负 bigint 不可往返;`ReplayArray.set` 的末步 `nextParam` 与回退起点 `paramStart + 1`;`insert(index === length)` 取 `paramStart = indexArray[length] = 0`。研究建议一律不纳入本阶段(未登记、无既有测试、会扩大变更面)。
⑦ **账本事实(D-14 的执行障碍)**:WINDOWS.md 中只有 id 23 对应 `#06-04-1`/`#06-04-2`;`#06-04-3`/`#06-04-4` **没有对应条目**,修复后无 `fixed` 可打。研究建议不为 Phase 6 的 finding 新建账本条目(新增会抬高 `open_count` 门禁),改为在本计划 SUMMARY 登记完整对照表。请用户确认该处置。
⑧ 回滚与失败纪律:按缺陷/根因原子提交;任一任务方案失败必须退出本次修改、修订本 PLAN.md 后重新执行(D-09)。
</action>
<decision>replay 四条修复方案的执行范围:`#06-04-4` 是两处同修还是只修 `:424`,以及同族未登记缺口(set、末位 insert、负 int64/负 bigint)是否纳入</decision>
<context>
replay 四条共享「命令索引与参数字节偏移混用 + 末步缺终点哨兵」根因。`#06-04-4` 的两条 skip 只经读流顺序读,因此只修 `:424` 也能转绿,但 array.get(i) 的插入语义仍错(Assumption A6)。同族的 set/末位 insert/负值路径均未登记、无既有测试,纳入会显著扩大变更面。
</context>
<options>
<option id="approve-both">
<name>四条方案 + `#06-04-4` 两处同修;越界清单不纳入;不新建账本条目</name>
<pros>完整修正 insert 的 paramArray 与 indexArray 两个方向,array.get(i) 的插入语义同时正确;变更面仍限于本项目文件清单</pros>
<cons>`:425` 超出登记点名的单行范围(属同一根因的完整修复,已登记为 Assumption A6)</cons>
</option>
<option id="approve-min-insert">
<name>四条方案 + `#06-04-4` 只修 `:424`(严格最小)</name>
<pros>严格贴合登记的建议方向,变更面最小</pros>
<cons>array.get(i) 的插入语义仍错,需在 SUMMARY 如实标注覆盖真相</cons>
</option>
<option id="include-set">
<name>approve-both + 额外纳入 ReplayArray.set 的同族修复</name>
<pros>一次性消除同族最后一处已知缺口</pros>
<cons>未登记、无既有测试、需新增验证方式;违反 D-10「不新增用例」或需用户另行授权</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 approve-both / approve-min-insert / include-set,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 approve-both、approve-min-insert 或 include-set</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户明确 `#06-04-4` 的修法范围(两处同修 / 只修 `:424`)
- 用户明确越界清单的处置(默认不纳入)与账本条目处置(默认不新建)
</acceptance_criteria>
<done>用户给出明确选项;本计划后续任务按该选项执行</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-04-1 —— int64 解码乘数改为 2147483648</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(:586-656 decodeParam 全路径、:347-380 编码侧 int64 分支)
- packages-user/data-common/src/replay/array.test.ts(:82-96 位宽用例注释、:259-266、:287-291 目标 skip)
</read_first>
<action>
把 `array.ts:621` 的解码乘数由 `2147483647` 改为 `2147483648`,与 `:369-370` 编码侧(`Math.floor(num / 2147483648)` / `num % 2147483648`)严格一致。不改 `getInt32` 的读取偏移(`:618`/`:619`)与 `byte = 9`(`:620`)。
按 D-10 取消 `array.test.ts:287-291`(`round-trips int64 values above the int32 range`)的 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不处理负 int64 路径(`Math.floor` + JS `%` 符号语义,属未登记缺口,Task 0 已确认不纳入);不改编码侧;不新增用例。
验证:`pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿(重点确认 `:259-266` 的位宽切换用例与告警码用例不回归)→ D-44 门禁三步。提交信息:`fix(07-03): #06-04-1 fix the int64 decode multiplier`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(int64 往返仍不精确),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `array.ts` 中 `value = low + high * 2147483648;` 存在,且不再存在 `2147483647` 解码乘数
- `array.test.ts` 的 `round-trips int64 values above the int32 range` 已取消 skip
- `pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿
</acceptance_criteria>
<done>int64 解码精确往返、目标用例取消 skip;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: 修复 #06-04-2 —— 多字节 bigint 编码按位取字节、解码无符号读取</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(:255-275 bigint 编码、:626-635 bigint 解码、:376-380 setParamArray 的长度前缀写入)
- packages-user/data-common/src/replay/array.test.ts(:269-275 与 :335-340 既有绿用例、:278-284 与 :294-304 目标 skip、:371-376 告警 152)
</read_first>
<action>
把 `array.ts` bigint 编码(`:264-270`)的 `total`/`base`/`remain` 循环体替换为按位取字节:对 `i` 从 0 到 `length - 1` 写入 `Number((param >> (8n * BigInt(i))) & 0xffn)`。保留 `const bit = param.toString(2);`(`:261`)、`const length = Math.ceil(bit.length / 8);`(`:262`)与 `byteLength: arr.length + 2`(`:274`)不变。
把解码(`:628` 与 `:631`)的有符号读取改为无符号:长度前缀用 `this.paramView.getUint8(startIndex + 1)`,字节用 `this.paramView.getUint8(startIndex + 2 + i)`。其余解码逻辑与告警分支不变。
按 D-10 取消 `array.test.ts:278-284`(`round-trips a multi-byte bigint`)与 `:294-304`(`round-trips a heterogeneous step mixing a multi-byte bigint and an int64 value`)两处 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不处理负 bigint(未登记缺口,Task 0 已确认不纳入);不改 `normalizeParamList` 的位宽告警(152)逻辑;不新增用例。
验证:`pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿(重点确认 `:269-275` 单字节 bigint 与 `:335-340` 异质序列既有绿用例不回归)→ D-44 门禁三步。提交信息:`fix(07-03): #06-04-2 round-trip multi-byte bigint parameters`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(多字节 bigint 往返仍不精确),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `array.ts` 的 bigint 编码循环体内出现 `& 0xffn`,且不再出现 `total += remain <<` 写法
- `array.ts` 解码的长度前缀与字节均使用 `getUint8`
- `array.test.ts` 的 `round-trips a multi-byte bigint` 与 `round-trips a heterogeneous step mixing a multi-byte bigint and an int64 value` 已取消 skip
- `pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿
</acceptance_criteria>
<done>多字节 bigint 编解码可逆、两条 skip 取消并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 3: 修复 #06-04-3 —— delete 以命令索引回退并引入末步哨兵</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(:392-410 add 的 indexArray 写入、:442-474 delete、:749-767 rebuildIndexArray、:697-706 get)
- packages-user/data-common/src/replay/array.test.ts(:198-208 与 :484-495 既有 delete(0) 绿用例、:211-221 与 :511-520 目标 skip)
</read_first>
<action>
在 `array.ts` 中新增一个私有 `getParamRange(index: number)`:返回 `{ start, end }`,其中 `start = this.indexArray[index]`,`end = index + 1 < this.length ? this.indexArray[index + 1] : this.paramUsed`(以 `paramUsed` 作末条命令的终点哨兵)。按 dev.md 把它放在调用它的方法之前、置于合理的 `#region` 并写中文 jsDoc 说明「获取指定命令在参数缓冲区中的字节区间」;不得是解释下一行的无价值注释。
在 `delete`(:442-474)中用它替换 `:446-448` 的 `paramStart`/`nextParam`/`paramLength` 取值,并把 `:469` 的回退循环起点由 `paramStart` 改为 `index`(即 `for (let i = index; i < this.length; i++)`)。`delete(0)` 路径修后等价(此时 `index === paramStart === 0`),既有 `:198-208`/`:484-495` 必须保持绿。
按 D-10 取消 `array.test.ts:211-221`(`deletes a middle step and shifts later param indexes`)与 `:511-520`(`reads the new order after deleting a middle step from a heterogeneous route`)两处 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不改 `set`(同族未登记缺口,Task 0 已确认不纳入);不改 `add`;不改参数缓冲区置零逻辑。
验证:`pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿(全文件同时覆盖 `delete(0)`、`insert` 零参数、`set`、位宽切换与扩容)→ D-44 门禁三步。提交信息:`fix(07-03): #06-04-3 shift param indexes from the command index on delete`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(中间步删除后后续参数错位,或 `delete(0)` 既有用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `array.ts` 存在私有方法 `getParamRange`,其 `end` 使用 `index + 1 < this.length` 与 `this.paramUsed` 的末步哨兵
- `delete` 的回退循环起点为 `index`
- `array.test.ts` 的 `deletes a middle step and shifts later param indexes` 与 `reads the new order after deleting a middle step from a heterogeneous route` 已取消 skip
- `pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿(含 `delete(0)` 既有绿用例)
</acceptance_criteria>
<done>delete 的索引回退与末步哨兵正确、两条 skip 取消并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 4: 修复 #06-04-4 —— insert 的两处 copyWithin 位移方向</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(:412-440 insert、:722-743 createReadStream 的顺序读取、:697-706 get)
- packages-user/data-common/src/replay/array.test.ts(:185-195 零参数 insert 既有绿用例、:498-508 与 :523-539 目标 skip)
</read_first>
<action>
按 Task 0 用户确认的范围修正 `array.ts` 的 `insert`(:412-440)位移方向:`paramArray.copyWithin` 的 target 改为 `paramStart + length`、start 改为 `paramStart`;`indexArray.copyWithin` 的 target 改为 `index + 1`、start 改为 `index`。若用户选择「严格最小」,只改 `paramArray` 一处,并在 SUMMARY 中如实标注 `array.get(i)` 的插入语义仍依赖 `indexArray` 因而未完全修正(A6 覆盖真相)。
其余保持不变:`:421-422` 的 `commandStart`/`paramStart` 取值、`:427` 的注释(说明插入命令的起始索引不变)、`:428-429` 的赋值、`:431-432` 的 `length`/`paramUsed` 递增、`:435-437` 的尾段补偿循环(在修正后的位移方向上已恰好覆盖 `[index+1, newLength-1]`,不再改)。
按 D-10 取消 `array.test.ts:498-508`(`reads the new order after inserting a step`)与 `:523-539`(`reads the new order after inserting a step into a heterogeneous route`)两处 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不改 `insert(index === length)` 的末位追加路径(未登记缺口);不改 `add`/`set`/`delete`;不新增用例。
验证:`pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿(重点确认 `:185-195` 零参数 insert 既有绿用例不回归)→ D-44 门禁三步。提交信息:`fix(07-03): #06-04-4 shift replay params in the insert direction`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(插入后读回顺序错乱,或零参数 insert 既有用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `array.ts` 的 `insert` 中 `paramArray.copyWithin` 的目标为 `paramStart + length`
- 选择两处同修时,`indexArray.copyWithin` 的目标为 `index + 1`;选择严格最小时,SUMMARY 明确标注 `get` 语义未完全修正
- `array.test.ts` 的 `reads the new order after inserting a step` 与 `reads the new order after inserting a step into a heterogeneous route` 已取消 skip
- `pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿
</acceptance_criteria>
<done>insert 位移方向正确、两条 skip 取消并转绿、覆盖真相已在 SUMMARY 记录;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 5: D-44 门禁、全量套件、WINDOWS.md id 23 结清与同族缺口登记</name>
<files>packages-user/data-common/src/replay/array.ts, packages-user/data-common/src/replay/array.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Validation Architecture、§Open Questions A/B/C、§Common Pitfalls 2/4/5/6/7)
- .planning/WINDOWS.md(id 23 行)
</read_first>
<action>
对两个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按两文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
按 D-14 结清账本:`gsd_run windows fixed 23`(id 23 描述「受阻塞缺口 G-06-04-A:多字节 bigint / 超 int32 int64 编解码缺陷」)。`#06-04-3`/`#06-04-4` 无账本条目,**不新建条目**(Task 0 已确认),改为在 SUMMARY 记录完整对照表。
在 SUMMARY 中显式登记本计划**未修复**的同族缺口(不得静默丢弃):负 int64 不可逆、负 bigint 不可往返、`ReplayArray.set` 的末步 `nextParam` 与回退起点、`insert(index === length)` 的 `paramStart`、码 128 占位符不足(属 07-05)。每条注明「未登记缺陷、无既有测试、需用户决定是否另立条目」。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed";或 `windows status` 显示 id 23 仍为 open</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对两文件 0 错误
- `pnpm exec vue-tsc --noEmit` 按两文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`
- WINDOWS.md 中 id 23 状态为 `fixed`
- SUMMARY 含同族未修复缺口清单(4 条)
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、id 23 结清、同族缺口已登记于 SUMMARY(提交粒度按 D-13,按根因族各成原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 录像存档 → 解码器 | 录像参数经定长/长度前缀解码还原为命令参数,长度与索引读取必须无符号且与编码对称 |
| 命令索引 ↔ 参数字节偏移 | 同一 `number[]` 承担两种语义,混用即产生静默错位 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-02 | Tampering | `array.ts` `decodeParam`/`normalizeParam` | medium | mitigate | Task 1/2 使 int64 与多字节 bigint 编解码精确往返;`getUint8` 修正顺带消除「长度前缀 ≥128 被当作负数」的静默错读 |
| T-7-08 | Tampering | `array.ts` `delete`/`insert` 的索引位移 | medium | mitigate | Task 3/4 使命令索引与参数字节偏移严格区分,避免删除/插入后回放读到错误参数(错误参数会被下游当作合法指令执行) |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-common/src/replay/array.test.ts` 全绿(7 条 skip 取消后)
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 两个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
- WINDOWS.md id 23 为 fixed;同族未修复缺口在 SUMMARY 可见
</verification>
<success_criteria>
1. `#06-04-1`、`#06-04-2`、`#06-04-3`、`#06-04-4` 对应 7 条正确预期用例全部取消 skip 并转绿
2. `delete(0)`、零参数 `insert`、`set`、位宽切换与扩容等既有绿用例不回归
3. 按根因两个族(编解码 / 索引编辑)各成原子提交,共 4 个提交
4. 同族未登记缺口在 SUMMARY 明确登记,未被静默忽略
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-common/src/replay/array.ts` | int64 乘数、bigint 编解码、`getParamRange` 助手、`delete` 索引回退、`insert` 位移方向 |
| 测试(修改) | `packages-user/data-common/src/replay/array.test.ts` | 7 条 skip 取消 |
| 账本 | `.planning/WINDOWS.md` | id 23 → fixed |
| 摘要 | `.planning/phases/07-data-fixes/07-03-SUMMARY.md` | 逐条修复记录、覆盖真相(读流 vs get)、同族未修复缺口清单、门禁结果 |
<output>
Create `.planning/phases/07-data-fixes/07-03-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,350 @@
---
phase: 07-data-fixes
plan: 04
type: execute
wave: 4
depends_on:
- 07-03
files_modified:
- packages-user/data-base/src/hero/equipStore.ts
- packages-user/data-base/src/hero/equipment.ts
- packages-user/data-base/src/hero/attribute.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/saveLoad.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 52000
raw_tokens: 52000
tasks: 7
confidence: low
must_haves:
truths:
- "取消 skip 后 hero/saveLoad.test.ts 的装备数值/百分比加成在 NoCompression 与压缩档往返用例转绿"
- "取消 skip 后装备存档快照与活对象独立的用例(同实例与容器三档)转绿"
- "取消 skip 后无修饰器时 final 属性反映 base 的用例转绿"
- "取消 skip 后字符串槽位优先占用空槽的用例转绿"
- "码 147 对应用例保留 it.skip 并带中文说明注释,生产代码一行未动"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-base/src/hero/equipStore.ts
provides: "NoCompression 读档按 value/percentage 分表恢复;压缩档读档以 item.equip 原始定义回退后再叠加差异"
- path: packages-user/data-base/src/hero/equipment.ts
provides: "saveState 深拷贝 equipped/slots;getCouldEquipSlot 优先返回空槽"
- path: packages-user/data-base/src/hero/attribute.ts
provides: "无修饰器时 final 属性同步 base 值"
key_links:
- "equipStore.ts loadDiff 的基准 ↔ saveDiff(:80-88)以 this.item.equip 为基准写差异"
- "equipment.ts saveState 深拷贝 ↔ data-common/src/save/types.ts:12-17 的 ISaveableContent 契约"
- "equipment.ts saveState 深拷贝 ↔ equipStore.ts saveNoCompression(:67-74)的 new Map(...) 既有写法"
- "attribute.ts 无修饰器分支 ↔ 有修饰器分支(:84-97)的 finalAttribute 写入语义"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;若用户对 hero 各条的期望不同,在 D-09 预执行汇报时裁决"
- "A?:`#06-05-3` 在 WINDOWS.md 中无对应条目,D-14 的 waive 没有可 waive 的对象;按 D-06 只保留 skip 与注释,不新建账本条目(须在 D-09 汇报中确认)"
prohibitions:
- "不新增测试用例(D-10);只取消 it.skip(`#06-05-3` 连 skip 都保留)"
- "不弱化断言,不改写为可跑绿假象"
- "不为 #06-05-3 修改任何生产代码,不把码 147 标记为死码(D-06)"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
- "不修改本计划 files_modified 之外的源码;HeroEquipment.loadState 的数字槽回装语义缺口与 catchCalculateProgress 的同类早退一律只登记不修"
---
<objective>
修复勇士数据模型(`@user/data-base` L1)的 5 条登记缺陷:`#06-09-1`(高)`EquipmentState` 读档丢加成(NoCompression 误遍历百分比表;压缩档 `loadDiff` 缺 `item.equip` 回退基准)、`#06-09-2` `HeroEquipment.saveState` 未深拷贝、`#06-05-1` 无修饰器时 `finalAttribute` 陈旧、`#06-05-2` `getCouldEquipSlot` 空槽判断写反、`#06-05-3` 码 147 保留(设计如此,D-06)。
Purpose: 装备加成、存档快照独立性与属性 final 同步直接决定勇士数值正确性与存读档可靠性;`#06-09-1` 是 20 条中唯一登记为「高」严重度的一条。
Output: 3 个生产文件的最小修复 + 9 条 skip 取消 + 1 条 skip 保留且加注释 + WINDOWS.md id 25/26 结清 + 已知语义缺口登记。
执行序:本计划在 07-03(replay)之后串行执行;`depends_on` 表示 D-09 逐步用户确认与 D-12c 全量门禁的串行约束,源码层面与本系统无耦合。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/WINDOWS.md
@dev.md
@packages-user/data-common/src/save/types.ts
@packages-user/data-base/src/hero/types.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 hero 五条方案、147 处置与账本事实</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-base/src/hero/equipStore.ts(:63-102 存档侧、:112-151 读档侧)
- packages-user/data-base/src/hero/equipment.ts(:130-150 getCouldEquipSlot、:152-197 equip 与 147 分支、:329-346 saveState/loadState)
- packages-user/data-base/src/hero/attribute.ts(:76-117 recalculateAttribute 与 catchCalculateProgress)
- packages-user/data-base/src/hero/saveLoad.test.ts(:300-382、:584-610)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding hero 四小节、§Open Questions E、§WINDOWS.md 收口映射「需澄清」)
</read_first>
<action>
向用户逐条汇报以下内容,等待明确确认后才进入 Task 1。
① `#06-09-1`(高):`equipStore.ts` 的 `loadNoCompression`(:116-126)与 `loadDiff`(:132-151)都误把 `state.percentage` 同时写入 `this.value` 与 `this.percentage`;修正为 `loadNoCompression` 分别遍历 `state.value` 与 `state.percentage`;`loadDiff` 先以 `this.item.equip.value`/`.percentage` 为基准装载,再叠加 `state.value`/`state.percentage` 差异后 `rebuildModifiers()`。文档(`:128-131` 的 jsdoc 已写「缺失则回退到原始定义」)即为事实源。
② `#06-09-2`:`equipment.ts:329-334` 的 `saveState` 返回活引用;改为 `equipped: new Map(this.equips)` 与 `slots: [...this.slots]`(与 `equipStore.ts:71` 的 `new Map(...)` 既有写法一致),满足 `data-common/src/save/types.ts:12-17` 的深拷贝契约。返回值类型兼容 `ReadonlyMap<number, number>` / `readonly string[]`,**无需 `as`**。
③ `#06-05-1`:`attribute.ts:81-82` 无修饰器时直接返回,`finalAttribute` 永不刷新;改为 `this.finalAttribute[name] = this.attribute[name]; return;`(保持既有同引用语义)。明确不改 `catchCalculateProgress`(`:100-102`)的同类早退——它是生成器且不写 final。
④ `#06-05-2`:`equipment.ts:141` 的条件 `empty !== -1 && !this.equips.has(index)` 恒假,导致 `empty` 永远为 -1、恒返回首个匹配槽;改为 `empty === -1 && !this.equips.has(index)`。明确不重构 `canEquipTo` 的字符串分支(`:50-72`,逻辑本身正确)。
⑤ `#06-05-3`(D-06 逐字执行):生产代码**一行不动**、**不标记死码**;`equipment.test.ts:306-315` 的 `it.skip` **保留**并补一条中文注释说明「147 为保留错误码,当前不可达,设计如此」;不纳入取消 skip 清单。可达性证明已由研究复核(数字槽必返回非 -1;字符串槽 `hasSlot === false` 时 `canEquipTo` 提前返回 `CannotEquip`)。
⑥ **账本事实(D-14 的执行障碍)**:`#06-05-3` 在 WINDOWS.md 中**没有对应条目**(19–27 的 9 条已全部映射到其它 finding),因此 D-14 所述「保留 open 并 waive 注明原因」**没有可 waive 的对象**。研究建议不为 Phase 6 的 finding 新建账本条目(新增会抬高 `open_count` 门禁),改为在本计划 SUMMARY 记录处置。请用户确认。
⑦ **相邻语义缺口(默认不改,请用户确认)**:`HeroEquipment.loadState`(`:336-346`)以 `equip(uid, index)` 数字槽回装,而 `canEquipTo` 对数字槽要求 `item.equip.slots.includes(index)`;只声明字符串槽的装备在容器三档往返时可能被静默丢弃(现有用例的装备定义均为 `slots=[0]`,故未暴露)。属 `canEquipTo` 契约范畴(用户设计),本计划不改,仅登记。
⑧ 回滚与失败纪律:按缺陷原子提交;任一任务方案失败必须退出本次修改、修订本 PLAN.md 后重新执行(D-09)。
</action>
<decision>是否按本计划处置 hero 五条(含 `#06-05-3` 只改注释不建账本条目),以及数字槽回装语义缺口是否纳入本阶段</decision>
<context>
hero 五条中 `#06-09-1` 是全阶段唯一「高」严重度项;`#06-05-3` 按 D-06 为设计如此,生产代码不动,但 WINDOWS.md 无对应条目,D-14 的 waive 没有对象。数字槽回装与 canEquipTo 的不一致属用户设计的接口契约范畴,纳入会超出 FIX-01「修缺陷」边界。
</context>
<options>
<option id="approve">
<name>五条按计划执行;147 只改注释;数字槽缺口只登记不修</name>
<pros>严格贴合 D-06 与 D-01 范围;不抬高 windows open_count 门禁</pros>
<cons>147 的「保留」与数字槽缺口只存在于 SUMMARY,不进入账本</cons>
</option>
<option id="new-window-entry">
<name>为 `#06-05-3` 新建账本条目并 waive</name>
<pros>147 的保留决策进入跨阶段账本,后续阶段可见</pros>
<cons>抬高 open_count,可能阻塞 /gsd-ship 的 windows_enforce 门禁</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 approve / new-window-entry,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 approve 或 new-window-entry</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户明确 `#06-05-3` 的处置方式(只改注释 / 新建账本条目)
- 用户明确数字槽语义缺口是否纳入(默认不纳入)
- 用户确认 `loadDiff` 以 `item.equip` 为基准的修法
</acceptance_criteria>
<done>用户给出明确选项;本计划后续任务按该选项执行</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-09-1(高)—— EquipmentState 读档分表恢复与压缩档回退基准</name>
<files>packages-user/data-base/src/hero/equipStore.ts, packages-user/data-base/src/hero/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/equipStore.ts(:34-55 构造器与 rebuildModifiers、:63-110 存档侧、:112-151 读档侧)
- packages-user/data-base/src/hero/saveLoad.test.ts(:328-382 目标用例群、:384-401 既有 uid 往返绿用例)
</read_first>
<action>
在 `equipStore.ts` 修正两处读档实现:
① `loadNoCompression`(:116-126):第一个循环改为遍历 `state.value` 并写入 `this.value`,第二个循环保持遍历 `state.percentage` 并写入 `this.percentage`;
② `loadDiff`(:132-151):`clear()` 之后先以 `this.item.equip.value` 与 `this.item.equip.percentage` 为基准装载 `this.value`/`this.percentage`,再叠加 `state.value` 与 `state.percentage` 的差异条目,最后 `rebuildModifiers()`。原先误遍历 `state.percentage` 写入 `this.value` 的两段循环删除。
按 D-11 若实现与既有 jsDoc(`:112-115`「使用完整存档值覆盖所有修饰器」/`:128-131`「从存档查询值,缺失则回退到原始定义」)出现措辞偏差,同步校正注释;在 `loadDiff` 的基准装载处保留/补一条有价值的中文注释说明「基准为装备原始定义,再叠加存档中的差异条目」。
按 D-10 取消 `saveLoad.test.ts` 的 4 条 skip:`:337-341`(LowCompression 百分比)、`:344-348`(HighCompression 百分比)、`:351-366`(数值修饰器同实例)、`:369-381`(压缩档未修改的数值修饰器),断言一字不改,同步 it 前的中文注释。既有绿用例 `:330-334`(NoCompression 百分比)必须保持绿。
不做的事:不改 `saveNoCompression`/`saveDiff`;不改构造器装载;不改 `rebuildModifiers`;不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts` 与 `pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts packages-user/data-base/src/hero/equipment.test.ts packages-user/data-base/src/hero/saveLoad.test.ts` 全绿 → D-44 门禁三步。提交信息:`fix(07-04): #06-09-1 restore equipment modifiers from split save tables`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(压缩档回退后加成仍丢失,或 NoCompression 既有绿用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `equipStore.ts` 的 `loadNoCompression` 中 `this.value.set(` 的数据源为 `state.value`(源码断言:`for (const [name, ...] of state.value)` 后写 `this.value.set`)
- `loadDiff` 中存在以 `this.item.equip.value` / `this.item.equip.percentage` 为基准的装载
- `saveLoad.test.ts` 的 4 条目标用例(`:337`、`:344`、`:351`、`:369`)均已取消 skip
- `pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts` 全绿
</acceptance_criteria>
<done>两条读档路径正确、4 条 skip 取消并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: 修复 #06-09-2 —— HeroEquipment.saveState 深拷贝</name>
<files>packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/equipment.ts(:329-346 saveState/loadState)
- packages-user/data-common/src/save/types.ts(:12-17 ISaveableContent 深拷贝契约)
- packages-user/data-base/src/hero/equipStore.ts(:67-74 new Map(...) 既有写法)
- packages-user/data-base/src/hero/saveLoad.test.ts(:294-325、:584-610)
</read_first>
<action>
把 `equipment.ts:329-334` 的 `saveState` 改为返回深拷贝:`equipped` 用 `new Map(this.equips)`,`slots` 用 `[...this.slots]`。返回值类型与 `IHeroEquipmentSave` 兼容,不使用 `as`。
按 D-10 取消 `saveLoad.test.ts:312-325`(`returns an equipment snapshot independent from the live state`)与 `:589-609`(`restores the equipped mapping through the container across all compressions`)两处 skip,断言一字不改,同步 it 前的中文注释。既有绿用例 `:294-309`(先 `structuredClone(saveState())`)必须保持绿。
不做的事:不改 `loadState`(`:336-346`);不改数字槽回装语义(Task 0 已确认不纳入);不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts` 全绿 → D-44 门禁三步。提交信息:`fix(07-04): #06-09-2 deep copy the equipment save snapshot`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(快照仍与活对象同引用导致读回丢失),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `equipment.ts` 的 `saveState` 返回 `new Map(this.equips)` 与 `[...this.slots]`
- `saveLoad.test.ts` 的 `returns an equipment snapshot independent from the live state` 与 `restores the equipped mapping through the container across all compressions` 已取消 skip
- `pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts` 全绿
</acceptance_criteria>
<done>存档快照与活对象解耦、两条 skip 取消并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 3: 修复 #06-05-1 —— 无修饰器时同步 final 属性</name>
<files>packages-user/data-base/src/hero/attribute.ts, packages-user/data-base/src/hero/attribute.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/attribute.ts(:76-117、:131-149 set/add/mul/div、:220-235 markDirty)
- packages-user/data-base/src/hero/attribute.test.ts(:96-128 既有绿用例、:115-121 目标 skip、:247-269 catchCalculateProgress)
</read_first>
<action>
把 `attribute.ts:80-82` 的提前返回改为「无修饰器时把 base 值写入 final 后返回」:保留 `const modifierList = this.modifier.get(name);`,将 `if (!modifierList) return;` 改为在分支内执行 `this.finalAttribute[name] = this.attribute[name]; return;`。同引用语义与有修饰器分支(`:85` 的 `let value = baseValue`)一致。按 D-11 在该分支写一条有价值的中文注释说明「无修饰器时 final 与 base 保持同值」。
按 D-10 取消 `attribute.test.ts:115-121`(`reflects base-only changes without any modifier`)的 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不改 `catchCalculateProgress`(`:100-117`)——它是生成器且不写 final,保留原样;不改 `isSameReference` 告警(109)分支;不改 `set/add/mul/div`;不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts` 全绿(重点确认 `:96-112` 有修饰器路径与 `:247-269` 生成器用例不回归)→ D-44 门禁三步。提交信息:`fix(07-04): #06-05-1 refresh final attributes without modifiers`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(final 仍陈旧),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `attribute.ts` 的 `recalculateAttribute` 在无修饰器分支内写入 `this.finalAttribute[name]`
- `attribute.test.ts` 的 `reflects base-only changes without any modifier` 已取消 skip
- `pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts` 全绿
</acceptance_criteria>
<done>无修饰器时 final 同步 base、目标用例取消 skip 并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 4: 修复 #06-05-2 —— 优先占用空命名槽</name>
<files>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/equipment.ts(:32-72 canEquipTo 字符串分支、:130-150 getCouldEquipSlot、:152-197 equip)
- packages-user/data-base/src/hero/equipment.test.ts(:173-236 与 :275-287 既有绿用例、:290-303 目标 skip)
</read_first>
<action>
把 `equipment.ts:141` 的条件由 `empty !== -1 && !this.equips.has(index)` 改为 `empty === -1 && !this.equips.has(index)`,恢复「优先返回首个空槽、无空槽才返回首个匹配槽」的语义。`:145-149` 的返回分支保持不变(修后 `:148` 不再是死代码)。按 D-11 检查 `:130-133` 与 `:152-159` 的注释是否仍准确,如有偏差同步校正。
按 D-10 取消 `equipment.test.ts:290-303`(`uses the first empty named slot instead of replacing an occupant`)的 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不重构 `canEquipTo` 的字符串分支(`:50-72`,逻辑本身正确);不改数字槽早退(`:135`);不改 `equip` 的自动卸载逻辑;不动 147 分支;不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts` 全绿(重点确认 `:173-236` 的单槽/autoUnload 与 `:275-287` 的槽位列举不回归)→ D-44 门禁三步。提交信息:`fix(07-04): #06-05-2 prefer the first empty named slot`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(字符串槽仍替换占用者,或既有数字槽用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `equipment.ts` 的条件为 `empty === -1 && !this.equips.has(index)`
- `equipment.test.ts` 的 `uses the first empty named slot instead of replacing an occupant` 已取消 skip
- `pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts` 全绿
</acceptance_criteria>
<done>空槽优先语义恢复、目标用例取消 skip 并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 5: D-06 注释保留、D-44 门禁、全量套件与 WINDOWS.md 25/26 结清</name>
<files>packages-user/data-base/src/hero/equipStore.ts, packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/attribute.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/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/hero/equipment.test.ts(:305-315 保留的 147 skip)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding #06-05-3、§Validation Architecture、§Open Questions E)
- .planning/WINDOWS.md(id 25/26 行)
</read_first>
<action>
① 按 D-06 为保留的 `equipment.test.ts:306-315`(`warns code 147 when no equipment slot is available`)补一条中文单行注释,说明「147 为保留错误码,当前不可达,设计如此」,并同步其上方原「疑似 bug」注释(改为中性描述,说明该用例为保留错误码的静态见证)。**除注释外该用例一字不改,`it.skip` 必须保留**;生产代码不得出现任何与 147 相关的改动。若 Task 0 用户选择新建账本条目,则按用户指示执行 `windows` 的 append/waive 流程。
② 对 6 个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按 6 个文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
③ 按 D-14 结清账本:`gsd_run windows fixed 25`(id 25 = 压缩档 `loadDiff` 未回退装备原始定义)与 `gsd_run windows fixed 26`(id 26 = `HeroEquipment.saveState` 未深拷贝)。`#06-05-1`/`#06-05-2`/`#06-05-3` 无账本条目,**不新建条目**(除非 Task 0 另有指示)。
④ 在 SUMMARY 中登记相邻语义缺口(不得静默丢弃):`HeroEquipment.loadState` 数字槽回装与 `canEquipTo` 要求 `item.equip.slots.includes(index)` 的不一致(只声明字符串槽的装备在容器往返时可能被静默丢弃);并记录 `#06-05-3` 的处置方式与 `#06-09-1`/`#06-09-2` 的 windows 对照。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed";或 `Select-String -Path packages-user/data-base/src/hero/equipment.test.ts -Pattern "保留错误码"` 无命中;或 `windows status` 显示 id 25/26 仍为 open</fails_when>
</verify>
<acceptance_criteria>
- `equipment.test.ts` 的 147 用例仍为 `it.skip`,且文件内存在包含「保留错误码」的中文注释
- `pnpm exec eslint` 对 6 个改动文件 0 错误;`pnpm exec vue-tsc --noEmit` 按 6 个文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`(147 用例为既有 skip)
- WINDOWS.md 中 id 25、26 状态为 `fixed`
- SUMMARY 含数字槽语义缺口登记
</acceptance_criteria>
<done>147 注释就位且 skip 保留、D-44 三步全过、全量套件绿、id 25/26 结清、缺口已登记(提交粒度按 D-13 一个缺陷一个原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 存档 → 装备状态恢复 | 存档是外部可篡改输入;读档必须按 save 形状分表恢复并以原始定义为回退基准,不得静默丢字段 |
| 活对象 → 存档快照 | `saveState` 返回值必须与活对象解耦,否则后续修改会污染已生成的快照 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-01 | Tampering | `equipStore.ts` `loadNoCompression`/`loadDiff` | high | mitigate | Task 1 分表恢复并以 `item.equip` 为回退基准,消除压缩档静默丢加成(高严重度项) |
| T-7-09 | Tampering | `equipment.ts` `saveState` | medium | mitigate | Task 2 深拷贝 `equipped`/`slots`,满足 `ISaveableContent` 契约,防止快照被活对象后续修改污染 |
| T-7-10 | Tampering | `attribute.ts` `recalculateAttribute` | low | mitigate | Task 3 使无修饰器路径的 `finalAttribute` 与 base 同步,消除陈旧 final 导致的数值误判 |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts packages-user/data-base/src/hero/equipment.test.ts packages-user/data-base/src/hero/saveLoad.test.ts` 全绿
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 6 个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
- WINDOWS.md id 25/26 为 fixed;147 注释与 skip 同时存在
</verification>
<success_criteria>
1. `#06-09-1`(高)、`#06-09-2`、`#06-05-1`、`#06-05-2` 对应 9 条正确预期用例取消 skip 并转绿
2. `#06-05-3` 严格按 D-06 处置:生产代码零改动、skip 保留、注释说明就位
3. 按缺陷 4 个原子提交 + 1 个注释/门禁收尾提交
4. 数字槽语义缺口在 SUMMARY 登记,未被静默忽略
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-base/src/hero/equipStore.ts` | 读档分表恢复与压缩档回退基准 |
| 生产源码(修改) | `packages-user/data-base/src/hero/equipment.ts` | `saveState` 深拷贝;`getCouldEquipSlot` 空槽条件 |
| 生产源码(修改) | `packages-user/data-base/src/hero/attribute.ts` | 无修饰器时 final 同步 base |
| 测试(修改) | `packages-user/data-base/src/hero/saveLoad.test.ts` | 6 条 skip 取消 |
| 测试(修改) | `packages-user/data-base/src/hero/attribute.test.ts` | 1 条 skip 取消 |
| 测试(修改) | `packages-user/data-base/src/hero/equipment.test.ts` | 1 条 skip 取消 + 1 条 skip 保留并加注释 |
| 账本 | `.planning/WINDOWS.md` | id 25/26 → fixed |
| 摘要 | `.planning/phases/07-data-fixes/07-04-SUMMARY.md` | 逐条修复记录、147 处置、数字槽缺口登记、门禁结果 |
<output>
Create `.planning/phases/07-data-fixes/07-04-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,272 @@
---
phase: 07-data-fixes
plan: 05
type: execute
wave: 5
depends_on:
- 07-04
files_modified:
- packages-user/data-base/src/map/mapLayer.ts
- packages-user/data-base/src/map/dynamicTile.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: 26000
raw_tokens: 26000
tasks: 4
confidence: low
must_haves:
truths:
- "取消 skip 后 map/mapLayer.test.ts 的越图 transferToDynamic 发码 128 用例转绿"
- "取消 skip 后 map/saveLoad.test.ts 的同实例恢复图块数字用例转绿"
- "码 131 的既有断言(gameMap.test.ts 的 setEventLayer 路径)不回归"
- "mapLifecycle.test.ts 的 loadState 幂等与 dirty 语义不回归"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-base/src/map/mapLayer.ts
provides: "transferToDynamic 越界分支与两个 transferToStatic* 分支发同一诊断码 128"
- path: packages-user/data-base/src/map/dynamicTile.ts
provides: "DynamicTile.loadState 先 set(save.num) 再恢复事件,逐个 save 字段被消费"
key_links:
- "mapLayer.ts transferToDynamic 越界分支 ↔ transferToStatic(:466-469)与 transferToStaticIfSafe(:486-489)的同款守卫"
- "码 131 的语义(setEventLayer 越权)↔ packages/common/src/logger.json 文案,唯一既有断言在 gameMap.test.ts:185"
- "dynamicTile.ts loadState ↔ 同文件 set(:56-66,内含 restoreDefaultEvents)与 saveState(:102-116,num 为必填)"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;若用户对 map 两条的期望不同,在 D-09 预执行汇报时裁决"
prohibitions:
- "不新增测试用例(D-10);只取消 it.skip"
- "不弱化断言,不改写为可跑绿假象"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
- "不修改本计划 files_modified 之外的源码(尤其 `data-base/src/map/gameMap.ts` 的码 131 写入点与 `mapLifecycle.test.ts`);不为码 128 补齐占位符参数(越界清单,默认不改)"
---
<objective>
修复地图数据模型(`@user/data-base` L1)的 2 条登记缺陷:`#06-06-1` 越图 `transferToDynamic` 发错诊断码 131(应为 128,对齐两个 `transferToStatic*` 兄弟分支,D-04)、`#06-09-3` `DynamicTile.loadState` 不恢复 `num`。
Purpose: 诊断码是排障与回放校验的事实源,语义错位会误导定位;动态图块数字未恢复会让读档后的地图层与存档不一致(仅图层路径因先 `createDynamic(num)` 而掩盖该缺陷)。
Output: 2 个生产文件的最小修复 + 2 条 skip 取消 + 码 131 全仓断言复核 + 未登记缺口登记。
执行序:本计划在 07-04(hero)之后串行执行;`depends_on` 表示 D-09 逐步用户确认与 D-12c 全量门禁的串行约束,源码层面与本系统无耦合。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@dev.md
@packages/common/src/logger.json
@packages-user/data-base/src/map/types.ts
@packages-user/data-base/src/map/tile.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 map 两条方案与码 131/128 影响面</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-base/src/map/mapLayer.ts(:435-496 三个转换分支、:820-849 loadDynamics)
- packages-user/data-base/src/map/dynamicTile.ts(:56-66 set、:102-127 saveState/loadState)
- packages-user/data-base/src/map/mapLayer.test.ts(:397-437)
- packages-user/data-base/src/map/saveLoad.test.ts(:130-201)
- packages/common/src/logger.json(码 127/128/131)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding #06-06-1 与 #06-09-3、§Open Questions F、§Code Examples map/save)
</read_first>
<action>
向用户逐条汇报以下内容,等待明确确认后才进入 Task 1。
① `#06-06-1`(D-04):`mapLayer.ts:440-443` 的 `transferToDynamic` 越界分支由 `logger.warn(131, x, y)` 改为 `logger.warn(128, x, y)`,与 `transferToStatic`(`:466-469`)和 `transferToStaticIfSafe`(`:486-489`)逐字一致(含参数个数)。保留 `this.inMap(x, y)` 守卫(语义等价)。
② **码影响面复核(改动前后必做,Pitfall 1)**:码 131 的文案语义是 `setEventLayer` 越权,其唯一既有测试断言在 `gameMap.test.ts:185`,生产写入点全仓仅 `mapLayer.ts:441` 与 `gameMap.ts:149`。本改动只去掉 `mapLayer.ts:441` 这一处,不影响 `setEventLayer` 路径。`mapLayer.test.ts:408-416`(绿)只断言返回 `null`,不断言码集合。
③ **越界清单(默认不改,请用户确认)**:码 128 的文案含 `$1..$4` 四个占位符,而三个越界分支都只传 2 个参数(缺失项渲染为 `[not delivered]`)。本计划不补齐(与两个既有兄弟分支保持一致,避免扩大变更面)。
④ `#06-09-3`:`dynamicTile.ts:118-127` 的 `loadState` 改为先 `this.set(save.num)`(`set` 内部已含 `tileNum` 写入、`tileRaw` 重取与 `restoreDefaultEvents()`),再执行原始件恢复逻辑;**保持一参签名**(`MapTileBase` 抽象声明 `tile.ts:76` 同样一参,dev.md 要求未使用的后置参数直接不填,不得为它补 `compression`)。既有绿用例 `map/saveLoad.test.ts:142-161` 与 `mapLifecycle.test.ts:126-148` 按研究逐条复核不回归。
⑤ 账本事实:`#06-06-1` 与 `#06-09-3` 在 WINDOWS.md 中**无对应条目**,修复后无 `fixed` 可打;按研究建议不新建条目(避免抬高 `open_count`),改为在本计划 SUMMARY 记录对照。
⑥ 回滚与失败纪律:按缺陷原子提交;任一任务方案失败必须退出本次修改、修订本 PLAN.md 后重新执行(D-09)。
</action>
<decision>是否按 D-04 把越图 transferToDynamic 的诊断码由 131 改为 128,以及是否顺带补齐码 128 的 4 个占位符参数</decision>
<context>
码 131 的语义是 setEventLayer 越权,与越界转换无关;两个 transferToStatic* 兄弟分支早已发 128。补齐 128 占位符会同时改动两个既有兄弟分支,超出本条范围。`#06-09-3` 与 `#06-06-1` 无账本条目,新建会抬高 open_count 门禁。
</context>
<options>
<option id="approve">
<name>两条方案按计划执行;不补 128 占位符;不新建账本条目</name>
<pros>与两个既有兄弟分支保持逐字一致,变更面最小;账本 open_count 不变</pros>
<cons>128 的 $3/$4 仍渲染为 [not delivered](既有小瑕疵被继承)</cons>
</option>
<option id="fill-placeholders">
<name>同时为三个越界分支补齐 128 的 4 个占位符参数</name>
<pros>诊断文案完整可读</pros>
<cons>改动手伸向两个未被登记的既有分支,变更面与回滚粒度扩大;超出 #06-06-1 登记范围</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 approve / fill-placeholders,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 approve 或 fill-placeholders</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户确认码 131 → 128 的改动不影响 `setEventLayer` 语义
- 用户明确 128 占位符的处置(默认不补)与账本条目处置(默认不新建)
</acceptance_criteria>
<done>用户给出明确选项;本计划后续任务按该选项执行</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-06-1 —— 越图 transferToDynamic 改发码 128</name>
<files>packages-user/data-base/src/map/mapLayer.ts, packages-user/data-base/src/map/mapLayer.test.ts</files>
<read_first>
- packages-user/data-base/src/map/mapLayer.ts(:435-496)
- packages-user/data-base/src/map/mapLayer.test.ts(:397-437)
- packages-user/data-base/src/map/gameMap.test.ts(:173-192 码 131 的唯一既有断言)
- packages/common/src/logger.json(码 128 与 131 文案)
</read_first>
<action>
把 `mapLayer.ts:441` 的 `logger.warn(131, x.toString(), y.toString())` 改为 `logger.warn(128, x.toString(), y.toString())`,参数个数与形态与两个兄弟分支保持一致。`if (!this.inMap(x, y))` 守卫、`return null`、其后的 `num === 0` 分支(告警 127)与其余控制流一律不动。若 Task 0 用户选择补齐占位符,则按用户指示同时修改三个越界分支的参数(否则严格只改这一处)。
按 D-10 取消 `mapLayer.test.ts:419-427`(`warns code 128 for an out-of-map transferToDynamic`)的 skip,断言一字不改,同步 it 前的中文注释。修复前先确认 `:408-416` 的既有绿用例(只断言 `null`)与 `gameMap.test.ts:185` 的码 131 断言均不含 131 → 128 的隐式依赖。
不做的事:不改 `gameMap.ts` 的 `setEventLayer` 写入点;不改 `transferToStatic*`(除非 Task 0 选择了补齐占位符);不改 `num === 0` 分支;不改 `mapLayer.test.ts:397-406`(告警 127)与 `:429-437`;不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/gameMap.test.ts` 全绿(覆盖码 131 唯一断言)→ D-44 门禁三步。提交信息:`fix(07-05): #06-06-1 warn code 128 for an out-of-map dynamic transfer`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/gameMap.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(诊断码不符,或码 131 的既有断言回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `mapLayer.ts` 的 `transferToDynamic` 越界分支发 `logger.warn(128, ...)`,且文件内不再存在 `logger.warn(131`
- `mapLayer.test.ts` 的 `warns code 128 for an out-of-map transferToDynamic` 已取消 skip
- `pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/gameMap.test.ts` 全绿
</acceptance_criteria>
<done>越界诊断码与兄弟分支一致、目标用例取消 skip 且码 131 路径不回归;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: 修复 #06-09-3 —— DynamicTile.loadState 恢复图块数字</name>
<files>packages-user/data-base/src/map/dynamicTile.ts, packages-user/data-base/src/map/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/map/dynamicTile.ts(:50-128)
- packages-user/data-base/src/map/saveLoad.test.ts(:130-201)
- packages-user/data-base/src/map/mapLifecycle.test.ts(:100-150)
- packages-user/data-base/src/map/types.ts(:25-35 IDynamicBlockSave)
</read_first>
<action>
按「逐个 save 字段核对该字段是否被 loadState 消费」的清单式修法改写 `dynamicTile.ts:118-127` 的 `loadState`:第一步调用既有 `this.set(save.num)`(其内部已含 `this.tileNum = num`、`tileRaw` 重取与 `restoreDefaultEvents()`),第二步保留原有 `if (save.events)` 事件恢复逻辑(`tileEvent().clear()` 后按 priority 写入)。签名保持一参 `loadState(save: Readonly<IDynamicBlockSave>)`,**不得**补 `compression` 参数。按 D-11 同步方法前的注释(如无注释则在合理位置补一条有价值的中文说明,指出 num 与事件是 save 的全部字段)。
按 D-10 取消 `map/saveLoad.test.ts:164-173`(`restores the tile num on the same instance`)的 skip,断言一字不改,同步 it 前的中文注释。
不做的事:不改 `set`/`saveState`/`restoreDefaultEvents`;不改 `MapLayer.loadDynamics`;不新增用例。
验证:`pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts` 与 `pnpm exec vitest run packages-user/data-base/src/map/mapLifecycle.test.ts` 均需全绿(逐条复核 `:142-161` 事件覆盖、`mapLifecycle.test.ts:126-148` 的 `dirty` 语义与三档往返)→ D-44 门禁三步。提交信息:`fix(07-05): #06-09-3 restore the dynamic tile num on load`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/map/saveLoad.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(图块数字未恢复或事件覆盖语义回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `dynamicTile.ts` 的 `loadState` 首行调用 `this.set(save.num)`,签名仍为一参
- `map/saveLoad.test.ts` 的 `restores the tile num on the same instance` 已取消 skip
- `pnpm exec vitest run packages-user/data-base/src/map/saveLoad.test.ts` 全绿
- `pnpm exec vitest run packages-user/data-base/src/map/mapLifecycle.test.ts` 全绿
</acceptance_criteria>
<done>读档恢复 num 且事件语义不变、目标用例取消 skip 并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 3: D-44 门禁、全量套件与未登记缺口登记</name>
<files>packages-user/data-base/src/map/mapLayer.ts, packages-user/data-base/src/map/dynamicTile.ts, packages-user/data-base/src/map/mapLayer.test.ts, packages-user/data-base/src/map/saveLoad.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Validation Architecture 的「既有绿用例不回归」表、§Open Questions F、§WINDOWS.md 收口映射)
</read_first>
<action>
对 4 个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按 4 个文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
账本:`#06-06-1` 与 `#06-09-3` 在 WINDOWS.md 中无对应条目,本计划**不新建条目**(Task 0 已确认),在 SUMMARY 记录对照。
在 SUMMARY 中登记本计划未处置的相邻事项(不得静默丢弃):码 128 的 `$1..$4` 占位符在三个越界分支只传 2 个参数、缺失项渲染为 `[not delivered]`(属既有小瑕疵,需用户决定是否另立条目)。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed"</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对 4 个改动文件 0 错误
- `pnpm exec vue-tsc --noEmit` 按 4 个文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`
- SUMMARY 含码 128 占位符缺口的登记与 findings↔账本对照
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、缺口已登记于 SUMMARY(提交粒度按 D-13 一个缺陷一个原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 地图坐标 → 图块转换 | 坐标来自地图数据/事件,越界坐标必须被守卫拦截并发出语义正确的诊断码 |
| 存档 → 动态图块恢复 | `IDynamicBlockSave` 是外部可篡改输入,每个字段都必须被 `loadState` 消费 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-03 | Tampering | `mapLayer.ts` `transferToDynamic` | low | mitigate | Task 1 使越界转换发码 128(越界语义),恢复诊断码的可解释性与唯一性 |
| T-7-11 | Tampering | `dynamicTile.ts` `loadState` | medium | mitigate | Task 2 补齐 `num` 恢复,避免读档后图块数字与存档不一致(静默数据损失) |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts` 全绿
- `pnpm exec vitest run packages-user/data-base/src/map/mapLifecycle.test.ts` 全绿
- `pnpm exec vitest run packages-user/data-base/src/map/gameMap.test.ts` 全绿(码 131 不回归)
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 4 个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
</verification>
<success_criteria>
1. `#06-06-1`、`#06-09-3` 两条正确预期用例取消 skip 并转绿
2. 码 131 的既有断言与 `setEventLayer` 语义不受影响
3. `mapLifecycle.test.ts` 的 `loadState` 幂等与 `dirty` 语义不回归
4. 两个缺陷各一个原子提交
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-base/src/map/mapLayer.ts` | 越界 `transferToDynamic` 诊断码 131 → 128 |
| 生产源码(修改) | `packages-user/data-base/src/map/dynamicTile.ts` | `loadState` 先 `set(save.num)` |
| 测试(修改) | `packages-user/data-base/src/map/mapLayer.test.ts` | 1 条 skip 取消 |
| 测试(修改) | `packages-user/data-base/src/map/saveLoad.test.ts` | 1 条 skip 取消 |
| 摘要 | `.planning/phases/07-data-fixes/07-05-SUMMARY.md` | 修复记录、码 131 影响面复核、128 占位符缺口登记、门禁结果 |
<output>
Create `.planning/phases/07-data-fixes/07-05-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,233 @@
---
phase: 07-data-fixes
plan: 06
type: execute
wave: 6
depends_on:
- 07-05
files_modified:
- packages-user/data-common/src/common/mover.ts
- packages-user/data-common/src/common/mover.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 22000
raw_tokens: 22000
tasks: 3
confidence: low
must_haves:
truths:
- "取消 skip 后 common/mover.test.ts 的多步后退用例转绿(净位移为 -2、朝向不变、移动方向为反方向)"
- "单步 backward 用例与 forward(2) 用例不回归"
- "IObjectMover.backward 的 jsdoc 与实现语义一致(D-11)"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-common/src/common/mover.ts
provides: "backward 的后退基准不在多步之间摆动;契约注释与实现一致"
key_links:
- "prepareStep 的 Special 分支 ↔ start() 在循环前把 moveDirection 置为 Unknown(mover.ts:687)"
- "IObjectMover.backward 的 jsdoc(mover.ts:309-313)↔ 实现的后退基准方向"
- "getCurrentDirection(mover.ts:464-473)与 prepareStep 注释(mover.ts:475-478)↔ 后退分支的基准说明"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;若用户对 #06-08-1 的期望不同,在 D-09 预执行汇报时裁决"
- "A5:`#06-08-1` 采用「后退基准取 faceDirection 并同步 jsdoc」的路线;备选路线是保留字面契约并新增私有基准方向字段"
prohibitions:
- "不新增测试用例(D-10);只取消 it.skip"
- "不弱化断言,不改写为可跑绿假象"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
- "不修改本计划 files_modified 之外的源码;forward(:579-587)与 backward(:589-597)的入队逻辑、Teleport/Dir/DirFace/Face/AnimDir/Speed 分支一律不动"
---
<objective>
修复移动器(`@user/data-common` L0)的 `#06-08-1`:`ObjectMover` 在 `ObjectMoveType.Special` 的后退分支中以 `getCurrentDirection()` 为基准,而该基准在第 1 步后被自己改写为反方向,导致第 2 步再取反——多步后退方向来回摆动、净位移为零且朝向被翻转。
Purpose: 后退是多步移动的组成步骤(如击退、脚本位移);方向摆动会让整个移动序列与预期位移不符,且在数据端表现为「移动了但回到原点」。
Output: `mover.ts` 后退基准的最小修复 + 契约 jsdoc(连同两处相关注释)与实现一致(D-11)+ `mover.test.ts` 一条 skip 取消 + WINDOWS.md id 22 结清。
执行序:本计划在 07-05(map)之后串行执行;`depends_on` 表示 D-09 逐步用户确认与 D-12c 全量门禁的串行约束,源码层面与本系统无耦合。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/WINDOWS.md
@dev.md
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 #06-08-1 两条路线并获用户裁决</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-common/src/common/mover.ts(:303-313 backward jsdoc、:455-511 getCurrentDirection/prepareStep、:579-597 forward/backward、:680-721 start)
- packages-user/data-common/src/common/mover.test.ts(:130-145 onStepEnd 落点计算、:278-319)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding #06-08-1、§Assumptions Log A5)
</read_first>
<action>
向用户汇报 `#06-08-1` 的根因与两条互斥路线,等待明确裁决后才进入 Task 1。
根因(file:line):`mover.ts:492-503` 的 `Special` 分支无条件用 `getCurrentDirection()` 取基准,后退时把 `moveDirection` 写成 `faceHandler.opposite(dir)`;而 `getCurrentDirection()`(`:467-473`)优先返回 `moveDirection`,于是第 2 步取到刚写入的反方向并再次取反(`start()` 只在循环前把 `moveDirection` 置为 `Unknown`,`:687`)。测试的 `onStepEnd` 按 `moveDirection` 计算落点(`mover.test.ts:138-140`),故该字段必须是本步实际移动方向。
路线 A(研究推荐;后退基准取朝向 + 同步 jsdoc):后退分支改为 `const dir = this.faceDirection; this.moveDirection = this.faceHandler.opposite(dir); this.faceDirection = dir;`;`else`(前进)分支保持既有 `getCurrentDirection()` 语义不变。按 D-11 同步 `IObjectMover.backward` 的 jsdoc(`:309-313`,现为「沿当前移动方向的反方向移动」)为「沿当前朝向的反方向后退」并说明多步后退保持同轴;同步 `getCurrentDirection`(`:464-466`)与 `prepareStep`(`:475-478`)注释措辞。既有测试名 `moves backward against the current face direction` 已与路线 A 一致。
路线 B(保留字面契约):不改 jsdoc 措辞,改为新增一个私有「相对基准方向」字段,在 `start()` 中初始化为 `Unknown`,后退分支读取它而不读取被改写的 `moveDirection`。代价:新增私有状态,`prepareStep` 需在非后退步骤中同步该字段。
请用户二选一。同时说明:既有绿用例 `mover.test.ts:292-304`(单步 backward)与 `:278-289`(forward(2))在两路线下均不回归;全仓 `backward(` 仅命中 `mover.ts` 与 `mover.test.ts`,无其它调用方。
回滚与失败纪律:本计划一个原子提交;若方案失败必须退出本次修改、修订本 PLAN.md 后重新执行(D-09)。
</action>
<decision>`#06-08-1` 的后退基准修法:路线 A(基准取 faceDirection 并同步 jsdoc)还是路线 B(保留字面契约,新增私有相对基准方向字段)</decision>
<context>
现实现把 moveDirection 既当作「本步实际移动方向」又当作「下一步的相对基准」,两者被同一次赋值耦合,导致多步后退方向摆动。路线 A 与既有测试名(moves backward against the current face direction)一致,但需按 D-11 改 jsdoc 措辞;路线 B 保留字面契约但引入新的私有状态。
</context>
<options>
<option id="route-a">
<name>后退基准取 faceDirection,并同步 jsdoc 与两处注释</name>
<pros>不新增状态;与既有测试名一致;契约措辞与实现同步(D-11)</pros>
<cons>修改公共层接口 jsdoc 的历史措辞,属契约文本改动</cons>
</option>
<option id="route-b">
<name>保留 jsdoc 字面契约,新增私有相对基准方向字段</name>
<pros>公共契约文本零改动</pros>
<cons>新增私有状态并需在非后退步骤同步,状态机复杂度上升;prepareStep 面更广</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 route-a / route-b,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 route-a 或 route-b</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户明确选定 A 或 B 路线
- 若选 A,用户确认同步修改 `IObjectMover.backward` 的 jsdoc 措辞属 D-11 范围
</acceptance_criteria>
<done>用户给出明确路线;本计划后续任务按该路线执行</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-08-1 —— 后退基准不摆动,并同步契约注释</name>
<files>packages-user/data-common/src/common/mover.ts, packages-user/data-common/src/common/mover.test.ts</files>
<read_first>
- packages-user/data-common/src/common/mover.ts(:303-313、:455-511、:579-597、:680-721)
- packages-user/data-common/src/common/mover.test.ts(:130-145、:278-319、:321-345)
</read_first>
<action>
按 Task 0 用户选定的路线实施(**只实施选定路线**)。
路线 A:只改 `mover.ts:492-503` 的 `Special` 分支——后退时以 `this.faceDirection` 为基准(`moveDirection = opposite(faceDirection)`、`faceDirection` 保持该朝向),前进时保持 `this.getCurrentDirection()` 的既有写法;随后按 D-11 同步 `:309-313` 的 `backward` jsdoc 与 `:464-466`/`:475-478` 两处注释措辞,使「后退基准 = 当前朝向、多步后退保持同轴」在文档中可见。
路线 B:保留 `:309-313` 的 jsdoc 措辞,新增一个私有基准方向字段(`start()` 中初始化为 `FaceDirection.Unknown`,`prepareStep` 在非后退步骤同步),后退分支读取该字段。字段与初始化点按 dev.md 命名规范(小驼峰、不以下划线开头、显式声明类型)并写有价值的中文注释。
按 D-10 取消 `mover.test.ts:307-319`(`keeps retreating along the same axis across multiple backward steps`)的 skip,断言一字不改,同步 it 前的中文注释。既有 `:292-304`(单步)与 `:278-289`(forward(2))必须保持绿。
不做的事:不改 `forward`/`backward` 的入队逻辑(`:579-597`);不改 `Dir`/`DirFace`/`Face`/`AnimDir`/`Speed` 分支;不改 `start()` 的 `moveDirection` 归一(`:687`);不新增用例。
验证:`pnpm exec vitest run packages-user/data-common/src/common/mover.test.ts` 全绿 → D-44 门禁三步。提交信息:`fix(07-06): #06-08-1 keep backward steps on the same axis`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-common/src/common/mover.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(第 2 步方向再次翻转导致净位移为 0,或单步/forward 既有用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `mover.test.ts` 的 `keeps retreating along the same axis across multiple backward steps` 已取消 skip
- 选路线 A 时:`mover.ts` 后退分支的基准为 `this.faceDirection`,且 `backward` 的 jsdoc 不再写「沿当前移动方向的反方向移动」
- 选路线 B 时:`mover.ts` 新增私有基准方向字段并在 `start()` 中初始化,jsdoc 措辞保持不变
- `pnpm exec vitest run packages-user/data-common/src/common/mover.test.ts` 全绿
</acceptance_criteria>
<done>多步后退保持同轴、契约注释与实现一致、目标用例取消 skip 并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: D-44 门禁、全量套件与 WINDOWS.md id 22 结清</name>
<files>packages-user/data-common/src/common/mover.ts, packages-user/data-common/src/common/mover.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Validation Architecture、§Common Pitfalls 4/5/6/7)
- .planning/WINDOWS.md(id 22 行)
- .planning/phases/06-unit-tests/06-TEST-FINDINGS.md(#06-08-1)
</read_first>
<action>
对两个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按两文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
按 D-14 结清账本:`gsd_run windows fixed 22`(id 22 描述「backward(count>1) 方向摆动、净位移为零(#06-08-1),已 it.skip 待用户确认」)。
在 SUMMARY 中记录:选定路线(A/B)、jsdoc 改动前后措辞、`pnpm test:ci` 计数变化、以及「方案失败即退出并修订计划」的执行结果。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed";或 `windows status` 显示 id 22 仍为 open</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对两文件 0 错误
- `pnpm exec vue-tsc --noEmit` 按两文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`
- WINDOWS.md 中 id 22 状态为 `fixed`
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、id 22 结清并在 SUMMARY 记录(提交粒度按 D-13 一个缺陷一个原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 移动计划队列 → 位移 | 世界坐标由步骤状态机推导;方向状态被步骤自改写会产生错误位移 |
| 契约文档 → 实现 | `IObjectMover.backward` 的 jsdoc 是公共层契约事实源,措辞与实现必须一致 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-12 | Tampering | `mover.ts` `prepareStep` 的 `Special` 后退分支 | low | mitigate | Task 1 使后退基准在步与步之间稳定,净位移与朝向可预期(击退/脚本位移不再归零) |
| T-7-13 | Repudiation | `mover.ts` `IObjectMover.backward` jsdoc | low | mitigate | Task 1 按 D-11 同步契约措辞或改用独立基准字段,避免实现与文档各执一辞 |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-common/src/common/mover.test.ts` 全绿
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 两个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
- WINDOWS.md id 22 为 fixed;`backward` 契约措辞与实现一致
</verification>
<success_criteria>
1. `#06-08-1` 正确预期用例取消 skip 并转绿
2. `mover.test.ts:292-304`(单步 backward)与 `:278-289`(forward(2))不回归
3. `IObjectMover.backward` 的 jsdoc 与实现语义一致(D-11)
4. 单个原子提交
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-common/src/common/mover.ts` | `Special` 后退基准修正 + jsdoc/注释同步(或私有基准字段) |
| 测试(修改) | `packages-user/data-common/src/common/mover.test.ts` | 1 条 skip 取消 |
| 账本 | `.planning/WINDOWS.md` | id 22 → fixed |
| 摘要 | `.planning/phases/07-data-fixes/07-06-SUMMARY.md` | 选定路线、jsdoc 改动、门禁结果、windows 对照 |
<output>
Create `.planning/phases/07-data-fixes/07-06-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,236 @@
---
phase: 07-data-fixes
plan: 07
type: execute
wave: 7
depends_on:
- 07-06
files_modified:
- packages-user/data-state/src/core.ts
- packages-user/data-state/test/saveablesRoundTrip.test.ts
autonomous: false
requirements:
- FIX-01
estimate:
tokens: 26000
raw_tokens: 26000
tasks: 3
confidence: low
must_haves:
truths:
- "取消 skip 后 saveablesRoundTrip.test.ts 的「存档含未注册 key 触发 178」用例转绿"
- "缺 key 路径只触发 177,不再触发 178(178 与 177 语义相互独立)"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
artifacts:
- path: packages-user/data-state/src/core.ts
provides: "码 178 = 存档中出现但未加载(未注册)的 key,与码表文案一致"
- path: packages-user/data-state/test/saveablesRoundTrip.test.ts
provides: "177/178 两条诊断码各自独立的回归见证"
key_links:
- "core.ts:533 的差集方向 ↔ packages/common/src/logger.json 的码 178 文案(D-05 指定文案为事实源)"
- "core.ts:522-527 的 177 分支 ↔ 178 分支(两者必须互不重叠)"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;若用户对 #06-09-5 的期望不同,在 D-09 预执行汇报时裁决"
- "A4:`#06-09-5` 的既有非 skip 用例 `:322-334` 必须随 D-05 修正;推荐改写为「缺 key 不再触发 178」的负向断言以保留其独立见证价值,避免与 `:337-349` 完全重复"
prohibitions:
- "不新增测试用例(D-10);`:322-334` 属 D-05 授权的既有断言纠偏"
- "不弱化断言,不改写为可跑绿假象"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
- "不修改本计划 files_modified 之外的源码;`core.ts` 的 saveables 注册、177 分支与 `saveState` 一律不动"
---
<objective>
修复存档容器(`@user/data-state` L3)的 `#06-09-5`:`CoreState.loadState` 的码 178 判定方向与码表文案相反——现为 `total.difference(loaded)`(已注册但存档缺失,与 177 重叠),应改为 `loaded.difference(total)`(存档中出现但未加载),使 177/178 恢复各自的独立诊断意义。
Purpose: 存档版本不一致或手工注入未注册 key 时,独立的 178 是唯一的可观测信号;方向相反会让「缺 key」重复告警而「多出 key」静默通过。
Output: `core.ts:533` 单行差集方向修正 + 1 条 skip 取消 + 1 条既有非 skip 用例按 D-05 纠偏 + 文档/注释一致性核对。
执行序:本计划在 07-06(flag+common)之后串行执行;`depends_on` 表示 D-09 逐步用户确认与 D-12c 全量门禁的串行约束。`core.ts` 亦为 `#06-07-1` 的用户接线文件,故 07-08 必须在本计划之后(见 07-08 的 `depends_on`)。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@dev.md
@packages/common/src/logger.json
@packages-user/data-state/test/saveablesRoundTrip.test.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-09 预执行汇报 —— 汇报 #06-09-5 修法与 :322-334 纠偏方案</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-state/src/core.ts(:509-545 saveState/loadState)
- packages/common/src/logger.json(码 177 与 178 文案)
- packages-user/data-state/test/saveablesRoundTrip.test.ts(:306-349)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding #06-09-5、§Assumptions Log A4)
</read_first>
<action>
向用户汇报 `#06-09-5` 的修法与既有用例纠偏方案,等待明确确认后才进入 Task 1。
① 根因(file:line):`core.ts:531-537` 用 `const remain = total.difference(loaded);`(`total` = 已注册 saveables,`loaded` = 存档 keys),得到「已注册但存档缺失」——这正是 177 分支(`:522-527`)的语义;码 178 的文案为「存档中出现但未加载」,对应 `loaded.difference(total)`。因此现实现下「缺 key」同时触发 177 与 178,「多出 key」不告警。
② 修法(D-05,改实现对齐文案):把 `:533` 改为 `const remain = loaded.difference(total);`,其余不变(`logger.warn(178, ids)` 与 `:534-537` 的结构保持)。`Set.prototype.difference` 已在用,环境可用。
③ **既有非 skip 用例必须同步纠偏(D-05 已授权,D-09 须点名)**:`saveablesRoundTrip.test.ts:322-334`(`warns code 178 when the save data misses a saveable key`)修后必红(缺 key 时 `loaded ⊆ total`,178 不再触发)。两个可选处置:
- **推荐(负向见证)**:把 `:322-334` 改写为「缺 key 不再触发 178」的负向断言(同时断言仍触发 177),并更名;`:337-349` 的 skip 取消,承担「多出 key 触发 178」的正向见证。两条用例各司其职、互不重复,且是 D-05 语义的强回归护栏。
- **备选(去重)**:把 `:322-334` 的用例体替换为多 key 版本后删除,仅保留取消 skip 后的 `:337-349`(一条用例)。代价:少一条 177/178 互斥的显式护栏。
④ 文档一致性(D-11):核对 `core.ts:518-537` 附近是否有与 178 语义相关的注释;如无需改动则不新增注释(不得为改而改)。
⑤ 账本事实:`#06-09-5` 在 WINDOWS.md 中无对应条目,修复后无 `fixed` 可打;按研究建议不新建条目,改为在本计划 SUMMARY 记录对照。
⑥ 回滚与失败纪律:本计划一个原子提交;若方案失败必须退出本次修改、修订本 PLAN.md 后重新执行(D-09)。
</action>
<decision>码 178 判定改为 `loaded.difference(total)` 后,既有非 skip 用例 `:322-334` 的纠偏方式:负向见证改写还是去重删除</decision>
<context>
D-05 已授权同步更新 `:322-334`(它固化了「缺 key 触发 178」的错误语义)。若直接改写为多 key 版本,它将与取消 skip 后的 `:337-349` 完全重复;负向见证改写可同时守住 177/178 的语义互斥,保留两条用例各自的价值。
</context>
<options>
<option id="negative-guard">
<name>`:322-334` 改写为负向见证并更名;`:337-349` 取消 skip</name>
<pros>两条用例各司其职(缺 key 不触发 178 / 多出 key 触发 178),是 D-05 语义的强护栏;不产生重复用例</pros>
<cons>用例语义发生变化,需同步更名与注释,便于后续人工审查</cons>
</option>
<option id="dedupe">
<name>`:322-334` 替换为多 key 版本后去重删除,仅取消 `:337-349` 的 skip</name>
<pros>用例数严格不增</pros>
<cons>失去 177/178 互斥的显式护栏;删除既有用例的幅度大于「纠偏断言」</cons>
</option>
<option id="revise">
<name>修订方案后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 negative-guard / dedupe,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 negative-guard 或 dedupe</human-check>
<fails_when>用户回复 revise,或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- 用户明确 `:322-334` 的处置方式(负向见证改写 / 去重删除)
- 用户确认码 178 判定改为 `loaded.difference(total)` 与码表文案一致
</acceptance_criteria>
<done>用户给出明确选项;本计划后续任务按该选项执行</done>
</task>
<task type="auto">
<name>Task 1: 修复 #06-09-5 —— 码 178 判定改为存档多出的 key,并纠偏既有用例</name>
<files>packages-user/data-state/src/core.ts, packages-user/data-state/test/saveablesRoundTrip.test.ts</files>
<read_first>
- packages-user/data-state/src/core.ts(:490-545)
- packages/common/src/logger.json(码 177 与 178 文案)
- packages-user/data-state/test/saveablesRoundTrip.test.ts(:306-349、:352-384 容器往返既有绿用例)
</read_first>
<action>
在 `core.ts:531-537` 把差集方向改为 `const remain = loaded.difference(total);`(`loaded` 来自 `new Set(state.keys())`,`total` 来自 `new Set(this.saveables.keys())`),其余判空、`join(' | ')` 与 `logger.warn(178, ids)` 逻辑保持。**不得**改动 `:522-527` 的 177 分支。
测试按 Task 0 选定方案执行:
- 方案 `negative-guard`:把 `saveablesRoundTrip.test.ts:322-334` 改写为「缺 key 不触发 178、仍触发 177」的断言(`expect(...).toContain(177)` 与 `expect(...).not.toContain(178)`),用例更名以反映该语义,并同步其前的中文单行注释;随后把 `:337-349` 的 `it.skip` 改为 `it`(断言一字不改),同步其注释。
- 方案 `dedupe`:删除 `:322-334` 整条用例(含其上方注释),并把 `:337-349` 的 `it.skip` 改为 `it`(断言一字不改);在该位置补一条中性中文说明,指出该用例覆盖「存档含未注册 key」触发 178。
两种方案共同约束:既有绿用例 `:307-319`(177)必须保持绿;不得新增任何用例;不得弱化任何断言。
不做的事:不改 `saveState`、saveables 注册、`bindSaveableExecuter` 或 112/113 分支;不改 `:352-384` 的容器往返用例;不新增用例。
验证:`pnpm exec vitest run packages-user/data-state/test/saveablesRoundTrip.test.ts` 全绿 → D-44 门禁三步。提交信息:`fix(07-07): #06-09-5 warn code 178 for unloaded save keys`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-state/test/saveablesRoundTrip.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(178 仍由缺 key 触发,或 177 用例回归),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `core.ts` 中存在 `const remain = loaded.difference(total);`,且不再存在 `total.difference(loaded)`
- `saveablesRoundTrip.test.ts` 的 `warns code 178 when the save data has keys that are not loaded` 已取消 skip
- 方案 `negative-guard` 时,文件内同时存在 177 的正向断言与 178 的负向断言;方案 `dedupe` 时,原 `:322-334` 用例已不存在
- `pnpm exec vitest run packages-user/data-state/test/saveablesRoundTrip.test.ts` 全绿
</acceptance_criteria>
<done>178 语义与码表一致、177/178 互不重叠、目标用例取消 skip 并转绿;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: D-44 门禁、全量套件与 findings↔账本对照登记</name>
<files>packages-user/data-state/src/core.ts, packages-user/data-state/test/saveablesRoundTrip.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Validation Architecture、§Common Pitfalls 1/4/5/6/7、§WINDOWS.md 收口映射)
</read_first>
<action>
对两个改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按两文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
账本:`#06-09-5` 在 WINDOWS.md 中无对应条目,本计划**不新建条目**(Task 0 已确认),在 SUMMARY 记录对照。
在 SUMMARY 中记录:采用的纠偏方案(`negative-guard` / `dedupe`)、177/178 的语义切分说明、`pnpm test:ci` 计数变化,以及「若方案失败即退出并修订计划」的执行结果。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed"</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对两文件 0 错误
- `pnpm exec vue-tsc --noEmit` 按两文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`
- SUMMARY 含纠偏方案、177/178 语义切分与 findings↔账本对照
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、对照已登记于 SUMMARY(提交粒度按 D-13 一个缺陷一个原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 存档数据 → 容器加载 | 存档是外部可篡改/版本错配输入;未注册 key 的唯一可观测信号是诊断码 178 |
| 诊断码 → 排障语义 | 177 与 178 必须互不重叠,否则「版本不一致」与「手工注入」不可区分 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-04 | Tampering / Repudiation | `core.ts` `loadState` 178 判定 | low | mitigate | Task 1 修正差集方向,使 178 恢复「存档出现但未加载」的独立诊断意义(缺 key 由 177 承担) |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `pnpm exec vitest run packages-user/data-state/test/saveablesRoundTrip.test.ts` 全绿
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 两个改动文件 eslint 0 错误、vue-tsc 过滤后 0 类型错误
- 177 用例保持绿;178 由「多出 key」触发
</verification>
<success_criteria>
1. `#06-09-5` 正确预期用例取消 skip 并转绿
2. `:322-334` 既有断言按 D-05 纠偏(负向见证或去重),177 用例不回归
3. 码 178 判定与 `logger.json` 文案一致(D-05/D-11)
4. 单个原子提交
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 生产源码(修改) | `packages-user/data-state/src/core.ts` | 码 178 差集方向修正 |
| 测试(修改) | `packages-user/data-state/test/saveablesRoundTrip.test.ts` | 1 条 skip 取消 + 1 条既有断言纠偏 |
| 摘要 | `.planning/phases/07-data-fixes/07-07-SUMMARY.md` | 纠偏方案、177/178 语义切分、门禁结果 |
<output>
Create `.planning/phases/07-data-fixes/07-07-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,236 @@
---
phase: 07-data-fixes
plan: 08
type: execute
wave: 8
depends_on:
- 07-07
files_modified:
- packages-user/data-state/test/replayPlayback.test.ts
autonomous: false
requirements:
- FIX-01
user_setup:
- service: "本地开发环境(无外部服务)"
why: "D-07 规定 #06-07-1 的接线由用户亲自完成:CoreState 必须向 pathfinding.finder 注入 useMapState/useMapLayer/usePassPredicate"
env_vars: []
dashboard_config:
- task: "在 packages-user/data-state/src/core.ts 中、地图可用之后(initMapState 之后或 loading.once('loaded') 钩子内)调用 state.pathfinding.finder.useMapState(this.maps)、useMapLayer(<当前 eventLayer>)、usePassPredicate(new DefaultPassPredicateImpl(this.maps))"
location: "packages-user/data-state/src/core.ts(装配点参考 core.ts:248-264;等价注入逐字见 data-state/test/replayPlayback.test.ts:108-114);AI 不实现该接线"
estimate:
tokens: 16000
raw_tokens: 16000
tasks: 3
confidence: low
must_haves:
truths:
- "用户完成 CoreState → finder 注入后,取消 skip 的顶层录像瞬移用例转绿"
- "顶层录像瞬移不再因 maps/layer 未注入而告警 173 并返回空路径(不再下发错误码 2005)"
- "pnpm test:ci 全绿,且本计划不新增任何 it.skip"
- "AI 未修改任何生产源码(D-07)"
artifacts:
- path: packages-user/data-state/test/replayPlayback.test.ts
provides: "顶层录像瞬移回归见证(取消 skip 后覆盖真实 finder 装配)"
key_links:
- "data-state/src/core.ts 的 finder 注入点 ↔ data-system/src/path/finder.ts:117-131 的注入 API ↔ data-state/src/hero/predicate.ts 的 DefaultPassPredicateImpl"
- "finder.find 在 maps/layer 为 null 时的告警 173 ↔ replayPlayback.test.ts:362-375 的期望行为"
assumptions:
- "FIX-01 探针未分类:本计划以显式假设承接,即 FIX-01 = 「登记缺陷的正确预期 skip 用例转绿且不新增跳过」;#06-07-1 属 D-07 的用户负责项,AI 只在其后验证"
- "用户接线属一次性注入;楼层切换时事件层会变化(gameMap.ts:146/152),是否需在切层时重新注入由用户设计决定,AI 不预设、不评价"
prohibitions:
- "AI 不修改 packages-user/data-state/src/core.ts 或任何生产源码(D-07);失败时退出并汇报,不得自行另辟他法"
- "不新增测试用例(D-10);只取消 it.skip"
- "不弱化断言,不改写为可跑绿假象"
- "不修改渲染端 @user/client-* 与 legacy 渲染接线"
- "不引入新依赖、不新建文件"
---
<objective>
处置 path 系统的 `#06-07-1`(D-07 用户负责):`CoreState` 未向 `pathfinding.finder` 注入 `useMapState`/`useMapLayer`/`usePassPredicate`,导致顶层录像瞬移恒因 `maps`/`layer` 为 `null` 告警 173 并返回空路径,进而下发错误码 2005。用户完成接线后,由 AI 取消 `replayPlayback.test.ts:362-375` 的 skip 并验证转绿;AI **不实现**该接线。
Purpose: 这是 20 条中唯一的装配缺口(L3 顶层接线),属用户设计的接口装配范畴;完成它才能让顶层录像瞬移链路真实可用。
Output: 用户接线完成的事实确认 + 顶层录像瞬移用例取消 skip 并转绿 + 未新建账本条目的处置记录。
执行序:本计划在 07-07(save)之后串行执行。`depends_on: 07-07` 是**真实**依赖——`core.ts` 同时是本计划的前置修改目标(07-07 改其 `loadState` 的 178 判定)与用户接线文件,两者必须串行以避免对同一文件并发编辑。
</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/phases/07-data-fixes/07-CONTEXT.md
@.planning/phases/07-data-fixes/07-RESEARCH.md
@.planning/phases/07-data-fixes/07-PATTERNS.md
@.planning/phases/07-data-fixes/07-VALIDATION.md
@.planning/phases/06-unit-tests/06-TEST-FINDINGS.md
@.planning/WINDOWS.md
@dev.md
@packages-user/data-system/src/path/finder.ts
@packages-user/data-state/src/hero/predicate.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: D-07/D-09 确认 —— 用户已完成后端接线,并确认取消 skip</name>
<files>(只读确认,不修改任何文件)</files>
<read_first>
- packages-user/data-state/src/core.ts(:240-270 装配区域;核对是否已出现 useMapState/useMapLayer/usePassPredicate)
- packages-user/data-state/test/replayPlayback.test.ts(:100-120 等价注入逐字、:355-380 目标 skip)
- packages-user/data-system/src/path/finder.ts(:98-131 注入 API、:205-233 maps/layer 为 null 时的告警 173)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§Per-Finding #06-07-1、§WINDOWS.md 收口映射)
</read_first>
<action>
执行前置:先用只读方式核对用户是否已在 `core.ts` 的装配区域调用 `state.pathfinding.finder.useMapState(...)`、`useMapLayer(...)`、`usePassPredicate(...)`。若未出现,**必须停止本计划**并把缺失项与接线依据(`replayPlayback.test.ts:108-114` 的等价注入)汇报给用户,等待用户完成后再重新执行本计划;不得由 AI 代改 `core.ts`(D-07)。
若已接线,向用户汇报以下内容并等待确认:
① 接线事实:`core.ts` 中三处注入的调用位置与实参(`this.maps` / 具体 `eventLayer` / `new DefaultPassPredicateImpl(this.maps)`);注入时机是否在地图可用之后(`initMapState` 之后或 `loaded` 钩子内)。
② 预期效果:`finder.find` 在 `maps`/`layer` 均可用时不再进入告警 173 早退分支,顶层录像瞬移可解析出路径,不再下发 2005。
③ 验证方式:取消 `replayPlayback.test.ts:362-375`(`plays a teleport step without manual finder wiring`)的 skip,断言一字不改,运行该文件确认转绿。
④ 已知设计边界(不作评价,仅陈述):`useMapLayer` 传入的是某个 `IGameMap.eventLayer`,而楼层切换时事件层会变化(`gameMap.ts:146/152`);若注入是一次性的,是否在切层时重新注入由用户决定(属用户设计范畴)。
⑤ 失败纪律:若取消 skip 后用例仍红,AI 必须**退出本计划**、如实汇报观测到的告警码与路径结果,并等待用户修订接线或修订本计划;**不得**自行修改 `core.ts`、不得改断言(D-07/D-09)。
⑥ 账本事实:`#06-07-1` 在 WINDOWS.md 中无对应条目,D-14 的 waive 没有可 waive 的对象;研究建议不新建条目(避免抬高 `open_count` 门禁),改为在本计划 SUMMARY 记录用户负责项与验证结果。请用户确认。
</action>
<decision>用户是否已完成 `CoreState` → `pathfinding.finder` 的三处注入(D-07),以及是否确认取消 `replayPlayback.test.ts:362-375` 的 skip 验证</decision>
<context>
`#06-07-1` 是唯一的顶层装配缺口,D-07 明确规定接线由用户完成、AI 只做接线后的验证。若接线尚未完成,本计划无法执行也必须停止——AI 不得代改 core.ts。接线采用一次性注入还是切层重注入,由用户设计决定。
</context>
<options>
<option id="wired-approve">
<name>接线已完成,确认取消 skip 验证,不新建账本条目</name>
<pros>AI 只在既有边界内验证;账本 open_count 不变</pros>
<cons>#06-07-1 的处置只出现在 SUMMARY,不进入跨阶段账本</cons>
</option>
<option id="not-wired">
<name>用户尚未接线 —— 停止本计划</name>
<pros>严格遵守 D-07,不越界修改生产代码</pros>
<cons>本计划需等待用户完成后重新执行;Phase 7 收尾延后</cons>
</option>
<option id="new-window-entry">
<name>接线已完成,并为 `#06-07-1` 新建账本条目并 waive</name>
<pros>用户负责项进入跨阶段账本,后续阶段可见</pros>
<cons>抬高 open_count,可能阻塞 /gsd-ship 的 windows_enforce 门禁</cons>
</option>
<option id="revise">
<name>接线方案或验证方式需修订后再执行</name>
<pros>避免带着错误方案落地</pros>
<cons>退出本计划并修订 PLAN.md 后重新执行</cons>
</option>
</options>
<resume-signal>回复 wired-approve / not-wired(并说明后重新执行)/ new-window-entry,或给出修订意见</resume-signal>
<verify>
<human-check>用户明确回复 wired-approve、not-wired 或 new-window-entry</human-check>
<fails_when>用户回复 not-wired 或 revise;或未回复即继续 —— 必须停止执行</fails_when>
</verify>
<acceptance_criteria>
- `core.ts` 的装配区域存在 `useMapState`、`useMapLayer`、`usePassPredicate` 三处调用(源码断言)
- 用户明确确认取消 skip 与账本条目处置
</acceptance_criteria>
<done>用户确认为 wired-approve 或 new-window-entry,且 `core.ts` 三处注入在源码中可见</done>
</task>
<task type="auto">
<name>Task 1: 取消 #06-07-1 的 skip 并验证顶层录像瞬移转绿</name>
<files>packages-user/data-state/test/replayPlayback.test.ts</files>
<read_first>
- packages-user/data-state/test/replayPlayback.test.ts(:100-120 等价注入、:355-380 目标 skip、文件内其余既有绿用例)
- packages-user/data-state/src/core.ts(:240-270 用户接线后的装配)
</read_first>
<action>
把 `replayPlayback.test.ts:362-375`(`plays a teleport step without manual finder wiring`)的 `it.skip` 改为 `it`,断言一字不改,同步 it 前的中文注释(去掉「疑似 bug/待修复」措辞,改为中性描述该用例覆盖真实 finder 装配)。
不改 `:108-114` 的 `wireFinder` 等价注入逻辑(该用例正是以 `wireFinder = false` 区分真实装配与测试注入);不改该文件任何其它用例;不新增用例;不改任何生产源码。
若用例仍红:立即停止并按 Task 0 的第 ⑤ 条退出汇报(记录实际告警码、找到的路径与首个分歧),不得改断言、不得改 `core.ts`。
验证:`pnpm exec vitest run packages-user/data-state/test/replayPlayback.test.ts` 全绿 → D-44 门禁三步(本计划仅一个测试文件)。提交信息:`test(07-08): #06-07-1 verify the top-level teleport with the wired finder`。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-state/test/replayPlayback.test.ts</automated>
<fails_when>非零退出,或摘要行出现 "1 failed" / "failed"(仍告警 173 / 路径为空 / 下发 2005),或输出出现 "no tests found"</fails_when>
</verify>
<acceptance_criteria>
- `replayPlayback.test.ts` 的 `plays a teleport step without manual finder wiring` 不再以 `it.skip` 存在(源码断言:该行不含 `.skip`)
- `pnpm exec vitest run packages-user/data-state/test/replayPlayback.test.ts` 全绿
- 本计划未修改任何生产源码(以本计划提交 sha 核验:`git show --name-only <本计划提交 sha>` 仅含该测试文件与 .planning 文档)
</acceptance_criteria>
<done>用例取消 skip 并转绿,且未触碰任何生产源码;已一个原子提交入库</done>
</task>
<task type="auto">
<name>Task 2: D-44 门禁、全量套件与用户负责项处置登记</name>
<files>packages-user/data-state/test/replayPlayback.test.ts</files>
<read_first>
- .planning/phases/07-data-fixes/07-VALIDATION.md(§Manual-Only Verifications 的 #06-07-1 行)
- .planning/phases/07-data-fixes/07-RESEARCH.md(§WINDOWS.md 收口映射「需澄清」、§Open Questions)
</read_first>
<action>
对唯一改动文件串行执行 D-12(沿用 D-44 的文件级判定)三步门禁:`pnpm exec eslint --fix` 后 `pnpm exec eslint` 0 错误;`pnpm exec vue-tsc --noEmit` 后按该文件相对路径过滤输出 0 类型错误(不以整仓退出码判定);`pnpm test:ci` 全绿。
账本:`#06-07-1` 无对应条目,按 Task 0 的用户确认处置(默认不新建);若用户选择 `new-window-entry`,按用户指示执行 `windows` 的 append/waive 流程并在 SUMMARY 记录 `open_count` 变化。
在 SUMMARY 中记录:用户接线的事实(三处注入的调用位置与实参)、验证命令与结果、用户负责项的边界说明(一次性注入 vs 切层重注入由用户决定)、以及 D-14 的账本处置结论。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>非零退出,或摘要行出现 "failed"</fails_when>
</verify>
<acceptance_criteria>
- `pnpm exec eslint` 对该文件 0 错误
- `pnpm exec vue-tsc --noEmit` 按该文件路径过滤后无命中
- `pnpm test:ci` 全绿,且本计划未新增 `it.skip`
- SUMMARY 含用户接线事实、验证结果与账本处置结论
</acceptance_criteria>
<done>D-44 三步全过、全量套件绿、用户负责项处置已登记于 SUMMARY(提交粒度按 D-13 一个缺陷一个原子提交)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 顶层装配 → finder | `maps`/`layer`/谓词未注入时 finder 返回空路径并依赖诊断码上报,属装配缺陷而非安全边界 |
| 用户代码 → AI 范围 | D-07 明确接线属用户,AI 越界即违反协作边界 |
| 包管理器 → 仓库 | 本阶段无安装动作 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-14 | DoS(功能性) | `path/finder.ts` 未注入时的早退路径(告警 173) | low | accept | 接线由用户完成(D-07);AI 只在接线后取消 skip 验证,失败即退出汇报,不修改生产代码 |
| T-7-SC | Tampering | npm/pip/cargo 安装 | high | accept | 本阶段不安装任何包(`package.json`/`pnpm-lock.yaml` 不变);若执行中出现安装需求,立即停止并汇报 |
</threat_model>
<verification>
- `core.ts` 中存在 `useMapState`/`useMapLayer`/`usePassPredicate` 三处注入(用户产出,源码断言)
- `pnpm exec vitest run packages-user/data-state/test/replayPlayback.test.ts` 全绿
- `pnpm test:ci` 全绿且本计划不新增 `it.skip`
- 改动文件仅 `replayPlayback.test.ts`(AI 未触碰生产源码)
</verification>
<success_criteria>
1. 用户完成 `CoreState` → `finder` 注入(D-07)
2. `#06-07-1` 正确预期用例取消 skip 并转绿
3. AI 未修改任何生产源码;失败时按 D-09 退出并汇报
4. 用户负责项与账本处置在 SUMMARY 登记
</success_criteria>
## Artifacts this phase produces
| 类型 | 路径 | 内容 |
|------|------|------|
| 用户产出(AI 不改) | `packages-user/data-state/src/core.ts` | `pathfinding.finder` 的三处注入 |
| 测试(修改) | `packages-user/data-state/test/replayPlayback.test.ts` | 1 条 skip 取消 |
| 摘要 | `.planning/phases/07-data-fixes/07-08-SUMMARY.md` | 用户接线事实、验证结果、账本处置 |
<output>
Create `.planning/phases/07-data-fixes/07-08-SUMMARY.md` when done
</output>

View File

@ -0,0 +1,920 @@
# Phase 7: 数据端缺陷修复 - Pattern Map
**Mapped:** 2026-09-15
**Files analyzed:** 13 生产文件(全部已存在,修改)+ 14 测试文件(取消 skip)+ 1 码表复核(`logger.json`,预计不改)
**Analogs found:** 13 / 13 生产文件均有同文件/同层既有正确范式
> 本阶段为**缺陷修复**:不存在新建文件。每条「Analog」指向**同文件内的正确兄弟分支**或**同层已实现正确语义的兄弟方法/类**。所有下列路径均已 `git ls-files` 验证为 **tracked source**(无 gitignored 镜像路径)。
>
> 关键约束(D-10):测试改动**仅限** `it.skip` → `it`(+ 本研究点名的既有绿用例断言纠偏),不新增用例、不弱化断言。
## File Classification
### 生产源码(修改)
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `packages-user/data-system/src/combat/damage.ts` | system / service | transform(二分搜索) | 同文件 `findNextCritical`(`:206-242`)的相邻分支 + 消费点 `calculateCritical`(`:160-193`) | exact(同方法内) |
| `packages-user/data-system/src/combat/mapDamage.ts` | system / service | event-driven(键控 Map 源→点位反向索引) | 同文件 `addMapDamage`(`:104-112`)+ `refreshEnemy`(`:304-333`);跨文件 `context.ts` `setEnemyAt`/`deleteEnemyAt`(`:256-299`) | role-match(同层同数据流) |
| `packages-user/data-system/src/combat/combat.ts` | system / service | request-response(async 流程) | 同文件 `combatFlow`(`:164-194`);契约事实源 `combat/types.ts:767-779` | exact(同方法内) |
| `packages-user/data-system/src/combat/context.ts` | system / service | event-driven(构建流水线) | 同文件 `refreshEnemy`(`:780-784`,`view.reset()`)+ `enemy.ts:15-17` | exact |
| `packages-user/data-base/src/enemy/manager.ts` | model / registry | CRUD(Map 查找 + clone) | 同文件 `internalGetPrefab`(`:131-139`)+ `getPrefab`/`getPrefabById`(`:165-173`) | exact |
| `packages-user/data-common/src/replay/array.ts` | utility(编解码器) | transform / binary I-O | 同文件 `decodeParamList`(`:663-677`)、`rebuildIndexArray`(`:749-767`)、`createReadStream`(`:708-743`)、`add`(`:392-410`) | exact(同文件既有正确范式) |
| `packages-user/data-base/src/hero/attribute.ts` | model | CRUD(属性重算) | 同文件 `catchCalculateProgress`(`:100-117`)与 `recalculateAttribute` 有修饰器分支(`:84-97`) | exact |
| `packages-user/data-base/src/hero/equipment.ts` | model | CRUD(装备槽) | `getCouldEquipSlot`(`:134-150`)自身;深拷贝兄弟 `equipStore.ts:67-74` | exact(同文件)+ role-match |
| `packages-user/data-base/src/hero/equipStore.ts` | model / store | CRUD + save-load | 同文件 `saveDiff`(`:80-102`,基准 `item.equip`)与 `saveNoCompression`(`:67-74`)、构造器 `:34-36` | exact |
| `packages-user/data-base/src/map/mapLayer.ts` | model | request-response(越界守卫) | 同文件 `transferToStatic`(`:460-478`)、`transferToStaticIfSafe`(`:480-496`) | exact |
| `packages-user/data-base/src/map/dynamicTile.ts` | model | CRUD + save-load | 同文件 `set`(`:56-66`)+ `saveState`(`:102-116`) | exact |
| `packages-user/data-common/src/common/mover.ts` | utility | event-driven(步骤状态机) | 同文件 `getCurrentDirection`(`:464-473`)、`prepareStep`(`:479-511`)、`forward`/`backward`(`:579-597`) | exact |
| `packages-user/data-state/src/core.ts` | store / container | batch(Set 差集诊断) | 同文件 `loadState` 的 177 分支(`:522-527`)+ `Set.difference` 既有用法(`:533`) | exact |
> `packages-user/data-system/src/path/system.ts`(#06-07-1)与 `data-state/src/core.ts` 的 finder 注入**由用户负责(D-07)**,AI 不修改;见「用户负责项」。
### 测试源码(取消 `it.skip` 转绿)
| Test File | 关联缺陷 | 目标用例(文件:行) | 备注 |
|-----------|---------|---------------------|------|
| `data-system/src/combat/damage.test.ts` | #06-01-1 | `:579-594` | 无既有断言纠偏 |
| `data-system/src/combat/damage.test.ts` | #06-01-4 | `:677-718` | 无 |
| `data-system/src/combat/mapDamage.test.ts` | #06-01-2 | `:532-545` | 无 |
| `data-system/src/combat/combat.test.ts` | #06-01-3 | `:483-501` | **另有 3 条既有绿用例须纠偏**(见下) |
| `data-system/src/combat/context.test.ts` | #06-15-1 | `:625-663` | 无 |
| `data-base/src/enemy/manager.test.ts` | #06-03-1 | `:272-279`、`:282-343` | 无 |
| `data-common/src/replay/array.test.ts` | #06-04-1 | `:287-291` | 无 |
| `data-common/src/replay/array.test.ts` | #06-04-2 | `:278-284`、`:294-304` | 无 |
| `data-common/src/replay/array.test.ts` | #06-04-3 | `:211-221`、`:511-520` | 无 |
| `data-common/src/replay/array.test.ts` | #06-04-4 | `:498-508`、`:523-539` | 无 |
| `data-base/src/hero/attribute.test.ts` | #06-05-1 | `:115-121` | 无 |
| `data-base/src/hero/equipment.test.ts` | #06-05-2 | `:290-303` | 无 |
| `data-base/src/hero/equipment.test.ts` | #06-05-3 | `:306-315` | **保留 skip,仅加中文注释(D-06)** |
| `data-base/src/hero/saveLoad.test.ts` | #06-09-1 | `:337-341`、`:344-348`、`:351-366`、`:369-381` | 无 |
| `data-base/src/hero/saveLoad.test.ts` | #06-09-2 | `:312-325`、`:589-609` | 无 |
| `data-base/src/map/saveLoad.test.ts` | #06-09-3 | `:164-173` | 无 |
| `data-base/src/map/mapLayer.test.ts` | #06-06-1 | `:419-427` | 无 |
| `data-common/src/common/mover.test.ts` | #06-08-1 | `:307-319` | **须同步 jsdoc(D-11)** |
| `data-state/test/saveablesRoundTrip.test.ts` | #06-09-5 | `:337-349` | **既有 `:322-334` 须纠偏/去重(D-05)** |
| `data-state/test/replayPlayback.test.ts` | #06-07-1 | `:362-375` | 用户接线后取消 skip |
---
## Pattern Assignments
### `packages-user/data-system/src/combat/damage.ts` (#06-01-1,controller→system, transform)
**Analog:** 同文件 `findNextCritical` 的相邻分支 + 消费点 `calculateCritical`。
**根因定位**(`:229-234`):`targetInfo` 被写在 `damage >= referenceDamage` 的「非临界点」分支,导致 `info` 永远停在 `upperLimit` 处的计算结果。
**消费点契约**(`:182-189`)——修复后必须满足的语义(`info` 与 `nextValue` 同源):
```ts
yield {
nextValue: next.value,
baseValue: currentValue,
nextDiff: next.value - currentValue,
baseInfo: currentInfo,
info: next.info,
damageDiff: next.info.damage - currentInfo.damage
};
```
**要照抄的修复形态**(把赋值移入 `<` 分支,`else` 只留 `left = middle`):
```ts
if (middleInfo.damage < referenceDamage) {
right = middle;
targetInfo = middleInfo;
} else {
left = middle;
}
```
**错误处理**:本方法无 `logger` 调用;保持既有 `this.calculator!` 非空断言与 `if (targetInfo.damage >= referenceDamage) return null;`(`:221`)早退不变。
**测试**:`damage.test.ts:579-594` 由 `it.skip` → `it`。
---
### `packages-user/data-system/src/combat/mapDamage.ts` (#06-01-2,system, event-driven)
**Analog(同文件正确范式):** `addMapDamage`(`:104-112`,`getOrInsertComputed` 建点位)与 `refreshEnemy`(`:304-333`,`point.affectedBy.add(viewItem); point.damages.add(damage)`)。**兄弟类范式:** `context.ts` 的 `setEnemyAt`/`deleteEnemyAt`(`:256-299`)——「写入时登记双向 Map,删除时同步清理」的既有正确写法。
**既有正确写法(context.ts `setEnemyAt`,`:284-289`)——「登记所有映射」的范式:**
```ts
const view = new EnemyView<TEnemy>(enemy, this);
this.enemyMap.set(index, enemy);
this.enemyViewMap.set(index, view);
this.locatorEnemyMap.set(enemy, index);
this.locatorViewMap.set(view, index);
this.computedToView.set(view.getComputingEnemy(), view);
```
**既有正确写法(context.ts `deleteEnemyAt`,`:269-277`)——「反向清理」的范式:**
```ts
this.needTotallyRefresh.delete(view);
this.dirtyEnemy.delete(view);
this.requestedCommonContext.delete(view);
this.computedToView.delete(view.getComputingEnemy());
this.enemyViewMap.delete(index);
this.enemyMap.delete(index);
this.locatorViewMap.delete(view);
this.locatorEnemyMap.delete(view);
```
**同文件 `getOrInsertComputed` 建点位范式(`:319-325`):**
```ts
const point = this.sourcedDamage.getOrInsertComputed(
index,
() => ({
affectedBy: new Set(),
damages: new Set()
})
);
const damage = viewItem.getDamageWithoutCheck(loc);
if (damage) {
point.affectedBy.add(viewItem);
point.damages.add(damage);
}
```
**两条候选修法(D-09 必须二选一汇报):**
- **方案 A(补齐 store 写入,推荐)**:在上述 `if (damage)` 分支内增加登记;`refreshIndex`(`:356-378`)重建 `point.damages` 后同样重登记。私有字段契约见 `IViewStore`(`:22-27`)与 `IDamageStore`(`:29-36`)——`viewStore.damages` 以 `index` 为键、`damageStore.index` 与之 1:1。
```ts
point.affectedBy.add(viewItem);
point.damages.add(damage);
// 记录伤害来源,供 deleteEnemy / removeEnemyAffecting 反向清理
this.damageStore.set(damage, { sourceView: viewItem, sourceEnemy: view, index });
const viewStore = this.viewStore.getOrInsertComputed(viewItem, () => ({
damages: new Map(),
enemy: view
}));
viewStore.damages.set(index, damage);
```
- **方案 B(只让 `deleteEnemy` 自足,最小)**:改写 `deleteEnemy`(`:150-167`),用 `viewItem.getRange()/getRangeParam()` 枚举点位并就地按剩余 `affectedBy` 重算 `point.damages`。
**既有读取范式(`deleteEnemy`,`:150-167`,方案 A 下**无需改动**):**
```ts
deleteEnemy(view: IEnemyView<TEnemy>): void {
const store = this.enemyStore.get(view);
if (!store) return;
const collection = new Set<number>();
for (const viewItem of store) {
const affecting = this.viewStore.get(viewItem);
if (!affecting) continue;
affecting.damages.forEach((dam, index) => {
this.damageStore.delete(dam);
collection.add(index);
});
this.viewStore.delete(viewItem);
}
this.enemyStore.delete(view);
collection.forEach(v => {
this.markDirtyIndex(v);
});
}
```
**错误处理**:沿用 `logger.warn(102/103/104)` 早退,不抛异常。保留 `if (!store) return;`(`:152`)——`mapDamage.test.ts:517-529` 依赖它。
**测试**:`mapDamage.test.ts:532-545` 取消 skip。方案 A 需额外确认无既有用例断言 store 内部结构。
---
### `packages-user/data-system/src/combat/combat.ts` (#06-01-3,system, request-response)
**Analog / 契约事实源:** `combat/types.ts:771-779`(只有返回 `false` 才停止并放弃战斗)。实现与文档相反。
**契约逐字(`:771-779`):**
```ts
/**
* 战前执行的内容,返回 `false` 会立刻停止后续战前内容的执行,并放弃此次战斗
* @param info 战斗伤害信息
* @param handler 信息对象
*/
before(
info: IEnemyDamageInfo<TEnemy, THero>,
handler: ICombatFlowHandler<TEnemy, THero>
): Promise<boolean>;
```
**当前实现(`:178-181`,错误):**
```ts
for (const script of this.scriptList) {
const skip = await script.before(damage, handler);
if (skip) return damage;
}
```
**修复形态(变量名同步自解释,D-11):**
```ts
for (const script of this.scriptList) {
const proceed = await script.before(damage, handler);
if (!proceed) return damage;
}
```
**错误处理**:其余分支保持 `logger.warn(139/141)` + `return null` 不变。
**⚠️ 必须同步纠偏的既有绿用例(本研究新发现 1,D-09 汇报项):** `FakeScript` 的 `beforeResult` 默认 `false`(`combat.test.ts:114/123`),修后「默认 false = 放弃战斗」会打红三条既有用例,必须显式传 `true`:
- `:289-312`(优先级顺序)`expect(calls).toEqual(['high.before','low.before','high.after','low.after'])`
- `:426-458`(await 顺序)→ `new FakeScript(1, 'script', fixture.calls, true)`
- `:460-480`(truthy 分支)→ 改为互补分支断言并更名 `runs hooks and after scripts when before returns truthy`
**FakeScript 注释同步(`:114`):** `/** before 的返回值,真值表示短路 */` → 改为「假值表示放弃战斗」。
**测试**:`combat.test.ts:483-501` 取消 skip。
---
### `packages-user/data-system/src/combat/context.ts` (#06-01-4 + #06-15-1,system, event-driven)
**Analog(范式):** 同文件 `refreshEnemy`(`:780-784`)——局部刷新前先 `view.reset()`。**工具:** `EnemyView.reset()`(`combat/enemy.ts:15-17`)= `computedEnemy.copyFrom(baseEnemy)`,而 `Enemy.copyFrom`(`data-base/src/enemy/enemy.ts:77-84`)会 `cloneAttributes()`(`structuredClone`)并重建特殊属性集。
**既有正确范式(`refreshEnemy`,`:780-787`):**
```ts
private refreshEnemy(view: EnemyView<TEnemy>): void {
const locator = this.getEnemyLocatorByView(view);
if (!locator) return;
view.reset();
const enemy = view.getComputingEnemy();
const base = view.getBaseEnemy();
const handler = this.createHandler(enemy, locator);
// ...
}
```
**`EnemyView.reset()`(`combat/enemy.ts:15-17`):**
```ts
reset(): void {
this.computedEnemy.copyFrom(this.baseEnemy);
}
```
**`buildup()` 现状(`:684-716`)——清空拓扑但漏 `reset()`:**
```ts
this.needUpdate = false;
this.sortedAura.clear();
this.convertedAura.clear();
this.dirtyEnemy.clear();
this.needTotallyRefresh.clear();
this.requestedCommonContext.clear();
```
**修复形态(在 `:695` 之后、各效果阶段之前插入,**无条件**执行):**
```ts
for (const view of this.enemyViewMap.values()) {
view.reset();
}
```
> 不能挂在 `hasAura || hasSpecialQuery` 之下——`buildupQuery`/`buildupFinal` 也会在上一轮数值上叠加。
> `enemyViewMap` 是 `Map<number, EnemyView<TEnemy>>`(`:32`),`values()` 即全部视图。
**错误处理**:`logger.warn(110)` 早退(未绑定勇士)保持不变;循环对全新视图为幂等 no-op。
**测试**:`damage.test.ts:677-718`(#06-01-4)、`context.test.ts:625-663`(#06-15-1)取消 skip。
---
### `packages-user/data-base/src/enemy/manager.ts` (#06-03-1,model/registry, CRUD)
**Analog(同文件正确实现):** `internalGetPrefab`(`:131-139`)与 `getPrefab`/`getPrefabById`(`:165-173`)。`deletePrefab`(`:176`)、`modifyPrefabAttribute`(`:218`)已在用 `internalGetPrefab`——本修复是消除不一致。
**既有正确范式(`internalGetPrefab`,`:131-139`):**
```ts
private internalGetPrefab(code: number | string) {
if (typeof code === 'number') {
const sourceCode = this.reuseByCode.get(code) ?? code;
return this.prefabByCode.get(sourceCode) ?? null;
} else {
const sourceId = this.reuseById.get(code) ?? code;
return this.prefabById.get(sourceId) ?? null;
}
}
```
**当前错误实现(`:119-129`,绕过复用映射):**
```ts
createEnemy(code: number): IEnemy<TEnemy> | null {
const prefab = this.prefabByCode.get(code);
if (!prefab) return null;
return prefab.clone();
}
createEnemyById(id: string): IEnemy<TEnemy> | null {
const prefab = this.prefabById.get(id);
if (!prefab) return null;
return prefab.clone();
}
```
**修复形态(D-08 明确允许改用 `internalGetPrefab`):**
```ts
createEnemy(code: number): IEnemy<TEnemy> | null {
const prefab = this.internalGetPrefab(code);
if (!prefab) return null;
return prefab.clone();
}
createEnemyById(id: string): IEnemy<TEnemy> | null {
const prefab = this.internalGetPrefab(id);
if (!prefab) return null;
return prefab.clone();
}
```
**类型说明:** `internalGetPrefab` 返回 `IEnemy | null`(可直接 `.clone()`);`getPrefab` 返回 `IReadonlyEnemy` 不可用。类成员提升使 `:131` 的声明在 `:119` 之后仍可用。**无需 `as`**(`dev.md` 禁连续 `as`)。
**不变式(可加一行有价值注释):** 「所有按 code/id 取模板的公开入口都必须经 `internalGetPrefab`」。
**测试**:`manager.test.ts:272-279`、`:282-343` 取消 skip。
---
### `packages-user/data-common/src/replay/array.ts` (#06-04-1..4,utility, transform/binary)
**共同根因:** `indexArray[i]` 存的是**第 i 条命令的参数起始字节**(见 `add` `:405`:`this.indexArray[this.length] = this.paramUsed;`),多处却当作**命令索引**使用;且末条缺少终点哨兵(`indexArray[length]` 未写入)。
**同文件正确范式 1——顺序累加参数偏移(`rebuildIndexArray`,`:749-767`):**
```ts
rebuildIndexArray(): void {
const commandSize = this.getCommandSize();
let currCommand = 0;
let currParam = 0;
// 需要对每个参数进行解码,然后 cumsum
for (let i = 0; i < this.length; i++) {
const { paramCount } = this.decodeCommand(currCommand);
const params = this.decodeParamList(currParam, paramCount);
this.indexArray[i] = currParam;
currCommand += commandSize;
currParam += params.reduce(
(prev, curr) => prev + curr.byteLength,
0
);
}
this.paramUsed = currParam;
}
```
**同文件正确范式 2——读流按已解码字节数顺序推进(`createReadStream`,`:722-730`):**
```ts
if (stream.index >= this.length) return null;
const { command, paramCount } = this.decodeCommand(currCommand);
const params = this.decodeParamList(currParam, paramCount);
stream.index++;
currCommand += commandSize;
currParam += params.reduce(
(prev, curr) => prev + curr.byteLength,
0
);
```
**建议引入的私有助手(以 `paramUsed` 为末步哨兵,统一 `[start, end)`):**
```ts
/** 获取指定命令在参数缓冲区中的字节区间 [start, end) */
private getParamRange(index: number): { start: number; end: number } {
const start = this.indexArray[index];
const end = index + 1 < this.length ? this.indexArray[index + 1] : this.paramUsed;
return { start, end };
}
```
**#06-04-1(`:621`,解码乘数):**
```ts
// 修复前
value = low + high * 2147483647;
// 修复后(与编码侧 :369-370 的 2147483648 一致)
value = low + high * 2147483648;
```
**#06-04-2(编码 `:264-270` / 解码 `:628`、`:631`,多字节 bigint):** 保持 `byteLength: arr.length + 2`(`:274`)与 `arr.length` 计算(`:261-263`)不变。
```ts
// 编码:替换 :264-270 的 total/base/remain 循环体
arr[i] = Number((param >> (8n * BigInt(i))) & 0xffn);
// 解码:无符号读取长度前缀与字节
const length = this.paramView.getUint8(startIndex + 1); // 原 getInt8
const num = this.paramView.getUint8(startIndex + 2 + i); // 原 getInt8
```
**#06-04-3(`delete`,`:447` 端点 + `:469` 回退起点):**
```ts
const nextParam =
index + 1 < this.length ? this.indexArray[index + 1] : this.paramUsed;
// ...
for (let i = index; i < this.length; i++) {
this.indexArray[i] -= paramLength;
}
```
> `delete(0)` 路径修后等价(`i = index = 0` == 修前 `i = paramStart = 0`),既有绿用例 `:198-208`、`:484-495` 保持绿。
**#06-04-4(`insert`,`:424` + `:425` **两处**位移方向取反):**
```ts
this.paramArray.copyWithin(paramStart + length, paramStart);
this.indexArray.copyWithin(index + 1, index);
```
> `:435-437` 尾段补偿循环(`this.length++` 之后执行)在修正方向上恰好覆盖 `[index+1, newLength-1]`,**无需再改**。
>
> ⚠️ 覆盖真相(须在汇报中说明):两个 insert skip 用例只用 `createReadStream` **顺序读**(`:498-508`、`:523-539`),读流 `currParam` 顺序累加、**不依赖 `indexArray` 绝对值** → 只修 `:424` 就能转绿,但 `array.get(i)`(`:697-706`)依赖 `indexArray[i]`,只修一处时 `get` 语义仍错。两处同修才是完整修复(Assumption A6)。
**错误处理**:沿用 `logger.warn(148/149/150/151/152/153/154/155)`,不抛异常;越界/容量判定不新增。
**测试**:`array.test.ts:278-284`、`:287-291`、`:294-304`、`:211-221`、`:511-520`、`:498-508`、`:523-539` 取消 skip(建议按缺陷分两个原子提交:常量/bigint 编解码 与 索引编辑)。
---
### `packages-user/data-base/src/hero/attribute.ts` (#06-05-1,model, CRUD)
**Analog(同文件):** `recalculateAttribute` 的有修饰器分支(`:84-97`)与 `catchCalculateProgress`(`:100-117`)——两者都是「无修饰器则早退」,但 `recalculateAttribute` 是**写 final 的唯一入口**(`set/add/mul/div` 经 `markDirty` 落到此处)。
**当前错误早退(`:80-82`):**
```ts
private recalculateAttribute<K extends keyof THero>(name: K): void {
const modifierList = this.modifier.get(name);
if (!modifierList) return;
```
**修复形态(无修饰器时 final = base,保持同引用语义):**
```ts
const modifierList = this.modifier.get(name);
if (!modifierList) {
this.finalAttribute[name] = this.attribute[name];
return;
}
```
> 引用语义与既有约定一致:有修饰器分支 `let value = baseValue`(`:85`)本身即同引用起点,且 `isSameReference` 告警分支(`:89-93`)证明「对象属性同引用」是既有约定。
>
> **不要**顺带改 `catchCalculateProgress`(`:100-102`)的同类早退——它是生成器、不写 final,保留原样。
**错误处理**:`logger.warn(109)` 分支不变。
**测试**:`attribute.test.ts:115-121` 取消 skip。
---
### `packages-user/data-base/src/hero/equipment.ts` (#06-05-2 + #06-09-2,model, CRUD/save-load)
#### #06-05-2 — 空槽判断写反
**Analog(同文件):** `canEquipTo` 的字符串分支(`:50-72`)自行计算 `hasEmpty`,逻辑本身正确——**只作语义参照,不要顺带重构**。
**当前错误实现(`getCouldEquipSlot`,`:134-150`):**
```ts
let empty = -1;
this.slots.forEach((name, index) => {
if (name !== slot) return;
if (first === -1) first = index;
if (empty !== -1 && !this.equips.has(index)) { // ← 恒假
empty = index;
}
});
if (empty === -1) {
return first;
} else {
return empty;
}
```
**修复形态(`:141` 条件取反,恢复「优先占空槽、无空槽才替换」):**
```ts
if (empty === -1 && !this.equips.has(index)) {
```
#### #06-09-2 — `saveState` 未深拷贝
**契约事实源(L0):** `packages-user/data-common/src/save/types.ts:12-17`——`saveState` 返回对象**应经过深拷贝(`structuredClone`)**。
**Analog(同层兄弟,`equipStore.ts` `saveNoCompression`,`:67-74`)——Map 深拷贝既有写法:**
```ts
private saveNoCompression(): IEquipmentStateSave<THero> {
return {
uid: this.uid,
num: this.item.num,
value: new Map(this.value),
percentage: new Map(this.percentage)
};
}
```
**当前错误实现(`:329-334`):**
```ts
saveState(): IHeroEquipmentSave {
return {
equipped: this.equips,
slots: this.slots
};
}
```
**修复形态(与兄弟 Map/数组深拷贝一致):**
```ts
saveState(): IHeroEquipmentSave {
return {
equipped: new Map(this.equips),
slots: [...this.slots]
};
}
```
> 类型兼容:`IHeroEquipmentSave.equipped` 为 `ReadonlyMap<number, number>`、`slots` 为 `readonly string[]`,`Map`/数组均满足,**无需 `as`**。
**错误处理**:`equip`/`unequip` 的 `logger.warn(146/147)` 与 `replay.disable()/revert()` 模式不变。按 D-06,#06-05-3 的 147 分支**一行不动**。
**测试**:`equipment.test.ts:290-303`(#06-05-2)取消 skip;`saveLoad.test.ts:312-325`、`:589-609`(#06-09-2)取消 skip。`equipment.test.ts:306-315`(147)**保留 skip + 中文注释**。
---
### `packages-user/data-base/src/hero/equipStore.ts` (#06-09-1,model/store, save-load)
**Analog(同文件正确范式):** `saveDiff`(`:80-102`)已确立**基准 = `item.equip` 原始定义**;`saveNoCompression`(`:67-74`)确立 value/percentage 分表;构造器(`:34-36`)确立 `new Map(equip.value)` / `new Map(equip.percentage)` 的基准装载。
**基准范式(`saveDiff`,`:80-88`):**
```ts
private saveDiff(): IEquipmentStateSave<THero> {
const { value, percentage } = this.item.equip;
const valueDiff = new Map<SelectKey<THero, number>, number>();
for (const [name, equipValue] of this.value) {
const base = value.get(name);
if (base !== equipValue) {
valueDiff.set(name, equipValue);
}
}
// ...
}
```
**当前错误实现(`loadNoCompression` `:116-126`、`loadDiff` `:132-151`)——两处均误遍历 `state.percentage` 且压缩档缺回退基准。**
**修复形态(`loadNoCompression`):**
```ts
private loadNoCompression(state: IEquipmentStateSave<THero>): void {
this.value.clear();
this.percentage.clear();
for (const [name, value] of state.value) {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
}
this.rebuildModifiers();
}
```
**修复形态(`loadDiff`:先回退 `item.equip` 原始定义,再叠加存档差异):**
```ts
private loadDiff(state: IEquipmentStateSave<THero>): void {
this.value.clear();
this.percentage.clear();
// 基准为装备原始定义,再叠加存档中的差异条目
for (const [name, value] of this.item.equip.value) {
this.value.set(name, value);
}
for (const [name, percentage] of this.item.equip.percentage) {
this.percentage.set(name, percentage);
}
for (const [name, value] of state.value) {
this.value.set(name, value);
}
for (const [name, value] of state.percentage) {
this.percentage.set(name, value);
}
this.rebuildModifiers();
}
```
> 关键:`modifier.setValue()` **不会**回写 `this.value/percentage` 映射——这是理解这几条用例的前提。
> 恢复后再调 `rebuildModifiers()`(`:46-55`),与既有 `rebuildModifiers` 生成 `ValueModifier`/`PercentageModifier` 的模式一致。
**错误处理**:`HeroEquipsStore.loadState` 的 `logger.error(58/59)` 与 `maxBy` 模式不变。
**测试**:`hero/saveLoad.test.ts:337-341`、`:344-348`、`:351-366`、`:369-381` 取消 skip;既有绿 `:330-334` 保持绿。
---
### `packages-user/data-base/src/map/mapLayer.ts` (#06-06-1,model, request-response)
**Analog(同文件兄弟分支,逐字一致):** `transferToStatic`(`:460-478`)与 `transferToStaticIfSafe`(`:480-496`)的越界分支都发码 **128**。
**既有正确范式(`transferToStatic`,`:466-469`):**
```ts
if (x < 0 || y < 0 || x >= this.width || y >= this.height) {
logger.warn(128, x.toString(), y.toString());
return null;
}
```
**当前错误实现(`transferToDynamic`,`:440-443`):**
```ts
if (!this.inMap(x, y)) {
logger.warn(131, x.toString(), y.toString());
return null;
}
```
**修复形态(D-04):**
```ts
if (!this.inMap(x, y)) {
logger.warn(128, x.toString(), y.toString());
return null;
}
```
> 可保留 `this.inMap(x, y)` 守卫(语义等价),仅改码与参数形态与兄弟分支一致。
> 码表(L0 权威):`128` = `Cannot transfer $1 to $2, since target position $3,$4 out of bounds.`;`131` = setEventLayer 越权专用。
**不变式:** 改动码前先 grep 全仓——码 131 的**唯一**测试断言在 `gameMap.test.ts:185`(`setEventLayer` 路径,不经 `transferToDynamic`);码 131 的生产写入点仅 `mapLayer.ts:441` 与 `gameMap.ts:149`。
**错误处理**:`logger.warn(127/129/130)` 其余分支不变,不抛异常。
**测试**:`mapLayer.test.ts:419-427` 取消 skip。
---
### `packages-user/data-base/src/map/dynamicTile.ts` (#06-09-3,model, save-load)
**Analog(同文件):** `set`(`:56-66`)——已内含 `tileNum` 写入、`tileRaw` 重取与 `restoreDefaultEvents()`;`saveState`(`:102-116`)证明 `save.num` 是必填字段(`map/types.ts:32-35`)。
**既有正确范式(`set`,`:56-66`):**
```ts
set(num: number): void {
this.tileNum = num;
const data = this.state.tileStore.getData(num);
if (!data) {
logger.warn(143, num.toString());
this.tileRaw = null;
} else {
this.tileRaw = data;
}
this.restoreDefaultEvents();
}
```
**当前错误实现(`loadState`,`:118-127`,漏 `save.num`):**
```ts
loadState(save: Readonly<IDynamicBlockSave>): void {
this.restoreDefaultEvents();
if (save.events) {
const eventView = this.tileEvent();
eventView.clear();
for (const [priority, id] of save.events) {
eventView.set(priority, id);
}
}
}
```
**修复形态(先 `set(save.num)`,`set` 内含 `restoreDefaultEvents()`):**
```ts
loadState(save: Readonly<IDynamicBlockSave>): void {
this.set(save.num);
if (save.events) {
const eventView = this.tileEvent();
eventView.clear();
for (const [priority, id] of save.events) {
eventView.set(priority, id);
}
}
}
```
> 签名:保持 1 参(`MapTileBase` 抽象声明 `tile.ts:76` 同样一参;`dev.md`「未使用的后置参数直接不填」)。**不要**补 `compression`。
**错误处理**:`logger.warn(143)` 由 `set` 内部统一上报。
**测试**:`map/saveLoad.test.ts:164-173` 取消 skip;`mapLifecycle.test.ts:126-148` 复核仍绿(`dirty` 语义由 `restoreDefaultEvents` 末尾 `markPure` 决定)。
---
### `packages-user/data-common/src/common/mover.ts` (#06-08-1,utility, state machine)
**Analog(同文件):** `getCurrentDirection`(`:464-473`)与 `prepareStep` 的 `Forward` 分支(`:498-501`)——前进语义保持不变;后退基准改为 `faceDirection`。
**当前错误实现(`:492-503`):** 每步把 `moveDirection` 写成 `opposite(dir)`,下一步 `getCurrentDirection()` 又优先取该反方向 → 再取反 → 方向摆动、净位移 0、朝向翻转。
**相关既有范式(`:464-473`):**
```ts
/**
* 获取当前应当作为相对移动基准的方向
*/
private getCurrentDirection(): FaceDirection {
if (this.moveDirection !== FaceDirection.Unknown) {
return this.moveDirection;
} else {
return this.faceDirection;
}
}
```
**修复形态(后退基准取 `faceDirection`,前进保持既有语义):**
```ts
case ObjectMoveType.Special: {
if (step.direction === ObjectSpecialStep.Backward) {
const dir = this.faceDirection;
this.moveDirection = this.faceHandler.opposite(dir);
this.faceDirection = dir;
} else {
const dir = this.getCurrentDirection();
this.moveDirection = dir;
this.faceDirection = dir;
}
break;
}
```
**D-11 必办——同步 jsdoc(`:309-313`):**
```ts
/**
* 追加若干个后退步,沿当前移动方向的反方向移动
* @param count 追加次数,默认 1
*/
backward(count?: number): this;
```
→ 改为「沿当前朝向的反方向后退」并说明多步后退保持同轴;`getCurrentDirection`(`:464-466`)与 `prepareStep`(`:475-478`)注释同步措辞。**备选**(D-09 汇报二选一):保留「当前移动方向」字面契约,另设私有「相对基准方向」字段。
**错误处理**:本文件按步骤状态机运行,无 `logger` 诊断码;不改 `forward`(`:579-587`)/`backward`(`:589-597`)的入队逻辑。
**测试**:`mover.test.ts:307-319` 取消 skip;`:292-304`(单步)修后等价保持绿。
---
### `packages-user/data-state/src/core.ts` (#06-09-5,store/container, batch)
**Analog(同文件):** 178 分支自身(`:531-537`)与相邻 177 分支(`:522-527`)——177 的语义是「saveables 中存在但**存档缺失**」,正是当前 178 误用的差集方向。
**码表(L0 权威文案):**
- `177`: `Save data for saveable content $1 is needed, but there's no data in save data. ...`
- `178`: `Save data with keys of '$1' are saved but not be loaded, is there some issue for it?`(= 存档中出现但未加载)
**既有 177 范式(`:522-527`):**
```ts
for (const [key, value] of this.saveables) {
// 使用 has 判断是否在映射中,而非值的非空判断,因为空值也有可能是存档的一部分
if (!state.has(key)) {
logger.warn(177, key);
continue;
}
const data = state.get(key);
value.loadState(data, compression);
}
```
**当前错误实现(`:531-537`,方向与文案相反):**
```ts
const loaded = new Set<string>(state.keys());
const total = new Set(this.saveables.keys());
const remain = total.difference(loaded);
if (remain.size > 0) {
const ids = [...remain].join(' | ');
logger.warn(178, ids);
}
```
**修复形态(D-05,差集方向取反):**
```ts
const loaded = new Set<string>(state.keys());
const total = new Set(this.saveables.keys());
const remain = loaded.difference(total);
if (remain.size > 0) {
const ids = [...remain].join(' | ');
logger.warn(178, ids);
}
```
**错误处理**:`logger.warn(112/113/177/178)` 均为「告警不中断」,`Set.prototype.difference` 已在用(Node 22 + 仓库 polyfill 环境可用)。
**⚠️ 必须同步纠偏的既有绿用例(D-05 已授权):** `saveablesRoundTrip.test.ts:322-334` 的 `warns code 178 when the save data misses a saveable key` 修后必红。推荐处置(Assumption A4,需在 D-09 点名去重):把 `:322-334` 改为「多 key」版本并**让 `:337-349` 的 skip 成为被取消 skip 的那一条**,避免两条完全重复。
```ts
// 验证存档含未注册 key 时经 logger.catch 观测到警告码 178
it('warns code 178 when the save data has keys that are not loaded', () => {
const state = createCoreState();
const snapshot = new Map(state.saveState(SaveCompression.NoCompression));
snapshot.set('@system/extra', null);
const result = logger.catch(() =>
state.loadState(snapshot, SaveCompression.NoCompression)
);
expect(result.info.map(info => info.code)).toContain(178);
});
```
**测试**:`saveablesRoundTrip.test.ts:337-349` 取消 skip;`:307-319`(177)必须保持绿。
---
## Shared Patterns
### 1. 错误诊断:`logger.warn(code, ...)` + 早退,绝不抛异常
**Source:** `packages/common/src/logger.ts`、码表 `packages/common/src/logger.json`
**Apply to:** 全部生产文件(尤其 #06-06-1 改 128、#06-09-5 改 178)
```ts
if (!this.converter) {
logger.warn(106);
return null;
}
```
- 码是**数据**:文案在 `logger.json`,`$1..$4` 由参数替换;码唯一、不复用、0 禁用。
- **改动任一码的触发条件前先 grep 全仓**:(1) 所有 `*.test.ts` 中的该码断言;(2) 所有生产 `logger.warn(<code>` 写入点。这是本阶段最高危 Pitfall(已实证 #06-01-3 影响 3 条、#06-09-5 影响 1 条既有绿用例)。
### 2. 「重置再重算」范式(#06-01-4 / #06-15-1)
**Source:** `data-system/src/combat/context.ts:780-784` + `data-system/src/combat/enemy.ts:15-17`
```ts
view.reset(); // EnemyView.reset -> computedEnemy.copyFrom(baseEnemy)
```
**Apply to:** `context.ts` `buildup()` 全量构建前置。**不新造重置逻辑**,复用既有 API。
### 3. 「统一入口不变式」(#06-03-1)
**Source:** `data-base/src/enemy/manager.ts:131-139`(`internalGetPrefab`)
**Apply to:** `createEnemy`/`createEnemyById`——所有按 code/id 取模板的公开入口必须经该私有方法(`getPrefab`/`deletePrefab`/`modifyPrefabAttribute` 已是此模式)。
### 4. 深拷贝契约(#06-09-2)
**Source:** `packages-user/data-common/src/save/types.ts:12-17`(`saveState` 必须深拷贝)
**Apply to:** 所有 `saveState` 返回既有对象引用的实现。
```ts
// Map 深拷贝(equipStore.ts:71 既有写法)
value: new Map(this.value)
// 数组深拷贝
slots: [...this.slots]
// 普通对象/嵌套结构(enemy.ts:91 既有写法)
attrs: structuredClone(this.attributes)
```
### 5. 「读档字段 ↔ save 字段」逐字段核对清单(#06-09-1/2/3)
**Source:** `equipStore.ts:67-102`(save 侧)+ `dynamicTile.ts:102-116`(save 侧)
**Apply to:** 每个 `loadState`:逐个 save 字段确认是否被消费。已知三处漏项:`equipped`/`slots`(深拷贝)、`num`(dynamicTile)、`value/percentage`(压缩档回退基准)。
### 6. 键控 Map 双向登记(#06-01-2 方案 A)
**Source:** `data-system/src/combat/context.ts:256-299`(`setEnemyAt`/`deleteEnemyAt`)
**Apply to:** `mapDamage.ts` 的 `viewStore`/`damageStore`——写入时登记所有索引,删除时同步反向清理,禁止只写单向。
```ts
this.locatorEnemyMap.set(enemy, index);
this.locatorViewMap.set(view, index);
this.computedToView.set(view.getComputingEnemy(), view);
```
### 7. 测试取消 skip 的唯一改动形态(D-10)
**Source:** `replay/array.test.ts:287-291`(skip 写法)、`saveablesRoundTrip.test.ts:307-319`(`logger.catch` 断言范式)
```ts
// 修复前
it.skip('round-trips int64 values above the int32 range', () => {
// 修复后(仅删去 .skip,断言一字不改)
it('round-trips int64 values above the int32 range', () => {
```
`logger.catch` 断言范式(沿用既有,不新建 helper):
```ts
const result = logger.catch(() =>
state.loadState(snapshot, SaveCompression.NoCompression)
);
expect(result.info.map(info => info.code)).toContain(178);
```
测试文件每个 `it` 前保留单行中文注释(`dev.md` 强制);skip 用例的「疑似 bug + 修复后取消 skip」注释在转绿后应删除或改为中性描述。
### 8. 代码规范(全文件适用)
**Source:** `dev.md`、`.planning/codebase/CONVENTIONS.md`
- CRLF、4 空格、单引号、无尾逗号、`arrowParens: avoid`、printWidth 80。
- **禁 `import type`**(唯一例外 `entry-data/src/mota.ts`);**禁连续 `as`**(`as unknown as` 绝对禁止)。
- 不使用对象解构单属性(`const v = obj.v`);私有方法写在调用它的方法**之前**且置于合理 `#region`。
- 中文 jsDoc **写在源头**(多为 `interface`/`types.ts`);私有成员与方法必须注释;继承的 API 不重复注释。
- 私有成员不以 `_` 开头;未使用参数直接不填(不写 `_x`)。
---
## 既有绿用例纠偏清单(**必须与生产修复同 commit**)
| 文件:行 | 用例 | 纠偏内容 | 依据 |
|---------|------|---------|------|
| `data-system/src/combat/combat.test.ts:289-312` | 优先级/重复脚本顺序 | `beforeResult` 显式传 `true`(否则修后第一个 before 即放弃) | Assumption A3 |
| `data-system/src/combat/combat.test.ts:426-458` | await 顺序 | `new FakeScript(1, 'script', fixture.calls, true)` | Assumption A3 |
| `data-system/src/combat/combat.test.ts:460-480` | truthy 分支 | 改为互补分支断言 + 更名 `runs hooks and after scripts when before returns truthy` | Assumption A3 |
| `data-system/src/combat/combat.test.ts:114` | `FakeScript.beforeResult` 注释 | 「真值表示短路」→「假值表示放弃战斗」 | D-11 |
| `data-state/test/saveablesRoundTrip.test.ts:322-334` | 178 缺 key | 改为多 key 语义并与 `:337-349` 去重(保留其一) | D-05 / Assumption A4 |
**保留 skip(不转绿):**
| 文件:行 | 处置 | 依据 |
|---------|------|------|
| `data-base/src/hero/equipment.test.ts:306-315`(147) | 保留 `it.skip` + 中文注释「147 为保留错误码,当前不可达,设计如此」;生产代码一行不动 | D-06 |
---
## 用户负责项(AI 不实现,D-07)
| 项 | 事实/参考 | AI 职责 |
|----|----------|---------|
| `data-state/src/core.ts` 向 `pathfinding.finder` 注入 `useMapState`/`useMapLayer`/`usePassPredicate` | 测试内等价注入逐字:`replayPlayback.test.ts:108-114`;`DefaultPassPredicateImpl` 在 `data-state/src/hero/predicate.ts`;注入点 `core.ts:248-256`(`loading.once('loaded')` → `initMapState`) | 用户接线后取消 `replayPlayback.test.ts:362-375` 的 skip 并验证转绿;**不通过则按 D-09 退出汇报,不自行改 `core.ts`** |
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| — | — | — | 无。13 个生产文件全部为既有文件修改,且同文件/同层均存在可照抄的正确范式。本阶段**不新建任何文件**。 |
---
## Metadata
**Analog search scope:** `packages-user/data-system/src/combat`、`packages-user/data-base/src/{enemy,hero,map}`、`packages-user/data-common/src/{replay,common,save}`、`packages-user/data-state/src`、`packages/common/src`(码表)
**Files scanned:** 13 生产文件 + 8 测试文件 + 3 契约/码表文件(共 24 个 `Read`/`Grep` 目标)
**Tracked-source gate:** `git ls-files` 已验证 13 个生产文件全部 tracked;未命名任何 gitignored 镜像路径
**Pattern extraction date:** 2026-09-15
**Source of truth:** `07-CONTEXT.md` D-01..D-14、`07-RESEARCH.md` §Per-Finding Analysis(file:line + 逐字源码引用)、`06-TEST-FINDINGS.md`