From 7c04ed4a582151855c64df6dd891bd744e81bd57 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 17 Sep 2026 15:48:53 +0800 Subject: [PATCH] docs(07-12): lock user adjudications for WR-04/05/06 + compareEquip --- .planning/phases/07-data-fixes/07-12-PLAN.md | 419 ++++++++++--------- 1 file changed, 218 insertions(+), 201 deletions(-) diff --git a/.planning/phases/07-data-fixes/07-12-PLAN.md b/.planning/phases/07-data-fixes/07-12-PLAN.md index 68ac68f..24f981b 100644 --- a/.planning/phases/07-data-fixes/07-12-PLAN.md +++ b/.planning/phases/07-data-fixes/07-12-PLAN.md @@ -6,90 +6,94 @@ wave: 12 depends_on: - 07-11 files_modified: - - packages-user/data-base/src/hero/equipStore.ts - - packages-user/data-base/src/hero/types.ts - packages-user/data-base/src/hero/attribute.ts + - packages-user/data-base/src/hero/types.ts - packages-user/data-base/src/hero/equipment.ts - - packages-user/data-base/src/hero/saveLoad.test.ts - packages-user/data-base/src/hero/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: 64000 - raw_tokens: 64000 + tokens: 56000 + raw_tokens: 56000 tasks: 6 confidence: low must_haves: truths: - - "装备存档以**活修饰器值**为准(按 Task 0 Q1 裁决):运行时对装备修饰器的 `setValue(...)` 在三档压缩下都能读回(WR-04 关闭),或按裁决明写「运行时修饰器变更不入存档」并在 `IEquipmentState` 上留下契约说明" - - "`HeroAttribute.deleteModifierByIndex` 与 `deleteModifier` 的簿记一致:`bindAttribute(null)`、清理 `modifierName`/`modifierNosave`、`markDirty(name)`;被删修饰器可被再次 `addModifier`(不再因残留 `owner` 被码 108 拒绝)(WR-05 关闭)" - - "`HeroAttribute.iterateModifiers()` / `getModifierIndex()` 在 `deleteModifierByIndex` 之后不再产出/解析已删除的修饰器;`finalAttribute` 不再停留在陈旧值" - - "`HeroEquipment.loadState` 不再经 `equip()` 追加 `Equip` 录像指令:读档期间 `replaySystem.route.add` 不被调用(WR-06 关闭,按 Task 0 Q3 裁决口径)" - - "空存档读档(`{ equipments: [] }`)把 `HeroEquipsStore` 的 uid 计数器重置为 0 且不产生码 58,且该重置可观测(随后的 `add(...)` 返回 0)——由测试锚定(IN-02 测试契约对齐 `ba60ef9`;本计划对 IN-02 **只改测试**,不改其生产逻辑)" - - "`pnpm test:ci` 全绿,不新增 `it.skip`;`equipment.test.ts:306`(码 147,D-06)保持原样" + - "**WR-04 = 设计如此(非缺陷,本计划零代码改动)**:装备自身属性(`this.value` / `this.percentage`)是加成的**事实源**,存档持久化的正是这两个表,`loadState` 由它们 `rebuildModifiers()` 重建修饰器;**修饰器是被动的派生视图**——跨读档丢失运行时的修饰器对象引用是**预期行为**;运行时若(将来)要改变某件装备的加成,必须**经装备自身(`value` / `percentage`)**,而不是修改它的修饰器。既有 `saveLoad.test.ts`(`:229-245`、`:350-403`)读回「存档的装备属性值」的断言**逐字保留**,不做任何翻转" + - "**WR-05 关闭(Q2=C 委托)**:`HeroAttribute.deleteModifierByIndex` 按索引取出修饰器后委托 `deleteModifier(name, modifier)`,并返回该修饰器(`deleteModifier` 返回 `void`,故委托前先取出对象);`bindAttribute(null)`、`modifierName.delete`、`modifierNosave.delete`、`markDirty(name)` 只经 `deleteModifier` 这一份簿记实现生效" + - "**WR-05 四条后果全部自洽**:删除后 `iterateModifiers()` 不再产出该修饰器;`getModifierIndex(removed) === -1`;`getFinalAttribute(name)` 按剩余修饰器重算(不再陈旧);被删修饰器可再次 `addModifier` 而不被码 108 拒绝;被删修饰器的 `modifierNosave` 条目已清理" + - "`deleteModifierByIndex` 的索引越界/为负时返回 `null` 且**不删除任何修饰器**(取代旧 `splice(index, 1)` 的「负索引静默删末尾」),并有断言固定(A6:新增边界语义,非既有断言改写)" + - "**WR-06 关闭(Q3=A 复用既有通道)**:`HeroEquipment.loadState` 的重新装备循环被既有 `replay.disable()` / `replay.revert()` 包裹(`try/finally` 保证必达),读档期间 `replaySystem.route.add` **未被调用**、`disable`/`revert` 调用次数相等;读档后 `equip()` / `unequip()` 的正常录像记录能力不变" + - "**compareEquip 索引错配(Q4 仅修 (i))已修**:`compareEquip` 不再把在原属性上算出的索引直接用在克隆属性上删除;删除目标在克隆属性上**按对象**取得(`clone()` 按原属性同顺序重建修饰器数组);`getModifierIndex` 为 `-1` 或槽位越界时**不删除任何修饰器**(修复前 `splice(-1, 1)` 会静默删掉克隆中该属性的末尾修饰器、使对差值偏离)" + - "**`EquipmentState.getModifiers()` 为非缺陷(Q4 裁决,不修、不登记)**:其声明返回类型为 `Iterable<[SelectKey, IHeroModifier]>`(`types.ts:663` / `equipStore.ts:57-61`),调用方无法经该类型把它转成数组或改动内部数组,故本计划不改它,也**不**把它写入任何 known-issues / 登记列表" + - "**IN-02(测试专用)**:空存档读档把 `HeroEquipsStore` 的 uid 计数器重置为 0 且不产生码 58,且该重置可观测(随后的 `add(...)` 返回 0)——由测试锚定(Task 1 把 `saveLoad.test.ts:447-457` 对齐 `ba60ef9` 契约;`equipStore.ts` 零改动)" + - "`pnpm test:ci` 0 失败、不新增 `it.skip`;跳过项仍只有 `equipment.test.ts:306`(码 147,D-06);本计划对 `packages-user/data-common`、`hero/equipStore.ts`、`WINDOWS.md` 零改动" artifacts: - - path: packages-user/data-base/src/hero/equipStore.ts - provides: "`EquipmentState` 的三档 save/load 改为以活修饰器值为存档源(按 Q1 裁决),保持 `IEquipmentStateSave` 形状或按裁决调整" - - path: packages-user/data-base/src/hero/types.ts - provides: "`IEquipmentState`/`IEquipmentStateSave` 的契约 jsDoc(存档语义:活修饰器值 vs 原始定义差异;或按 Q1 裁决写入「运行时修饰器变更不入存档」)" - path: packages-user/data-base/src/hero/attribute.ts - provides: "`deleteModifierByIndex` 的完整簿记(与 `deleteModifier` 对齐),返回被删除的修饰器" + provides: "`deleteModifierByIndex` 改为「按索引取出修饰器 → 委托 `deleteModifier(name, modifier)` → 返回该修饰器」(Q2=C);越界/负索引返回 `null` 且不删除任何修饰器;`attribute.ts` 内不再有第二套删除簿记" + - path: packages-user/data-base/src/hero/types.ts + provides: "`deleteModifierByIndex` 的 jsDoc 与委托口径一致(共用簿记、修饰器被解绑、越界不删除);`IEquipmentStateSave`/`IEquipmentState` 上一条**非行为性**契约注释,写明 WR-04 的裁决(装备自身属性为事实源、存档持久化这两个表、读档由它们重建修饰器、运行时变更须经装备而非改其修饰器)" - path: packages-user/data-base/src/hero/equipment.ts - provides: "`HeroEquipment.loadState` 的无录像副作用还原(按 Q3 裁决:disable/revert 包裹或内部无记录装备助手);按 Q4(ii) 裁决处理 `equipment.test.ts` 相邻缺陷" - - path: packages-user/data-base/src/hero/saveLoad.test.ts - provides: "WR-04 三档回归(活值往返或契约说明)、WR-06 读档无录像副作用回归、WR-05 相关容器级断言、IN-02 空存档重置 `nextUid` 断言(改写 `:447-457`,把当前 RED 用例按 `ba60ef9` 的新契约转绿)" + provides: "`HeroEquipment.loadState` 用既有 `replay.disable()`/`replay.revert()` 包裹重新装备循环(Q3=A,`try/finally`);`compareEquip` 在克隆属性上按对象定位待删修饰器并跳过 `-1`/越界(Q4 仅修 (i))" - path: packages-user/data-base/src/hero/attribute.test.ts - provides: "WR-05 回归:`deleteModifierByIndex` 后 iterate/getModifierIndex/final 值/可重新挂载" + provides: "WR-05 回归:委托后的迭代器产出 / 索引解析 / final 刷新 / 可重新挂载(无码 108)/ `modifierNosave` 清理 / 越界不删除" - path: packages-user/data-base/src/hero/equipment.test.ts - provides: "WR-06 回归:`loadState` 期间 `route.add` 未被调用(基于既有 `replaySystem` 桩)" + provides: "WR-06 回归:`loadState` 期间 `route.add` 未被调用、`disable`/`revert` 配对平衡、随后 `equip`/`unequip` 记录仍在(基于既有 `replaySystem` 桩);compareEquip 索引回归(槽位装备经读档 `rebuildModifiers()` 后对差值不偏离)" + - path: packages-user/data-base/src/hero/saveLoad.test.ts + provides: "IN-02 用例改写(`saveLoad.test.ts:447-457` → `ba60ef9` 契约:空存档 → 随后 `add` 返回 0、不发码 58);WR-04 相关断言(`:229-245`、`:352-403`)逐字保留、不翻转" key_links: - - "`EquipmentState.modifiers` 与 `this.value`/`this.percentage`(`equipStore.ts:19-36`、`:46-55` `rebuildModifiers`)↔ `saveNoCompression`/`saveDiff`(`:63-102`)——存档源必须与活修饰器一致,否则 `saveDiff` 恒为空(WR-04 根因)" - - "`BaseHeroModifier.saveState`/`loadState`/`getValue`(`modifier.ts:30-44`)↔ `EquipmentState` 的存档——修饰器自带值序列化能力,无需新增修饰器 API" - - "`deleteModifierByIndex`(`attribute.ts:230-239`)↔ `deleteModifier`(`:213-228`)——两套簿记必须同构,否则 `modifierName`/`owner` 残留" - - "`HeroEquipment.loadState`(`equipment.ts:337-347`)→ `equip()`(`:153-198`)→ `replay.route.add(ReplayCommandCode.Equip, [uid])`(`:195`)——读档经公开入口产生了真实录像副作用" - - "`HeroEquipment.equip/unequip` 的 `replay.disable()`/`revert()`(`:169-170`/`:194`/`:211-212`)↔ `ReplaySystem.route`(`data-common/src/replay/system.ts:89-95`,`disable`/`revert` 直接转发到 `ReplayArray`)——Q3 选项 A 依赖该既有通道" - - "`compareEquip` 的 `this.attribute.getModifierIndex(modifier)` + `clone.deleteModifierByIndex(name, index)`(`equipment.ts:281-284`)——索引取自原属性却作用于克隆属性(相邻缺陷,按 Q4(ii) 裁决处置)" - - "`HeroEquipsStore.loadState`(`equipStore.ts:300-309`,生产修复 `ba60ef9`)↔ `HeroEquipsStore.add`(`:184-192`,`const uid = this.nextUid++`)——空存档 → `nextUid = 0` 的唯一可观测后果是「下一次 `add` 发号 0」,测试必须用 `add` 返回值锚定,而不是只断言「没有码 58」(IN-02)" + - "`deleteModifierByIndex`(`attribute.ts:230-239`)↔ `deleteModifier`(`:213-228`)——委托后簿记只有一份实现;`deleteModifier` 无返回值,故必须先取出 `arr[index]` 再委托、并把该对象作为返回值(Q2=C)" + - "`HeroEquipment.loadState`(`equipment.ts:337-347`)→ `equip()`(`:153-198`,`:195` `replay.route.add(ReplayCode.Equip, [uid])`)——读档经公开入口产生真实录像副作用;修复复用 `equip()` 同款通道 `replay.disable()`/`replay.revert()`(`:169-170`/`:194`)包裹读档循环(Q3=A)" + - "`ReplaySystem.disable`/`revert`(`data-common/src/replay/system.ts:89-95`)→ `ReplayArray.disabled` 计数(`array.ts:890-897`,`disabled > 0` 时 `add` 直接返回)——读档包裹窗口与录像读档不重叠,故包裹不会吞掉存档载入的指令" + - "`compareEquip`(`equipment.ts:281-284`)↔ `HeroAttribute.clone()`(`attribute.ts:293-311`:只重建 `modifier`、**不复制** `modifierName`;克隆体内的修饰器是 `modifier.ts:18-39` 的 `clone()` **新对象**)——跨对象索引拼接是缺陷根因;修复后在克隆的同名数组里按**对象**取同槽位目标并跳过 `-1`/越界(Q4 仅修 (i))" + - "`EquipmentState.modifiers`/`getModifiers()`(`equipStore.ts:19`、`:57-61`)↔ `saveNoCompression`/`saveDiff`(`:67-102`)——Q1 裁决:`value`/`percentage` 为事实源、`modifiers` 为派生视图,二者**不要求**运行时同步(设计如此,非缺陷);`loadState`(`:116-164`)→ `rebuildModifiers()`(`:46-55`)会重建修饰器对象,这正是 compareEquip 索引错配的触发条件" + - "`HeroEquipsStore.loadState`(`equipStore.ts:300-309`,生产修复 `ba60ef9`)↔ `HeroEquipsStore.add`(`:184-192`,`const uid = this.nextUid++`)——空存档 → `nextUid = 0` 的唯一可观测后果是「下一次 `add` 发号 0」,测试必须用 `add` 返回值锚定(IN-02,测试专用)" assumptions: - - "A1(D-10 例外,本计划授权):本计划为修复计划。Q1 若选「活修饰器值为存档源」,则 `saveLoad.test.ts:343-380` 中「读档后回到原始定义值」的既有断言必须改为「回到存档点的活值」——该改写是本计划两处被授权的既有断言变更之一(另一处为 A5 的 IN-02 用例改写),必须在 SUMMARY 逐条说明;其余既有断言一字不改" - - "A2(项目未发布,与 07-09 的 A2 同口径):`IEquipmentStateSave` 形状可变更,不保留旧形状兼容分支;本计划优先保持形状、只改语义(Q1 选项 A 可在现有 `value`/`percentage` 两个 Map 上表达活值与定义的差异)" - - "A3(不新增修饰器 API):`BaseHeroModifier` 已有 `saveState`/`loadState`/`getValue`/`setValue`,本计划**不**修改 `modifier.ts`;若实现中发现必须改修饰器基类,暂停并回到 Task 0 关卡追加裁决,不得自行扩大改动面" - - "A4(范围守卫):代码修复面仅 WR-04 / WR-05 / WR-06 三条;`IN-04`/`IN-05` 不在本轮清单,只登记不修;`IN-02`(空存档不重置 `nextUid`)的生产代码已由用户在 `ba60ef9` 修复,本计划以**测试专用**方式纳入(Task 1),不改其生产逻辑;`ReplayArray.setReplayArray` 的 `disabled = 0` 重置(`array.ts:807`)属 07-10 的文件,本计划不改" - - "A5(IN-02 测试专用,本计划授权;与 A1 并列的第二处授权断言变更):`saveLoad.test.ts:447-457`(`warns code 58 when the max equipment uid cannot be found`)在 `ba60ef9` 之后**当前为 RED**(`vitest run …/hero/saveLoad.test.ts` → 1 failed / 26 passed),因为空存档已不再发码 58 且会重置 `nextUid`。本计划把该用例改写为新契约(空存档 → `nextUid` 归零、随后的 `add` 返回 0、不发码 58),并在 SUMMARY 登记一条**契约/覆盖变化**:`logger.error(58)` 分支(`equipStore.ts:304-307`)现在只剩「非空但 `maxBy` 仍返回 `undefined`」的畸形数组防御路径可达,码 58 不再由空存档用例覆盖。除该用例与该变化外,其余既有断言一字不改" + - "A1(D-10 例外,本计划授权):本计划为修复计划,WR-05 / WR-06 / compareEquip 的回归用例为必需项。**本轮唯一被授权的既有断言变更是 A5 的 IN-02 用例改写**——WR-04 按 Q1 裁决为「设计如此」,故 07-09/原计划设想的「活值断言翻转」**不再适用**:`saveLoad.test.ts:229-245`(`roundTripPercentageModifier`)与 `:352-403`(三档压缩下修改后读回定义值)逐字不改,SUMMARY 亦**不得**登记任何 WR-04 断言翻转" + - "A2(项目未发布,与 07-09 的 A2 同口径):本计划**不改** `IEquipmentStateSave` 形状、不改存档语义——`value`/`percentage` 继续承载「装备自身的当前加成 / 与定义的差异」,既有存档形状与既有断言均不变;不保留旧形状兼容分支" + - "A3(改动面守卫):`attribute.ts` 只改 `deleteModifierByIndex` 的**方法体**(改为委托);`addModifier`/`deleteModifier`/`clone()`/`iterateModifiers`/`getModifierIndex` 逐字不改;`modifier.ts`(`BaseHeroModifier`)零改动;若实现中发现必须改 `modifier.ts` 或 `clone()`(例如让 `clone()` 携带 `modifierName`),暂停并回到 Task 0 关卡追加裁决,不得自行扩大改动面" + - "A4(范围守卫):代码修复面仅 WR-05、WR-06、compareEquip 索引(Q4 仅修 (i))三项;**WR-04 按 Q1 裁决为「设计如此」,不修、不改断言、不改存档语义**;**`EquipmentState.getModifiers()` 按 Q4 裁决为非缺陷**(声明返回 `Iterable<...>`),不修、不登记;`IN-04`/`IN-05` 不在本轮清单,只登记不修;`IN-02`(空存档不重置 `nextUid`)的生产代码已由用户在 `ba60ef9` 修复,本计划以**测试专用**方式纳入(Task 1),不改其生产逻辑;**`hero/equipStore.ts` 本计划零改动**(含 `nextUid` 段与 `getModifiers()`);`ReplayArray.setReplayArray` 的 `disabled = 0` 重置(`array.ts:856`)属 07-10/`data-common`,本计划不改" + - "A5(IN-02 测试专用,本计划授权;与 A1 并列的唯一被授权断言变更):`saveLoad.test.ts:447-457`(`warns code 58 when the max equipment uid cannot be found`)在 `ba60ef9` 之后**当前为 RED**(`vitest run …/hero/saveLoad.test.ts` → 1 failed / 26 passed),因为空存档已不再发码 58 且会重置 `nextUid`。本计划把该用例改写为新契约(空存档 → `nextUid` 归零、随后的 `add` 返回 0、不发码 58),并在 SUMMARY 登记一条**契约/覆盖变化**:`logger.error(58)` 分支(`equipStore.ts:300-309`)现在只剩「非空但 `maxBy` 仍返回 `undefined`」的畸形数组防御路径可达,码 58 不再由空存档用例覆盖。除该用例与该变化外,其余既有断言一字不改" + - "A6(新增边界语义):委托实现用 `arr[index]` 取代旧 `arr.splice(index, 1)` 后,索引越界/为负返回 `null` 且不删除任何修饰器——这是本次修复的确定性新语义(旧实现会静默删掉该属性的末尾修饰器),必须有断言固定;属**新增**边界断言,不是对既有断言的改写(`attribute.test.ts:189-204` 的按引用/按索引删除用例逐字保留)" prohibitions: - - "不改 `ReplayArray`/`ReplaySandbox`/`ReplaySystem`(`packages-user/data-common/src/replay/*`)——Q3 的修法只能在 `equipment.ts` 侧实现" - - "不改 `modifier.ts` 的 `BaseHeroModifier`(A3);不改 `attribute.ts` 的 `addModifier`/`deleteModifier`(只动 `deleteModifierByIndex` 与其余按 Q2 裁决点名的部分)" + - "不改 `ReplayArray`/`ReplaySandbox`/`ReplaySystem`(`packages-user/data-common/src/replay/*`)——Q3=A 只能走既有 `disable()`/`revert()` 通道;本计划对 `packages-user/data-common` 下任何文件零改动" + - "不改 `modifier.ts` 的 `BaseHeroModifier`(A3);不改 `attribute.ts` 的 `addModifier`/`deleteModifier`/`clone()`/`iterateModifiers`/`getModifierIndex`(只动 `deleteModifierByIndex` 的方法体,A3、A6)" + - "**不改 `hero/equipStore.ts`**:WR-04 按裁决不修(设计如此)、`EquipmentState.getModifiers()` 为非缺陷、IN-02 的生产逻辑已由用户在 `ba60ef9` 修复——本计划对该文件零改动" - "不改 `HeroState`/`equipStore` 的存读档**实例身份**语义(07-11 已定);本计划不重新引入 `clear()` + 重建" - "不改 `data-state`(`core.ts` / saveables 顺序)、不改渲染端 `@user/client-*` 与 legacy 接线" - "不引入新依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言" - - "不新增 `it.skip`;不为了转绿而弱化断言(A1、A5 授权的两处除外)" - - "IN-02 属**测试专用**:不得为 IN-02 改动 `HeroEquipsStore.loadState` 的 `nextUid` 分支(`equipStore.ts:300-309`)——`ba60ef9` 是用户已完成的生产修复基线,本计划只把测试对齐它;WR-04 对 `equipStore.ts` 存档源的改动不受此禁令限制" + - "不新增 `it.skip`;不为转绿弱化断言;**不翻转/不改写 `saveLoad.test.ts` 中任何 WR-04 相关断言**(唯一授权变更 = A5 的 IN-02 用例);SUMMARY 不得登记 WR-04 的断言翻转" + - "不得把 WR-04 或 `EquipmentState.getModifiers()` 当作缺陷写入 SUMMARY 或任何 known-issues / 登记列表(裁决:设计如此 / 非缺陷);WR-04 的唯一落笔形式是**非行为性契约注释**(`types.ts`)与 SUMMARY 的「设计如此」条目" + - "IN-02 属**测试专用**:不得为 IN-02 改动 `HeroEquipsStore.loadState` 的 `nextUid` 分支(`equipStore.ts:300-309`)——`ba60ef9` 是用户已完成的生产修复基线,本计划只把测试对齐它" --- -闭合装备/属性子系统的三条存档正确性缺陷:装备数值加成的**存档源**(WR-04)、修饰器删除的**簿记**(WR-05)、读档期的**录像副作用**(WR-06);并附一条**测试专用**的契约对齐(IN-02:空存档读档的 uid 计数器契约,生产代码已由用户在 `ba60ef9` 修复,本计划只把测试改写对齐,不重改生产逻辑)。 +闭合装备/属性子系统的**两条**存档正确性缺陷与**一条**相邻缺陷:修饰器删除的**簿记**(WR-05,Q2=C:改为委托 `deleteModifier`)、读档期的**录像副作用**(WR-06,Q3=A:用既有 `disable`/`revert` 包裹)、以及 `compareEquip` 的**索引错配**(Q4:仅修 (i));并附一条**测试专用**的契约对齐(IN-02)。装备存档源(WR-04)经用户裁决为**设计如此、不是缺陷**——本计划**不做任何代码改动、不翻转任何断言**(仅在 `types.ts` 落一条非行为性契约注释);`EquipmentState.getModifiers()` 经裁决为**非缺陷**,从范围与任何登记列表中移除。 现状(逐条锚点): -1. **WR-04(warning,`equipStore.ts:34-36, 67-102, 259-288`)** `this.value`/`this.percentage` 只在构造期从 `item.equip` 拷贝、此后只被另一次读档覆盖;活修饰器值(`BaseHeroModifier.setValue`,`modifier.ts:25-28`)从不回流。后果:`saveDiff()`(`:80-102`)与 `item.equip` 比较恒相等 → 低/高压缩档「无差异」→ 装备状态**一个字节都不存**;`saveNoCompression()`(`:67-74`)存的也是 `this.value` 而非修饰器值 → 无压缩档同样丢弃运行时变更。`07-REVIEW.md` 指出 `saveLoad.test.ts:343-380` 恰好把这个错误行为写成了断言(改修饰器到 0.9/99,断言读回 0.5/5)。 -2. **WR-05(warning,`attribute.ts:230-239`)** `deleteModifierByIndex()` 只 `splice`:不 `bindAttribute(null)`、不清理 `modifierName`/`modifierNosave`、不 `markDirty`。后果:`iterateModifiers()`/`getModifierIndex()` 仍能看到已删除的修饰器;被删修饰器 `owner` 残留 → 再次 `addModifier` 被码 108 拒绝(永久不可用);`finalAttribute[name]` 陈旧。唯一调用点 `compareEquip`(`equipment.ts:281-284`)。 -3. **WR-06(warning,`equipment.ts:336-346`)** `loadState()` 通过公开 `equip(uid, index)`(`:168-196`,末尾 `:195` 有 `replay.route.add(ReplayCommandCode.Equip, [uid])`)恢复槽位,读档因此向活录像追加 `Equip` 指令。今日只被 `CoreState.loadState` 的 saveable 顺序掩盖(hero 在 replay 之前,随后被覆盖),英雄单独读档或存档缺 `@system/replay` 时污染录像。 -4. **IN-02(info,`equipStore.ts:282-287` 原文 → 现 `:300-309`)** 原缺陷:空存档时 `maxBy` 返回 `undefined`、发码 58 并提前 `return`,`nextUid` 停在陈旧值。**生产代码已由用户在 `ba60ef9` 修复**:`state.equipments.length === 0` → `this.nextUid = 0`;否则 `maxBy` 取 `maxUid.uid + 1`,取不到才 `logger.error(58)`。但 `saveLoad.test.ts:447-457` 仍按旧契约断言(空存档 → `toContain(58)`),因此该文件**当前为 RED**(`vitest run …/hero/saveLoad.test.ts` → 1 failed / 26 passed);且该用例必须先转绿,否则后续 Task 的 `saveLoad.test.ts` 级 `` 会被这条**既存失败**阻塞。本计划把该用例改写为新契约(空存档 → `nextUid` 归零、随后的 `add` 返回 0、不发码 58),并登记随之而来的契约/覆盖变化。 +1. **WR-04(用户裁决 2026-09-17:设计如此,非缺陷——本计划不修,无断言翻转)** `07-REVIEW.md:184-194` 认为 `this.value`/`this.percentage` 只在构造期从 `item.equip` 拷贝、活修饰器值从不回流,故 `saveDiff()`(`equipStore.ts:80-102`)恒为空、压缩档「不持久化装备状态」。用户裁决否定了该前提:**修饰器是被动的派生视图**,装备自身属性(`this.value`/`this.percentage`)才是**事实源**;存档持久化的正是这两个表——无压缩档 `saveNoCompression()`(`:67-74`)全量、压缩档 `saveDiff()`(`:80-102`)只写与定义的差异,`loadState` 的 `loadNoCompression`/`loadDiff`(`:116-153`)再由它们 `rebuildModifiers()`(`:46-55`)重建修饰器。因此:(a)「活值与定义一致时 `saveDiff` 为空」是**正确行为**(若经装备自身把 `value`/`percentage` 改到与定义不同,`saveDiff` 会如实捕获该差异——这正是它存在的意义);(b) 读档后修饰器是**重建出来的新对象**,跨读档丢失旧修饰器引用属**预期**;(c) 运行时若要改变某件装备的加成,必须**经装备自身(`value`/`percentage`)**,而不是去改它的修饰器。既有断言 `saveLoad.test.ts:229-245`(改到 0.9 后读回 0.5)与 `:352-403`(改到 99 后读回 5)恰好把这个契约写对了,**逐字保留**;`equipStore.test.ts:245-269` 的「同实例 + 装备值恢复」亦保持。本计划对该条的唯一动作:`types.ts` 的非行为性契约注释(Task 2 步骤 (3))+ SUMMARY「设计如此」登记。 +2. **WR-05(warning,`attribute.ts:230-239`)** `deleteModifierByIndex()` 只 `splice`:不 `bindAttribute(null)`、不清理 `modifierName`/`modifierNosave`、不 `markDirty`。后果:`iterateModifiers()`/`getModifierIndex()` 仍能看到已删除的修饰器;被删修饰器 `owner` 残留 → 再次 `addModifier` 被码 108 拒绝(永久不可用);`finalAttribute[name]` 陈旧;`modifierNosave` 残留。唯一调用点 `compareEquip`(`equipment.ts:281-284`)。裁决 Q2=C:改为委托 `deleteModifier(name, modifier)`(单一簿记),并保留返回的被删修饰器。 +3. **WR-06(warning,`equipment.ts:337-347`)** `loadState()` 通过公开 `equip(uid, index)`(`:153-198`,末尾 `:195` 有 `replay.route.add(ReplayCode.Equip, [uid])`)恢复槽位,读档因此向活录像追加 `Equip` 指令(`unequip` 路径还会追加 `Unequip`)。今日只被 `CoreState.loadState` 的 saveable 顺序掩盖(hero 在 replay 之前,随后被覆盖),英雄单独读档或存档缺 `@system/replay` 时污染录像。裁决 Q3=A:用既有 `replay.disable()`/`replay.revert()` 包裹读档循环。 +4. **compareEquip 索引错配(`equipment.ts:281-284`,Q4 仅修 (i))** 该循环先算 `this.attribute.getModifierIndex(modifier)`(**原属性**上的索引),再调 `clone.deleteModifierByIndex(name, index)`(**克隆属性**上的删除)。当该索引为 `-1`(原属性不含该修饰器对象——例如装备实例刚经 `store.loadState` 走了 `rebuildModifiers()`、属性上仍是旧对象)时,旧实现 `arr.splice(-1, 1)` 会**静默删掉克隆中该属性的末尾修饰器**,删错对象使对差值偏离。注意 `clone()`(`attribute.ts:293-311`)只重建 `modifier`、**不复制** `modifierName`,且克隆体内的修饰器是 `clone()` 出的**新对象**,故不得改用 `clone.getModifierIndex(...)`。 +5. **`EquipmentState.getModifiers()`(`equipStore.ts:57-61`,Q4 裁决为非缺陷)** 它返回内部数组是**不是缺陷**:声明返回类型为 `Iterable<[SelectKey, IHeroModifier]>`(`types.ts:663`),调用方无法经该类型把它转成数组或改动内部数组。本计划**不修**,也**不登记**。 +6. **IN-02(info,`equipStore.ts:282-287` 原文 → 现 `:300-309`)** 原缺陷:空存档时 `maxBy` 返回 `undefined`、发码 58 并提前 `return`,`nextUid` 停在陈旧值。**生产代码已由用户在 `ba60ef9` 修复**:`state.equipments.length === 0` → `this.nextUid = 0`;否则 `maxBy` 取 `maxUid.uid + 1`,取不到才 `logger.error(58)`。但 `saveLoad.test.ts:447-457` 仍按旧契约断言(空存档 → `toContain(58)`),因此该文件**当前为 RED**(`vitest run …/hero/saveLoad.test.ts` → 1 failed / 26 passed);且该用例必须先转绿,否则 Task 5 的全量门禁被这条**既存失败**阻塞。本计划把该用例改写为新契约(空存档 → `nextUid` 归零、随后的 `add` 返回 0、不发码 58),并登记随之而来的契约/覆盖变化。 -Purpose: 装备是属性加成的来源,装备存档一旦不落地,「读档后数值正确」就只在无压缩档的部分路径上成立;删除簿记不全则修饰器图与访问器视图分叉;读档写录像会让「读档」这个只读语义产生可回放的历史。三者都在存档边界上,属同一主题。IN-02 则不同:生产行为已正确,缺的是**测试仍锚定旧契约**——不修则套件持续为红、也失去对 `ba60ef9` 的回归保护。 +Purpose: 修饰器删除簿记不全则属性图与访问器视图分叉;读档写录像会让「读档」这个只读语义产生可回放的历史;`compareEquip` 用原属性的索引删克隆属性则可能删错修饰器。三者都在装备/属性的存档边界上,属同一主题。IN-02 则不同:生产行为已正确,缺的是**测试仍锚定旧契约**——不修则套件持续为红、也失去对 `ba60ef9` 的回归保护。 -Output: `equipStore.ts`(存档源)+ `attribute.ts`(簿记)+ `equipment.ts`(读档无副作用)+ `types.ts`(契约 jsDoc)+ 三份测试文件的回归 + IN-02 测试契约对齐(`saveLoad.test.ts:447-457` 改写,把当前 RED 转绿)。 +Output: `attribute.ts`(委托簿记)+ `equipment.ts`(读档无录像副作用 + 克隆侧定位)+ `types.ts`(契约 jsDoc,含 WR-04 的非行为性契约注释)+ 三份测试文件的回归 + IN-02 测试契约对齐(把当前 RED 转绿)。**WR-04 无代码改动。** -**Artifacts this phase produces(本计划产出物)**:修改文件见 frontmatter `files_modified`;回归用例见 `must_haves.artifacts`;IN-02 为**测试专用**条目(不新增文件、不改其生产逻辑);无新建源码文件、无新增依赖。 +**Artifacts this phase produces(本计划产出物)**:修改文件见 frontmatter `files_modified`(3 个源码 + 3 个测试文件);回归用例见 `must_haves.artifacts`;IN-02 为**测试专用**条目(不新增文件、不改其生产逻辑);WR-04 的产出只有一条**非行为性契约注释**(`types.ts`)与 SUMMARY 的「设计如此」登记;`hero/equipStore.ts` 与 `packages-user/data-common` 零改动;无新建源码文件、无新增依赖。 **Phase 7 验证已失效**:`07-VERIFICATION.md`(2026-09-16T06:04:11Z)已因 07-09 与本轮 07-10..07-14 失效。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论。 -Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个计划,承接 `07-REVIEW.md` 的 WR-04/WR-05/WR-06。D-02: 属 hero 系统(`packages-user/data-base/src/hero`),与 07-04(hero 存读档与属性/槽位)、07-09(属性同引用)、07-11(容器同引用)同系统、按严重度递减排序。D-03/D-04/D-05/D-06: 其它系统契约裁决,不涉及(`HeroEquipment` 的码 146/147 语义不变,147 保持 D-06 的保留码状态)。D-07: path 接线,不涉及。D-08: enemy,不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划,回归用例为必需项(A1;IN-02 测试对齐见 A5)。D-11: 存档源语义变更必须同步 `types.ts` 的 jsDoc。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按根因原子提交(Task 2 / 3 / 4 分别对应 WR-04 / WR-05 / WR-06;Task 1 的 IN-02 测试对齐单独一条 `test(07-12): …` 提交,不与其根因混提)。D-14: 三条均无 `WINDOWS.md` 条目,只登记不新建。 +Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个计划,承接 `07-REVIEW.md` 的 WR-05/WR-06 与执行期发现的 `compareEquip` 索引错配(WR-04 经用户 2026-09-17 裁决为「设计如此」,不再是缺陷)。D-02: 属 hero 系统(`packages-user/data-base/src/hero`),与 07-04(hero 存读档与属性/槽位)、07-09(属性同引用)、07-11(容器同引用)同系统。D-03/D-04/D-05/D-06: 其它系统契约裁决,不涉及(`HeroEquipment` 的码 146/147 语义不变,147 保持 D-06 的保留码状态)。D-07: path 接线,不涉及。D-08: enemy,不涉及。D-09: 由 Task 0 关卡承载,裁决已于 2026-09-17 填入 ``(Q1=C 设计如此 / Q2=C 委托 / Q3=A disable-revert / Q4=B 仅修 (i))。D-10: 本计划为修复计划,回归用例为必需项(A1;唯一被授权的既有断言变更见 A5)。D-11: `deleteModifierByIndex` 的口径变更同步 `types.ts` jsDoc;WR-04 的契约以**非行为性注释**写入同一文件。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按根因原子提交(Task 1 = `test(07-12): …` IN-02 测试对齐,不与根因混提;Task 2 = `fix(07-12): … WR-05` + 独立 `docs(07-12): … WR-04 契约注释`;Task 3 = `fix(07-12): … WR-06`;Task 4 = `fix(07-12): … compareEquip 索引`)。D-14: WR-05/WR-06/compareEquip 均无 `WINDOWS.md` 条目,只登记不新建;**WR-04 与 `EquipmentState.getModifiers()` 不进任何登记列表**。 -执行序:`depends_on: 07-11`(Wave 12)。除门禁型依赖(D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录)外,还有**文件重叠的隐式依赖**:`hero/equipStore.ts`、`hero/types.ts`、`hero/saveLoad.test.ts` 与 07-11 的 `files_modified` 重叠,同波并行会互相覆盖,故必须排在 07-11 之后。 +执行序:`depends_on: 07-11`(Wave 12)。除门禁型依赖(D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录)外,还有**文件重叠的隐式依赖**:`hero/types.ts`、`hero/saveLoad.test.ts` 与 07-11 的 `files_modified` 重叠,同波并行会互相覆盖,故必须排在 07-11 之后。 -**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147,D-06)。**自 `ba60ef9` 起**:`saveLoad.test.ts` 的 IN-02 用例转红(单文件 1 failed / 26 passed),故当日全量基线实为「689 通过 / 1 失败(IN-02,`saveLoad.test.ts:447-457`)/ 1 跳过」。执行前重录当日基线并与本行对比;**该 1 条失败是本计划 Task 1 的预期输入**(Task 1 把它转绿),而 Task 5 的全量门禁要求 0 失败、跳过项仍只有 `equipment.test.ts:306` 一条。 +**基线(2026-09-16 实测)**:`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147,D-06)。**自 `ba60ef9` 起**:`saveLoad.test.ts` 的 IN-02 用例转红(单文件 1 failed / 26 passed),故当日全量基线实为「689 通过 / 1 失败(IN-02,`saveLoad.test.ts:447-457`)/ 1 跳过」。执行前重录当日基线并与本行对比;**该 1 条失败是本计划 Task 1 的预期输入**(Task 1 把它转绿),而 Task 5 的全量门禁要求 0 失败、跳过项仍只有 `equipment.test.ts:306` 一条(通过数因本轮新增回归用例而上升)。 @@ -113,116 +117,125 @@ Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个 @packages-user/data-base/src/hero/equipment.ts @packages-user/data-base/src/hero/types.ts @packages-user/data-base/src/hero/saveLoad.test.ts +@packages-user/data-base/src/hero/equipment.test.ts +@packages-user/data-base/src/hero/attribute.test.ts -**执行起点提示(IN-02,测试专用)**:`packages-user/data-base/src/hero/saveLoad.test.ts` **当前为 RED**——`warns code 58 when the max equipment uid cannot be found`(`:447-457`)仍按旧契约断言「空存档 → 出现码 58」,而生产代码已在用户提交 `ba60ef9`(`equipStore.ts:300-309`:空存档 → `nextUid = 0`,不再发码 58)中修复,故 `pnpm exec vitest run …/hero/saveLoad.test.ts` = 1 failed / 26 passed。`ba60ef9` 是 Task 1 测试调整的**基线**:Task 1 改写该用例使其对齐新契约,从而把该文件转绿(并解除对 Task 2 / Task 4 的 `saveLoad.test.ts` 级 `` 的既存失败阻塞);本计划**不**再改动 IN-02 的生产逻辑。 +**执行起点提示(裁决,2026-09-17)**:Task 0 的 `` 已由计划修订填入用户裁决,执行者**核对记录后直接进入 Task 1**,不再重复询问。要点:**WR-04 不修**(设计如此——装备自身 `value`/`percentage` 为事实源、修饰器为派生视图、读档由它们重建修饰器;仅允许 `types.ts` 的非行为性契约注释);`deleteModifierByIndex` **委托** `deleteModifier`(Q2=C);`loadState` 用既有 `disable`/`revert` 包裹(Q3=A);`compareEquip` **仅修 (i)** 索引错配(Q4=B),`EquipmentState.getModifiers()` 非缺陷、不修不登记。 + +**执行起点提示(IN-02,测试专用)**:`packages-user/data-base/src/hero/saveLoad.test.ts` **当前为 RED**——`warns code 58 when the max equipment uid cannot be found`(`:447-457`)仍按旧契约断言「空存档 → 出现码 58」,而生产代码已在用户提交 `ba60ef9`(`equipStore.ts:300-309`:空存档 → `nextUid = 0`,不再发码 58)中修复,故 `pnpm exec vitest run …/hero/saveLoad.test.ts` = 1 failed / 26 passed。`ba60ef9` 是 Task 1 测试调整的**基线**:Task 1 改写该用例使其对齐新契约,从而把该文件转绿(并解除对 Task 5 全量门禁的既存失败阻塞);本计划**不**再改动 IN-02 的生产逻辑。注意:`saveLoad.test.ts` 在本计划中**只被 Task 1 触碰**(WR-04 不改断言、WR-06 的回归放 `equipment.test.ts`)。 - Task 0: 契约裁决关卡(D-09)——装备存档源(活值 vs 定义差异)、`deleteModifierByIndex` 簿记范围、读档禁录方式、相邻缺陷(`compareEquip` 索引错配 / `getModifiers` 暴露内部数组)是否纳入 + Task 0: 契约裁决关卡(D-09)——装备存档源(活值 vs 定义差异)、`deleteModifierByIndex` 簿记范围、读档禁录方式、相邻缺陷(`compareEquip` 索引错配 / `getModifiers` 暴露内部数组)是否纳入【已于 2026-09-17 裁决:Q1=C 设计如此 / Q2=C 委托 / Q3=A disable-revert / Q4=B 仅修 (i);选项块保留为历史记录】 (只读汇报,不修改任何文件) - - packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState`::19-24 `modifiers`/`value`/`percentage`、:26-41 构造器、:46-55 `rebuildModifiers`、:63-102 `saveNoCompression`/`saveDiff`、:104-164 `saveState`/`loadNoCompression`/`loadDiff`/`loadState`) - - packages-user/data-base/src/hero/modifier.ts(`BaseHeroModifier` 的 `value`/`setValue`/`getValue`/`saveState`/`loadState`) - - packages-user/data-base/src/hero/attribute.ts(:190-211 `addModifier` 的 `modifier.owner` 拒绝与码 108、:213-228 `deleteModifier` 完整簿记、:230-239 `deleteModifierByIndex` 缺陷本体、:241-249 `markDirty`/`markModifierDirty`) - - packages-user/data-base/src/hero/equipment.ts(:153-198 `equip`(:169-170 disable、:194 revert、:195 route.add)、:200-218 `unequip`(:211-212 route.add)、:255-326 `compareEquip`(:281-284 索引错配)、:330-347 `saveState`/`loadState`) - - packages-user/data-base/src/hero/types.ts(:622-631 `IEquipmentStateSave`、:638-650 `IEquipmentState`、:24-68 `IHeroModifier` 与 `modifier.saveState` 契约) - - packages-user/data-base/src/hero/saveLoad.test.ts(:220-240 `roundTripPercentageModifier`、:341-380 三档百分比与数值加成用例 —— 「读档回到原始定义值」的断言所在) - - packages-user/data-base/src/hero/equipment.test.ts(:102-117 `createEnv` 的 `replaySystem` 桩:`route.add`/`disable`/`revert` 均为 `vi.fn()`) + - packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState`::19 `modifiers`、:22-24 `value`/`percentage`、:26-41 构造器、:46-55 `rebuildModifiers`、:57-61 `getModifiers`、:67-102 `saveNoCompression`/`saveDiff`、:104-164 `saveState`/`loadNoCompression`/`loadDiff`/`loadState`) + - packages-user/data-base/src/hero/modifier.ts(`BaseHeroModifier` 的 `value`/`setValue`/`getValue`/`saveState`/`loadState`、`ValueModifier`/`PercentageModifier` 的 `clone()`) + - packages-user/data-base/src/hero/attribute.ts(:51-57 私有表、:169-188 `iterateModifiers`/`getModifiers`/`getModifierIndex`、:190-211 `addModifier` 的 `modifier.owner` 拒绝与码 108、:213-228 `deleteModifier` 完整簿记、:230-239 `deleteModifierByIndex` 缺陷本体、:241-249 `markDirty`/`markModifierDirty`、:293-311 `clone()`(不复制 `modifierName`)) + - packages-user/data-base/src/hero/equipment.ts(:153-198 `equip`(:169-170 disable、:194 revert、:195 route.add)、:200-218 `unequip`(:210-212 route.add)、:255-326 `compareEquip`(:281-284 索引错配)、:330-347 `saveState`/`loadState`) + - packages-user/data-base/src/hero/types.ts(:636-645 `IEquipmentStateSave`、:652-664 `IEquipmentState`(:663 `getModifiers` 声明)、:211-230 `deleteModifier`/`deleteModifierByIndex`、:24-68 `IHeroModifier` 与 `modifier.saveState` 契约) + - packages-user/data-base/src/hero/saveLoad.test.ts(:229-245 `roundTripPercentageModifier`、:350-403 三档百分比与数值加成用例 —— 「读档回到原始定义值」的断言所在,WR-04 裁决后**保持原样**) + - packages-user/data-base/src/hero/equipment.test.ts(:50-57 `TestEnv`、:102-117 `createEnv` 的 `replaySystem` 桩:`route.add`/`disable`/`revert` 均为 `vi.fn()`) - .planning/phases/07-data-fixes/07-REVIEW.md(WR-04、WR-05、WR-06 原文与建议修法) - - packages-user/data-common/src/replay/system.ts(:89-95 `disable`/`revert` 转发到 `ReplayArray`) + - packages-user/data-common/src/replay/system.ts(:89-95 `disable`/`revert` 转发到 `ReplayArray`)与 `array.ts`(:890-897 `disabled` 计数) - 装备/属性存档正确性三条修复中的契约点: - (Q1)装备存档的**事实源**是什么(活修饰器值 vs `this.value`/`this.percentage` 同步 vs 明确不持久化);(Q2)`deleteModifierByIndex` 的簿记范围;(Q3)读档期间**禁用录像记录**的方式;(Q4)两处相邻缺陷是否在本计划一并修复。 + 装备/属性存档正确性修复中的契约点(**已于 2026-09-17 裁决,见 ``**): + (Q1)装备存档的**事实源**是什么(活修饰器值 vs `this.value`/`this.percentage` 同步 vs 装备自身属性为事实源、修饰器为派生视图);(Q2)`deleteModifierByIndex` 的簿记范围;(Q3)读档期间**禁用录像记录**的方式;(Q4)两处相邻缺陷(`compareEquip` 索引错配 / `getModifiers` 暴露内部数组)是否在本计划一并修复。 - Q1 决定装备加成的存档语义与**既有断言的命运**:`saveLoad.test.ts:341-380` 现在把「运行时 `setValue` 不落地」写成断言(改到 0.9/99,读回 0.5/5)。若选「活值为源」,这两条正确预期必须翻转为「读回 0.9/99」——属 D-10 的授权例外;若选「不持久化」,则这两条保持并需在 `IEquipmentState` 写明契约。 - Q2 决定删除 API 的可观测副作用(`iterateModifiers`/`getModifierIndex`/`final`/能否重新挂载)。Q3 决定读档期间「录像不记录」是**借用既有 disable 通道**(会把 `disabled` 计数短暂置高)还是**改走无记录助手**(`equip` 与 `loadState` 共用逻辑,行为面更大)。Q4 是执行期阅读发现的两处相邻缺陷,按「不得擅自扩大修复面」的约定须由用户决定纳入或登记。 + **本关卡已裁决(2026-09-17):下述选项块保留为历史记录与决策依据,不再需要用户在场确认;执行者核对 `` 后直接进入 Task 1。** + Q1 决定装备加成的存档语义与**既有断言的命运**:`saveLoad.test.ts:350-403` 现在把「运行时改修饰器不落地」写成断言(改到 0.9/99,读回 0.5/5)。用户裁决为**设计如此**(装备自身 `value`/`percentage` 是事实源、修饰器只是派生视图、读档由装备属性重建修饰器),故这两条断言**保持原样**,不翻转、不弱化。 + Q2 决定删除 API 的可观测副作用(`iterateModifiers`/`getModifierIndex`/`final`/能否重新挂载)。Q3 决定读档期间「录像不记录」是**借用既有 disable 通道**(会把 `disabled` 计数短暂置高)还是**改走无记录助手**(`equip` 与 `loadState` 共用逻辑,行为面更大)。Q4 是执行期阅读发现的两处相邻缺陷,按「不得擅自扩大修复面」的约定由用户裁决:(i) 修,(ii) 经复核**不是缺陷**(声明返回 `Iterable<...>`),既不入范围也不入登记。 + (**历史选项说明**:本块为裁决时的选项记录。除标注【已选】者外,其余选项一律为**未选·历史**,仅保留决策依据;`revise` 兜底未触发。裁决结果见 ``。) - 待用户在关卡处逐条填写(Q1..Q4:勾选选项 id 或给出裁决文字;Q4 若选 B/C 请写清具体哪一处修、哪一处登记)。 - **空的 `` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。 - 填写后 Task 2–4 一律按本记录执行;若裁决与 Task 2–4 的分支描述冲突,暂停并请用户澄清。 + 用户裁决(2026-09-17,逐条;本节由计划修订填入,执行者核对后直接进入 Task 1,不再重复询问): + - **Q1 = C「设计如此」——WR-04 不是缺陷**:设计契约是——**修饰器是被动的派生视图**,**装备自身的属性(`this.value` / `this.percentage`)是事实源**;存档持久化的就是这两个表(无压缩档 `saveNoCompression` 全量、压缩档 `saveDiff` 只写与定义的差异),`loadState` 的 `loadNoCompression`/`loadDiff` 由它们 `rebuildModifiers()` 重建修饰器。因此**跨读档丢失修饰器引用是预期行为**,「活值与定义一致时 `saveDiff` 为空」是正确行为;运行时若要改变某件装备的加成,必须**经装备自身(`value` / `percentage`)**,**不得**通过修改它的修饰器来「改变装备」。据此:**WR-04 不修**——**无生产代码改动、无断言翻转**(`saveLoad.test.ts:229-245`、`:350-403` 逐字保留),唯一允许的落笔是一条**非行为性契约注释**(写在 `types.ts` 的 `IEquipmentStateSave` / `IEquipmentState` jsDoc 上,见 Task 2 步骤 (3)),并在 SUMMARY 与登记段落记为「设计如此 / 非缺陷」。 + - **Q2 = C(委托 `deleteModifier`)**:`HeroAttribute.deleteModifierByIndex` 按索引取出修饰器后**委托** `deleteModifier(name, modifier)`(复用唯一一份簿记实现),并**保留返回的被删修饰器**——`deleteModifier` 返回 `void`,故必须先取出 `arr[index]` 再委托、并把它作为返回值;索引越界/为负时返回 `null` 且**不删除任何修饰器**(旧 `splice(index, 1)` 的负索引会静默删掉末尾修饰器)。 + - **Q3 = A(复用既有 disable/revert)**:`HeroEquipment.loadState` 用既有的 `replay.disable()` / `replay.revert()` 包裹重新装备循环(与 `equip()` 内部同一通道,`equipment.ts:169-170` / `:194`),用 `try/finally` 保证所有路径都 `revert`;**不**新增无记录助手、不改 `packages-user/data-common`。 + - **Q4 = B(只修 (i))**:(i) **修** `compareEquip` 的索引错配(`equipment.ts:281-284`)——它把在原属性上算出的 `getModifierIndex` 结果用在**克隆属性**上删除;注意 `clone()`(`attribute.ts:293-311`)**不复制** `modifierName`,且克隆体内的修饰器是 `clone()` 出的**新对象**,故**不得**改用 `clone.getModifierIndex(...)`;改为在克隆属性上**按对象**定位待删修饰器(取克隆自己同槽位的对象),并在原属性索引为 `-1` / 槽位越界时**不删除任何修饰器**。(ii) `EquipmentState.getModifiers()`(`equipStore.ts:57-61`)返回内部数组**不是缺陷**——其声明返回类型是 `Iterable<[SelectKey, IHeroModifier]>`(`types.ts:663`),调用方无法经该类型把它转成数组或改动内部数组;**从本计划范围中移除,且不得写入任何 known-issues / 登记列表**(连「已登记」都不写)。 + 填写后 Task 1–5 一律按本记录执行;若执行期发现与本记录冲突,暂停并请用户澄清。 - 逐条给出 Q1..Q4 的裁决(例如「Q1=A, Q2=A, Q3=A, Q4=B」);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md - 向用户汇报上述 4 个契约点与各自的直接后果(尤其 Q1 对 `saveLoad.test.ts:341-380` 断言的影响、Q3 对录像语义的影响)。不得在裁决前修改任何文件。 + 已裁决(2026-09-17):Q1=C, Q2=C, Q3=A, Q4=B(仅 (i))。执行者核对 `` 后直接进入 Task 1;若记录与用户实际意图不符,暂停并请用户澄清后再继续 + 本关卡已裁决:向执行者转述 `` 的四条口径(含「WR-04 不修、只许非行为性契约注释」与「`getModifiers()` 非缺陷、不修不登记」两条否定性口径),核对无误后直接进入 Task 1。执行者**不得**在 Task 1 之前修改任何文件,也**不得**按「建议默认」以外的未选选项推进。 - `` 中 Q1..Q4 均有明确裁决(Q4 已点名具体处置),且与用户实际意图一致 + `` 中 Q1..Q4 均有明确裁决(含 Q1「设计如此、不修、不翻转断言」与 Q4「仅修 (i)、(ii) 非缺陷不登记」两条否定性口径),且与用户实际意图一致 记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进 - - Q1(装备存档事实源)、Q2(`deleteModifierByIndex` 簿记范围)、Q3(读档禁录方式)、Q4(相邻缺陷处置)均有用户裁决并写入 `` - - Q1 的既有断言影响已明示(翻转 `saveLoad.test.ts:341-380` 属 A1 授权例外) - - 未在裁决前修改任何文件 + - Q1(装备存档事实源 = 装备自身属性,设计如此)、Q2(委托 `deleteModifier`)、Q3(既有 disable/revert 包裹)、Q4(仅修 (i) 索引错配;`getModifiers()` 非缺陷)四条裁决均已写入 `` + - Q1 的既有断言影响已明示:**`saveLoad.test.ts:229-245`、`:350-403` 保持原样,不翻转**(原「活值翻转」授权随之作废,见 A1) + - 未在 Task 1 之前修改任何文件 Q1..Q4 裁决已完整写入 ``;执行者据记录直接进入 Task 1,不再重复询问 @@ -237,7 +250,7 @@ Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个 - .planning/phases/07-data-fixes/07-REVIEW.md(IN-02 原文:`:247-251`;`Fix: set this.nextUid = 0 in the empty branch`) - **背景与边界**:IN-02 的**生产修复已由用户在 `ba60ef9` 完成**(`equipStore.ts:300-309`:空存档 → `nextUid = 0`;否则 `maxUid.uid + 1`,取不到才 `logger.error(58)`)。本任务**只改测试**:不得为 IN-02 修改 `equipStore.ts` 的任何生产逻辑,也不得顺手改动 `nextUid` 分支的写法。本任务与 Task 0 的 Q1..Q4 裁决无关(生产行为已定),但按文档顺序在 Task 0 关卡之后执行。**先做本任务**:`saveLoad.test.ts` 现有 1 条 RED,若不先转绿,Task 2 / Task 4 对该文件的 `` 会被这条既存失败阻塞。 + **背景与边界**:IN-02 的**生产修复已由用户在 `ba60ef9` 完成**(`equipStore.ts:300-309`:空存档 → `nextUid = 0`;否则 `maxUid.uid + 1`,取不到才 `logger.error(58)`)。本任务**只改测试**:不得为 IN-02 修改 `equipStore.ts` 的任何生产逻辑,也不得顺手改动 `nextUid` 分支的写法。本任务与 Task 0 的 Q1..Q4 裁决无关(生产行为已定,且四条裁决都不触及 `nextUid` 段),但按文档顺序在 Task 0 关卡之后执行。**先做本任务**:`saveLoad.test.ts` 现有 1 条 RED,若不先转绿,Task 5 的 `pnpm test:ci` 全量门禁会被这条既存失败阻塞(本计划中 `saveLoad.test.ts` 只被本任务触碰)。 (1) 改写 `saveLoad.test.ts:447-457` 的用例:夹具改为与 `:408-423` 同口径(先 `registerItem(env, createEquipItem(10, 'sword', [0], [['atk', 5]]))`,让 `add(10)` 可发号),用例名改为反映新契约(如 `resets the uid counter when the save has no equipment`); (2) 用 `logger.catch(() => env.store.loadState({ equipments: [] }, SaveCompression.NoCompression))` 读入空存档后,断言两件事:(a) 计数器重置**可观测**——`expect(env.store.add(10)).toBe(0)`(唯一可观测后果,见 frontmatter `must_haves.key_links`);(b) 该路径**不产生码 58**——`expect(result.info.map(info => info.code)).not.toContain(58)`。只断言 (b) 不足以证明重置发生,两条都必须有; (3) 在该用例内用一行注释登记**契约/覆盖变化**:`logger.error(58)` 现在只剩「非空数组但 `maxBy` 仍返回 `undefined`(畸形/含无效条目)」的防御路径可达,不再是空存档的行为;注释须写明该分支属 `equipStore.ts` 的用户实现、保持原样(防止后续被误当作死代码删除),并在 Task 5 的 SUMMARY 登记段落复述同一结论; @@ -256,128 +269,131 @@ Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个 - Task 2: WR-04 —— 装备存档源改为活修饰器值(按 Q1 裁决),三档压缩下运行时变更可往返或契约明写 - packages-user/data-base/src/hero/equipStore.ts, packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/saveLoad.test.ts + Task 2: WR-05 —— `HeroAttribute.deleteModifierByIndex` 改为「按索引取出 → 委托 `deleteModifier` → 返回该修饰器」(Q2=C),并同步 `types.ts` 契约 jsDoc(含 WR-04 的非行为性契约注释) + packages-user/data-base/src/hero/attribute.ts, packages-user/data-base/src/hero/types.ts, packages-user/data-base/src/hero/attribute.test.ts - - packages-user/data-base/src/hero/equipStore.ts(:15-165 `EquipmentState` 全量:`value`/`percentage` 私有表、`rebuildModifiers`、三档 save/load) - - packages-user/data-base/src/hero/modifier.ts(`saveState`/`loadState`/`getValue` 签名) - - packages-user/data-base/src/hero/types.ts(:622-631 `IEquipmentStateSave` —— 形状与 jsDoc 位置) - - packages-user/data-base/src/hero/saveLoad.test.ts(:220-240 百分比往返夹具、:341-380 三档用例 —— Q1=A 时需翻转的断言) - - packages-user/data-common/src/save/types.ts(`SaveCompression` 三档枚举) + - packages-user/data-base/src/hero/attribute.ts(:51-57 私有表 `modifier`/`modifierName`/`modifierNosave`、:169-188 `iterateModifiers`/`getModifiers`/`getModifierIndex`、:190-211 `addModifier`(:195-199 码 108 拒绝)、:213-228 `deleteModifier` 的完整簿记、:230-239 `deleteModifierByIndex` 缺陷本体) + - packages-user/data-base/src/hero/attribute.test.ts(:141-147 空修饰器安全默认、:149-204 修饰器管理(:189-204 按引用/按索引删除)、:206-220 存盘开关) + - packages-user/data-base/src/hero/types.ts(:211-230 `deleteModifier`/`deleteModifierByIndex` 的声明与 jsDoc;:636-645 `IEquipmentStateSave`、:652-664 `IEquipmentState` 的 jsDoc 位置) + - packages-user/data-base/src/hero/equipment.ts(:255-326 `compareEquip` —— 唯一调用 `deleteModifierByIndex` 的生产代码,用于确认委托后其可观测结果不变) + - .planning/phases/07-data-fixes/07-REVIEW.md(WR-05 原文与建议修法) - 按 Task 0 的 Q1 裁决实现。**若 Q1 = 选项 A(活值为源)**: - (1) `saveNoCompression()`(`:67-74`):`value`/`percentage` 两个 Map 改为由 `[...this.getModifiers()]` 生成——按修饰器类型分流(`ValueModifier` → `value`、`PercentageModifier` → `percentage`;判定方式沿用 `rebuildModifiers` 的既有分类口径或用修饰器 `modifier.saveState(compression)` 的返回值),键为属性名、值为 `modifier.saveState(compression)`; - (2) `saveDiff()`(`:80-102`):以 `item.equip.value`/`item.equip.percentage`(原始定义)为基准,仅写入与**当前活值**不同的条目; - (3) `loadNoCompression`/`loadDiff`(`:112-153`):保持「清空 → 按(定义或空)+ 存档差异重建表 → `rebuildModifiers()`」的结构(`loadDiff` 的定义回退是 `#06-09-1` 的修复成果,不得回退),使重建出的修饰器值即为存档点的活值;如需在重建后逐个 `modifier.loadState(value, compression)` 保证与 `IEquipmentStateSave` 的形状解耦,则按该方式实现并保持 `rebuildModifiers` 的一致性; - (4) `IEquipmentStateSave` 的 `value`/`percentage` jsDoc(`types.ts:627-630`)改写为「当前/差异的加成值(事实源为活修饰器)」;若 `IEquipmentState` 的 jsDoc 已描述存档语义则同步(`types.ts:638-650`); - (5) **A1 授权的断言翻转**:`saveLoad.test.ts` 的 `roundTripPercentageModifier`(`:220-240`)与 `:341-380` 三档用例由「读回原始定义值(0.5 / 5)」改为「读回存档点的活值(0.9 / 99)」,并在 SUMMARY 逐条说明该改写及其理由。 - **若 Q1 = 选项 B**:不实现上述 (1)-(3),改为同步方案(按记录指定的机制),并保持既有断言。 - **若 Q1 = 选项 C**:不修改行为,仅在 `types.ts` 写明契约并在 SUMMARY 登记「用户裁决维持现状」。 - 回归用例:(a) 三档压缩下 `modifier.setValue(新值)` → `saveState` → 再改 → `loadState` → 断言读回存档点的活值;(b) 未修改的加成在 Low/High 档仍经定义回退恢复(`#06-09-1` 不回归);(c) 无修饰器/空 `item.equip` 的装备实例三档往返不报错。 - - - pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/equipStore.test.ts - Q1=A 时三档压缩读回的修饰器值不等于存档点的活值(例如 Low/High 档仍为定义值);或 `#06-09-1`(未修改加成在压缩档经定义回退恢复)出现回归 —— 按 D-09 退出并修订计划 - - - - Q1 裁决落地:三档压缩的装备存档事实源与裁决一致(活值 / 同步 / 契约明写),并有断言 - - `#06-09-1` 的定义回退语义在 Low/High 档不回归 - - Q1=A 时 `saveLoad.test.ts:341-380` 的断言翻转已在 SUMMARY 逐条说明(A1) - - `IEquipmentStateSave`/`IEquipmentState` 的 jsDoc 与最终语义一致 - - 装备修饰器的运行时变更与原始定义的差异在三档压缩下语义明确且被断言(WR-04 按裁决闭合或明写为契约) - - - - Task 3: WR-05 —— `HeroAttribute.deleteModifierByIndex` 簿记与 `deleteModifier` 对齐(含迭代器/索引/final/可重新挂载回归),按 Q4(i) 裁决处理 `compareEquip` - packages-user/data-base/src/hero/attribute.ts, packages-user/data-base/src/hero/attribute.test.ts, packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/equipment.test.ts - - - packages-user/data-base/src/hero/attribute.ts(:169-188 `iterateModifiers`/`getModifiers`/`getModifierIndex`、:190-211 `addModifier`(:195-199 码 108 拒绝)、:213-228 `deleteModifier`、:230-239 `deleteModifierByIndex`、:241-249 `markDirty`/`markModifierDirty`) - - packages-user/data-base/src/hero/attribute.test.ts(现有 modifier 用例夹具与 `logger.catch` 口径) - - packages-user/data-base/src/hero/equipment.ts(:255-326 `compareEquip`,尤其 :281-284 的索引错配) - - packages-user/data-base/src/hero/equipment.test.ts(`diffs the final attributes of two equipment instances` 用例,:318-335) - - packages-user/data-base/src/hero/types.ts(:136-165 `iterateModifiers`/`getModifierIndex`/删除相关契约、:229 附近 `deleteModifierByIndex` 的 jsDoc 位置) - - - (1) 按 Task 0 的 Q2 裁决修改 `deleteModifierByIndex`(`attribute.ts:230-239`):选项 A(镜像 `deleteModifier`)→ 取出 `removed[0]` 后依次 `modifier.bindAttribute(null)`、`this.modifierName.delete(modifier)`、`this.modifierNosave.delete(modifier)`、`this.markDirty(name)`,返回值不变;选项 C(委托)→ 取 `index` 对应修饰器后调用 `this.deleteModifier(name, modifier)` 并返回该修饰器; - (2) 同步更新 `types.ts` 中 `deleteModifierByIndex` 的 jsDoc(说明与 `deleteModifier` 同构、修饰器将被解绑且不再是本属性成员); - (3) 按 Task 0 的 Q4(i) 裁决处理 `compareEquip`(`equipment.ts:281-284`):选项 A/B → 把「用原属性 `getModifierIndex` 的结果去删克隆属性」改为在**克隆属性**上定位(例如 `clone.getModifierIndex(modifier)`;若克隆未复制 `modifierName` 记录则按记录指定的等价方式,如按 `name` + 修饰器对象在克隆数组中 `indexOf`),保证删除的是克隆上对应的修饰器;选项 C → 不改,在 SUMMARY 登记为已知缺陷; - (4) 按 Q4(ii) 裁决处理 `EquipmentState.getModifiers()`(`equipStore.ts:57-61`):若裁决纳入,则返回副本或只读视图(不改 `IHeroEquipment`/`IEquipmentState` 的对外签名语义时优先返回元素副本数组);若裁决只登记,则不改。 - 回归用例: - (a) `attribute.test.ts`:`addModifier` 挂两个修饰器 → `deleteModifierByIndex` 删第一个 → 断言 `iterateModifiers()` 不再产出它、`getModifierIndex(removed) === -1`、`getFinalAttribute(name)` 等于按剩余修饰器重算的值、且该修饰器可再次 `addModifier` 而不触发码 108(`logger.catch` 口径); - (b) `equipment.test.ts`:既有 `diffs the final attributes of two equipment instances` 用例保持通过;按 Q4(i) 裁决补一条「克隆与原件修饰器顺序不同」的对差用例(或按裁决登记)。 + (1) **委托实现(Q2=C)**:把 `deleteModifierByIndex`(`attribute.ts:230-239`)的方法体改为——先 `const arr = this.modifier.get(name); if (!arr) return null;`,再 `const modifier = arr[index]; if (!modifier) return null;`,然后 `this.deleteModifier(name, modifier); return modifier;`。要点:**必须先取出 `arr[index]`**(`deleteModifier` 返回 `void`,只有先拿到对象才能把它作为返回值返回);删除的全部簿记(`bindAttribute(null)`、`modifierName.delete`、`modifierNosave.delete`、`markDirty(name)`)只经 `deleteModifier`(`:213-228`)这一份实现生效,本方法**不得**再自行 `splice`/清理任何表;不得改 `deleteModifier` 的实现(A3)。若类型推断需要,沿用旧实现的单次 `as IHeroModifier` 写法(不得使用连续 `as` 断言,不得新增 `import type`)。 + (2) **越界语义(A6)**:`arr[index]` 为 `undefined`(含 `index < 0` 与超出长度)时返回 `null` 且**不删除任何修饰器**。这把旧实现 `arr.splice(index, 1)` 的「负索引静默删掉末尾修饰器」换成确定性的「不删」——必须有断言固定(见 (5)(c)),并在 Task 5 的 SUMMARY 里作为**新增边界语义**登记(不属于既有断言改写)。 + (3) **非行为性契约注释(Q1 裁决:WR-04 设计如此)**:在 `types.ts` 的 `IEquipmentStateSave`(`:636-645`)与 `IEquipmentState`(`:652-664`)jsDoc 上补写契约:装备自身属性(`value`/`percentage`)是加成的**事实源**,存档持久化这两个表,`loadState` 由它们重建修饰器;**跨读档不保留运行时的修饰器对象引用属预期**;运行时改变某件装备的加成必须**经装备自身(`value`/`percentage`)**,不得通过修改它的修饰器来改变装备。**仅注释,零行为变更、零断言改动**。该注释单独一条 `docs(07-12): …` 提交,不与 WR-05 的 `fix(07-12)` 提交混提。 + (4) **jsDoc 同步**:`types.ts:221-230` 的 `deleteModifierByIndex` jsDoc 补写「与 `deleteModifier` 共用同一份簿记:修饰器被解绑(`bindAttribute(null)`)且不再属于该属性;索引越界/为负返回 `null` 且不删除任何修饰器」。 + (5) **回归用例(`attribute.test.ts`,新增用例;既有用例逐字保留)**: + (a) WR-05 四条后果:`addModifier('hp', first)` + `addModifier('hp', second)` 后删掉一个 → 断言 `[...attribute.iterateModifiers()]` 不再含它、`attribute.getModifierIndex(removed) === -1`、`attribute.getFinalAttribute('hp')` 等于按剩余修饰器重算的值; + (b) 可重新挂载:把被删修饰器再次 `addModifier(name, removed)` → 断言不产生码 108(`logger.catch` 口径)且 `getModifierIndex(removed) >= 0`、final 值按重挂后重算; + (c) 越界:`deleteModifierByIndex(name, -1)` 与 `deleteModifierByIndex(name, <当前长度>)` 均返回 `null`,且 `[...getModifiers(name)]` 内容不变(**不得**以「删掉了末尾」作为期望); + (d) `modifierNosave` 清理:挂一个 `save = false` 的修饰器 → `deleteModifierByIndex` 删除它 → 断言 `getModifierSaveEnabled(removed) === true`; + (e) 既有 `:189-204` 的按引用/按索引删除用例必须**逐字保留并保持通过**(返回值、最终属性、二次删除返回 `null`)。 + 每个新增用例前写一行中文注释说明覆盖内容(`dev.md:85`)。 pnpm exec vitest run packages-user/data-base/src/hero/attribute.test.ts packages-user/data-base/src/hero/equipment.test.ts - `deleteModifierByIndex` 之后 `iterateModifiers()` 仍产出该修饰器、或 `getModifierIndex` 仍能解析到它、或再次 `addModifier` 被码 108 拒绝、或 `finalAttribute` 未随删除刷新 —— 按 D-09 退出并修订计划 + `deleteModifierByIndex` 之后 `iterateModifiers()` 仍产出该修饰器、或 `getModifierIndex` 仍能解析到它、或再次 `addModifier` 被码 108 拒绝、或 `finalAttribute` 未随删除刷新、或越界索引删除了任何修饰器;或 `equipment.test.ts` 的 `diffs the final attributes of two equipment instances`(`:318-333`)转红 —— 按 D-09 退出并修订计划 - - `deleteModifierByIndex` 的簿记与 Q2 裁决一致,且四条后果(迭代器产出、索引解析、final 陈旧、owner 残留阻止重挂)都有断言覆盖 - - `compareEquip` 的索引来源按 Q4(i) 裁决处理(修或登记) - - `types.ts` 的 jsDoc 与最终语义一致;既有断言未弱化 + - `deleteModifierByIndex` 实现与 Q2=C 一致(取出对象 → 委托 `deleteModifier` → 返回该对象),`attribute.ts` 内不再有第二套删除簿记 + - 四条后果(迭代器产出、索引解析、final 刷新、owner 残留阻止重挂)与 `modifierNosave` 清理、越界不删除,均有断言覆盖 + - `deleteModifier`/`addModifier`/`clone()`/`iterateModifiers`/`getModifierIndex` 未被改动;`types.ts` jsDoc 与实现口径一致,且 WR-04 契约注释为零行为变更(独立 `docs` 提交) - 修饰器删除后属性图与访问器视图一致、final 值刷新、被删修饰器可重新挂载(WR-05 关闭) + 修饰器删除只有一份簿记实现,删除后属性图与访问器视图一致、final 值刷新、被删修饰器可重新挂载且越界不误删(WR-05 关闭);`types.ts` 同时记录了 WR-05 的口径与 WR-04 的「设计如此」契约 - Task 4: WR-06 —— `HeroEquipment.loadState` 读档不再追加录像指令(按 Q3 裁决) - packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/equipment.test.ts, packages-user/data-base/src/hero/saveLoad.test.ts + Task 3: WR-06 —— `HeroEquipment.loadState` 用既有 `replay.disable()`/`replay.revert()` 包裹重新装备循环(Q3=A),读档不再产生录像指令 + packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/equipment.test.ts - - packages-user/data-base/src/hero/equipment.ts(:153-198 `equip`(:168-170 注释与 disable、:194 revert、:195 route.add)、:200-218 `unequip`(:210-212 route.add)、:330-347 `saveState`/`loadState`) - - packages-user/data-base/src/hero/equipment.test.ts(:102-117 `createEnv` 的 `replaySystem` 桩、:171-316 装备/卸下用例、:318-361 对比与告警用例) - - packages-user/data-common/src/replay/system.ts(:89-95 `disable`/`revert`) - - packages-user/data-base/src/hero/saveLoad.test.ts(`createEquipHero` 夹具与其 `state.replaySystem` 形态) + - packages-user/data-base/src/hero/equipment.ts(:153-198 `equip`(:168-170 注释与 disable、:194 revert、:195 `route.add(ReplayCode.Equip, [uid])`)、:200-218 `unequip`(:210-212 `route.add(ReplayCode.Unequip, [slot])`)、:337-347 `loadState` 的 `equip(...)` 循环) + - packages-user/data-base/src/hero/equipment.test.ts(:50-57 `TestEnv`、:102-117 `createEnv` 的 `replaySystem` 桩(`route.add`/`disable`/`revert` 均为 `vi.fn()`)、:171-303 装备/卸下用例、:318-363 对比与告警用例;:305-315 的 `it.skip`(码 147,D-06)保持原样) + - packages-user/data-common/src/replay/system.ts(:89-95 `disable`/`revert` 转发到 `ReplayArray`)与 `array.ts`(:890-897 `disabled` 计数、`disabled > 0` 时 `add` 直接返回) - 按 Task 0 的 Q3 裁决实现。 - **若 Q3 = 选项 A(disable/revert 包裹)**:在 `HeroEquipment.loadState`(`:337-347`)的 `equip(...)` 循环外包裹 `this.state.replaySystem.disable()` / `this.state.replaySystem.revert()`(用 `try/finally` 或保证所有路径都 `revert`,与 `equip()` 内部的配对风格一致),使读档期间的 `route.add` 不产生录像命令; - **若 Q3 = 选项 B(无记录助手)**:把 `equip()` 的「业务部分」(`checkEuipped`/`getCouldEquipSlot`/`unequip`/`equips.set`/`loadEquipEffect`)抽为内部助手,`equip()` 在助手返回后自行 `replay.route.add(ReplayCommandCode.Equip, [uid])`,`loadState()` 只调用助手;`unequip()` 的 `route.add` 位置保持现状(其记录语义不变);助手抽取必须保持 `equip()` 的既有可观测行为(返回值、码 146/147、`disable`/`revert` 配对); - **若 Q3 = 选项 C(登记)**:不改行为,只在 `loadState` 的 jsDoc 写明顺序约束并补一条顺序断言。 - 回归用例: - (a) `equipment.test.ts`:在 `createEnv()` 的 `replaySystem` 桩上,`loadState` 前后断言 `route.add` **未被调用**(选项 A/B)或按选项 C 断言顺序约束;再断言 `loadState` 之后 `equip()`/`unequip()` 的 `route.add` 仍被调用(记录能力未被永久破坏); - (b) `saveLoad.test.ts`:`createEquipHero` 的读档往返后,`state.replaySystem.route.length`(或等价录像步数)不因读档增加——若夹具的 `replaySystem` 是桩则用 `route.add` 调用次数断言; - (c) 断言读档后槽位/加成恢复结果与既有断言一致(不得以「不记录」换取行为变化)。 + (1) **实现(Q3=A)**:在 `HeroEquipment.loadState`(`equipment.ts:337-347`)中,把 `for (const [index, uid] of state.equipped) { this.equip(uid, index); }` 这段循环用既有的 `const replay = this.state.replaySystem; replay.disable();` / `replay.revert();` 包裹,并用 `try { … } finally { replay.revert(); }` 保证**所有路径**都 `revert`(含 `equip()` 内部提前 `return` 的分支),与 `equip()`(`:169-170`/`:194`)的配对风格一致。不得改 `equip()`/`unequip()` 的记录位置与顺序,不得改 `ReplaySystem`/`ReplayArray`(`packages-user/data-common` 零改动),**不得**新抽「无记录助手」(Q3 选项 B 未被选用)。 + (2) **测试夹具(仅测试文件)**:`TestEnv` 增加 `replaySystem` 引用(如 `replaySystem: { route: { add: ReturnType }; disable: ReturnType; revert: ReturnType }`),`createEnv()` 在返回值中带上该桩(保持既有 `as never` 的单次断言写法;不新增 `import type`;既有其它用例不受影响)。 + (3) **回归用例(`equipment.test.ts`)**: + (a) 读档不写录像:装备一件带加成的装备 → `const saved = env.equipment.saveState()` → `unequip` → 把 `env.replaySystem.route.add`/`disable`/`revert` 全部 `mockClear()` → `env.equipment.loadState(saved)` → 断言 `route.add` **一次也没被调用**;`getEquipped(0)` 恢复到存档 uid、装备加成重新生效(`getFinalAttribute('atk')` 与存档点一致);并断言 `disable` 与 `revert` 调用次数**相等**(包裹窗口配对平衡)。 + (b) 记录能力未被破坏:`mockClear()` 后 `env.equipment.unequip(0)` → 断言 `route.add` 被调用(`ReplayCode.Unequip`);再 `env.equipment.equip(uid, 0)` → 断言 `route.add` 再次被调用(`ReplayCode.Equip`)。 + (c) 既有装备/卸下用例与 `diffs the final attributes of two equipment instances`(`:318-333`)保持通过;`it.skip`(`:306`,码 147)不得改动。 + 每个新增用例前写一行中文注释说明覆盖内容(`dev.md:85`)。 - pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts packages-user/data-base/src/hero/saveLoad.test.ts - `HeroEquipment.loadState` 仍调用 `replaySystem.route.add`(Q3 选 A/B 时);或读档后槽位/加成恢复与既有断言不一致;或 `equip()`/`unequip()` 的正常录像记录被一并关闭 —— 按 D-09 退出并修订计划 + pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts + `HeroEquipment.loadState` 仍调用 `replaySystem.route.add`;或读档后槽位/加成恢复与既有断言不一致;或 `equip()`/`unequip()` 的正常录像记录被一并关闭((b) 失败);或 `disable`/`revert` 调用次数失衡 —— 按 D-09 退出并修订计划 - - 按 Q3 裁决:`loadState` 期间不产生录像命令(A/B)或顺序约束明写并有断言(C) - - `equip()`/`unequip()` 的正常录像记录语义不变(仍有断言) - - 未修改 `packages-user/data-common` 下任何文件 + - `loadState` 期间的重新装备循环被 `disable()`/`revert()` 包裹,`route.add` 在读档期未被调用,`disable`/`revert` 配对平衡 + - `equip()`/`unequip()` 的记录语义与顺序不变(仍有断言);读档后槽位/加成恢复与既有断言一致 + - `packages-user/data-common` 下零改动;`equip()`/`unequip()` 的记录位置未改 - 英雄装备读档不再污染录像路由,装备/卸下的正常记录能力保持(WR-06 按裁决闭合) + 英雄装备读档不再污染录像路由,装备/卸下的正常记录能力保持(WR-06 按 Q3=A 闭合) - Task 5: D-12/D-44 门禁 + 全量套件 + 缺口登记(WR-04/05/06 + Q4 处置 + IN-02 已闭合(测试专用) + IN-04/IN-05 保留在册)与 Phase 7 验证失效说明 - .planning/phases/07-data-fixes/07-12-SUMMARY.md + Task 4: compareEquip 索引错配(Q4 仅修 (i))—— 在克隆属性上按对象定位待删修饰器,`-1`/越界不删除 + packages-user/data-base/src/hero/equipment.ts, packages-user/data-base/src/hero/equipment.test.ts - - .planning/phases/07-data-fixes/07-REVIEW.md(WR-04/WR-05/WR-06 与 IN-02/IN-04/IN-05 的编号,便于逐条对照闭合/登记;IN-02 原文在 `:247-251`) - - packages-user/data-base/src/hero/saveLoad.test.ts(Task 1 改写后的 IN-02 用例与其中的契约注释 —— SUMMARY 的覆盖变化登记须与之一致) - - .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status) - - .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表) - - .planning/WINDOWS.md(确认三条无入账 id) + - packages-user/data-base/src/hero/equipment.ts(:255-326 `compareEquip`::271 `const clone = this.attribute.clone();`、:281-284 索引错配本体、:292-312 后续的装备修饰器加/删、:318-325 差值汇总) + - packages-user/data-base/src/hero/attribute.ts(:169-188 `getModifiers`/`getModifierIndex`、:230-239 `deleteModifierByIndex`(Task 2 之后为委托实现)、:293-311 `clone()`:只重建 `modifier`、**不复制** `modifierName`,且克隆体内的修饰器是 `modifier.ts:18-39` 的 `clone()` **新对象**) + - packages-user/data-base/src/hero/equipment.test.ts(:318-333 既有对差用例、:102-117 `createEnv` 夹具) + - packages-user/data-base/src/hero/equipStore.ts(:155-164 `EquipmentState.loadState` → `:46-55 rebuildModifiers()` 会**重建**修饰器对象——这是「原属性仍引用旧修饰器对象」的现实触发条件) - (1) **门禁(D-12 / D-44 文件级)**:对 `files_modified` 中每个改动的文件执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;**文件级类型门禁按 06-RESEARCH Pitfall 4 的判定方式**:`pnpm exec vue-tsc --noEmit 2>&1 | Select-String -Pattern "<改动文件相对路径>"` 无命中(**不得**用「退出码非 0」判定失败);`pnpm test:ci` 全绿(对比基线 66 文件 / 690 通过 / 1 跳过,跳过项仍只能是 `equipment.test.ts:306` 码 147;**执行起点含 `ba60ef9` 引入的 1 条 IN-02 失败,须由 Task 1 消除**——门禁要求 0 失败)。 - (2) **原子提交(D-13)**:Task 1..4 各自独立提交——Task 1 为 `test(07-12): …`(IN-02 测试契约对齐 `ba60ef9`),Task 2 / 3 / 4 为 `fix(07-12): …`(分别携带 `WR-04` / `WR-05` / `WR-06` 与必要的 `#06-09-1` 锚点);IN-02 的测试调整不得与其根因混入同一条提交。 - (3) **缺口登记**:SUMMARY 的 findings/ledger 段落逐条标注 WR-04/WR-05/WR-06 已闭合(附 commit)、Q4 两处相邻缺陷的处置(已修/已登记)、**IN-02 已闭合(测试专用:测试契约对齐 `ba60ef9`,附 commit;生产逻辑零改动)并附其契约/覆盖变化——`logger.error(58)` 只剩「非空但 `maxBy` 返回 `undefined`」的畸形数组防御路径可达,码 58 不再由空存档用例覆盖**、`IN-04`/`IN-05` 保留在册;若 A1 授权的断言翻转或 A5 授权的 IN-02 用例改写发生,在此逐条列出「旧断言 → 新断言 → 理由」。`WINDOWS.md` 不新建、不 `fixed`/`waive`。 - (4) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。 + (1) **缺陷**:`compareEquip` 在准备克隆属性时(`equipment.ts:281-284`)对槽位现役装备的每个修饰器先算 `this.attribute.getModifierIndex(modifier)`(**原属性**上的索引),再调用 `clone.deleteModifierByIndex(name, index)`(**克隆属性**上的删除)。当该索引为 `-1`(原属性不含该修饰器对象——例如装备实例刚经 `store.loadState` 走了 `rebuildModifiers()`、属性上仍是旧对象)时,旧实现 `arr.splice(-1, 1)` 会**静默删掉克隆中该属性的末尾修饰器**,删错对象、对差值随之偏离。注意 `clone()` 不复制 `modifierName`(`attribute.ts:293-311`),且克隆体内的修饰器是 `clone()` 出的新对象,故**不得**改用 `clone.getModifierIndex(...)`(会恒为 -1)。 + (2) **修复(仅修 (i))**:把该循环改为在**克隆属性**上定位删除目标——先 `const index = this.attribute.getModifierIndex(modifier);`,`index < 0` 时 `continue`(原属性不含该修饰器:克隆上不得删除任何修饰器);否则 `const cloned = [...clone.getModifiers(name)][index];` 取克隆自己的**同槽位对象**(`clone()` 按原属性**同顺序**重建每个属性的修饰器数组,故槽位一一对应),`cloned` 为 `undefined`(越界)时 `continue`;最后 `clone.deleteModifier(name, cloned)` 按对象在克隆上删除。**不得**改 `clone()` 的实现(不新增 `modifierName` 复制,A3),不得改 `deleteModifier`/`addModifier`。 + (3) **写法约束**:若泛型推断需要,沿用同文件既有的 `// @ts-expect-error 泛型无法推导` 风格(`equipment.ts:293`/`:301`/`:306`)或单次 `as` 断言;不得使用连续 `as` 断言,不新增 `import type`。 + (4) **回归用例(`equipment.test.ts`,新增用例;既有 `:318-333` 逐字保留)**:构造「槽位现役装备先经一次 `store.loadState` 往返(其修饰器被 `rebuildModifiers()` 重建,`this.attribute` 上仍是旧对象)」后再调用 `compareEquip` 的场景,断言逐属性对差值与**未**经重建时一致。夹具前提:被比较的属性名下**至少有两个修饰器**(例如先给英雄侧挂一个同名 `ValueModifier`,再装备一件同名加成的装备),否则单修饰器情形下 `splice(-1, 1)` 与正确删除结果恰好相同、无法暴露缺陷——这是本用例必须显式构造的前提,并需在用例注释里写明。若该场景无法用公开 API 稳定构造,**暂停并请用户裁决策略**,不得删除用例或弱化断言。 + 每个新增用例前写一行中文注释说明覆盖内容(`dev.md:85`)。 + + + pnpm exec vitest run packages-user/data-base/src/hero/equipment.test.ts + 修复后仍存在「原属性索引为 `-1` / 槽位越界时删除了克隆上的修饰器」的路径;或新增回归用例未锚定 `-1`/越界路径(修复前后结果相同且用例内无证据说明其触发机制);或既有 `diffs the final attributes of two equipment instances`(`:318-333`)转红 —— 按 D-09 退出并修订计划 + + + - `compareEquip` 不再把原属性的索引直接用在克隆属性上删除;删除目标在克隆属性上**按对象**取得,`getModifierIndex` 为 `-1` 或槽位越界时**不删除任何修饰器** + - 回归用例显式构造「原属性不含该修饰器对象」的路径(属性名下 ≥2 个修饰器)并在用例注释中写明触发机制;既有对差用例逐字保留且通过 + - `clone()`/`deleteModifier`/`addModifier` 未被改动;未使用连续 `as` 断言、未新增 `import type`;`EquipmentState.getModifiers()` 未被改动、未被登记 + + `compareEquip` 删除修饰器的定位在克隆属性上自洽,`-1`/越界不再造成静默误删(Q4 仅修 (i) 闭合) + + + + Task 5: D-12/D-44 门禁 + 全量套件 + 缺口登记(WR-05 闭合 / WR-06 闭合 / compareEquip 索引已修 / WR-04「设计如此」非缺陷不修 / `getModifiers()` 非缺陷不修不登记 / IN-02 已闭合(测试专用)/ IN-04·IN-05 保留在册)与 Phase 7 验证失效说明 + .planning/phases/07-data-fixes/07-12-SUMMARY.md + + - .planning/phases/07-data-fixes/07-REVIEW.md(WR-04/WR-05/WR-06 与 IN-02/IN-04/IN-05 的编号,便于逐条对照闭合/登记/设计如此;WR-04 原文在 `:184-194`,IN-02 原文在 `:247-251`) + - packages-user/data-base/src/hero/saveLoad.test.ts(Task 1 改写后的 IN-02 用例与其中的契约注释 —— SUMMARY 的覆盖变化登记须与之一致;同时确认 `:229-245`、`:350-403` 的 WR-04 相关断言未被改动) + - packages-user/data-base/src/hero/types.ts(Task 2 补的 WR-04 非行为性契约注释与 `deleteModifierByIndex` jsDoc —— SUMMARY 的登记口径须与之一致) + - .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status) + - .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表;确认 07-12 已在 Wave 12 在册、无需结构变更) + - .planning/WINDOWS.md(确认 WR-05/WR-06/compareEquip 无入账 id) + + + (1) **门禁(D-12 / D-44 文件级)**:对 `files_modified` 中每个改动的文件执行 `pnpm exec eslint --fix <文件>` 后 `pnpm exec eslint <文件>` 0 错误;**文件级类型门禁按 06-RESEARCH Pitfall 4 的判定方式**:`pnpm exec vue-tsc --noEmit 2>&1 | Select-String -Pattern "<改动文件相对路径>"` 无命中(**不得**用「退出码非 0」判定失败);`pnpm test:ci` **0 失败**(跳过项仍只能有 `equipment.test.ts:306` 码 147 一条;**通过数因本轮新增回归用例而上升,不得低于执行起点**)。执行起点含 `ba60ef9` 引入的 1 条 IN-02 失败(689 通过 / 1 失败 / 1 跳过),须由 Task 1 消除。 + (2) **原子提交(D-13)**:Task 1 = `test(07-12): …`(IN-02 测试契约对齐 `ba60ef9`,不与根因混提);Task 2 = `fix(07-12): … WR-05`(委托 `deleteModifier`)+ **独立** `docs(07-12): …`(WR-04 的非行为性契约注释,零行为变更);Task 3 = `fix(07-12): … WR-06`(disable/revert 包裹);Task 4 = `fix(07-12): … compareEquip 索引`(仅 (i))。 + (3) **缺口登记**:SUMMARY 的 findings/ledger 段落逐条标注—— + · **WR-04 = 设计如此 / 非缺陷**(引用 2026-09-17 的 Q1 裁决):修饰器为被动派生视图、装备自身 `value`/`percentage` 为事实源、存档持久化这两个表、`loadState` 由它们重建修饰器、跨读档丢失修饰器引用属预期、运行时变更须经装备而非改其修饰器;**未修改任何代码、未翻转任何断言**;唯一落笔是 `types.ts` 的非行为性契约注释(附 `docs(07-12)` commit)。 + · WR-05 已闭合(Q2=C 委托,附 commit);WR-06 已闭合(Q3=A disable/revert,附 commit);compareEquip 索引(Q4 仅修 (i))已修(附 commit)。 + · **`EquipmentState.getModifiers()` 非缺陷**(声明返回 `Iterable<...>`,不可转数组/不可变):**不得出现在任何 known-issues / 登记列表中**(连「已登记」都不写)。 + · **IN-02 已闭合(测试专用)**:测试契约对齐 `ba60ef9`(附 commit;生产逻辑零改动),并附其契约/覆盖变化——`logger.error(58)` 只剩「非空但 `maxBy` 返回 `undefined`」的畸形数组防御路径可达,码 58 不再由空存档用例覆盖。 + · `IN-04`/`IN-05` 保留在册。 + · **断言变更清单**:仅列出 A5 授权的 IN-02 用例改写(旧断言 → 新断言 → 理由),并**明写「本轮无 WR-04 断言翻转」**(`saveLoad.test.ts:229-245`、`:350-403` 逐字未改);A6 的越界语义属新增断言,单独标注为「新增边界语义」。 + (4) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`)。`ROADMAP.md` 的 Wave 12 条目已存在(07-12 在册、计划数与 Wave 列表无结构变化),本计划**不改** `ROADMAP.md` 文本;`WINDOWS.md` 不新建、不 `fixed`/`waive`。 pnpm test:ci - `pnpm test:ci` 出现新增失败或新增 `it.skip`;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误 —— 按 D-09 退出并修订计划 + `pnpm test:ci` 出现任何失败或新增 `it.skip`(跳过项多于 1 条);或通过数低于执行起点;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误;或 SUMMARY 把 WR-04 或 `EquipmentState.getModifiers()` 登记为缺陷 —— 按 D-09 退出并修订计划 - - 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条 - - Task 1–4 各有独立提交并含对应锚点(Task 1 = IN-02 `test(07-12)`;Task 2–4 = WR-04/05/06 `fix(07-12)`) - - SUMMARY 完成缺口登记(WR-04/05/06、Q4 处置、IN-02 已闭合 + 码 58 覆盖变化、IN-04/IN-05 保留在册、A1 断言翻转 / A5 IN-02 用例改写清单)与 Phase 7 验证失效说明 - - `WINDOWS.md` 未被本计划改动 + - 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` **0 失败**且跳过项仍仅 1 条(`equipment.test.ts:306`) + - Task 1–4 各有独立提交并含对应锚点(Task 1 = IN-02 `test(07-12)`;Task 2 = WR-05 `fix(07-12)` + WR-04 契约注释 `docs(07-12)`;Task 3 = WR-06 `fix(07-12)`;Task 4 = compareEquip `fix(07-12)`) + - SUMMARY 完成缺口登记:WR-04「设计如此/非缺陷」(含 Q1 理由与「无代码改动、无断言翻转」)、WR-05/WR-06/compareEquip 闭合、`getModifiers()` 非缺陷**且未登记**、IN-02 已闭合 + 码 58 覆盖变化、IN-04/IN-05 保留在册、A5 断言变更清单与「无 WR-04 断言翻转」 + - `WINDOWS.md` 与 `ROADMAP.md` 未被本计划改动 - D-44 三步门禁全绿、原子提交完成、缺口与断言变更逐条登记、Phase 7 验证失效说明写入 SUMMARY + D-44 三步门禁全绿(0 失败)、原子提交完成、缺口与断言变更逐条登记(含 WR-04「设计如此」与 `getModifiers()` 非缺陷两条否定性口径)、Phase 7 验证失效说明写入 SUMMARY @@ -387,8 +403,8 @@ Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个 | Boundary | Description | |----------|-------------| -| 存档数据 → 装备/属性状态 | 读档决定修饰器的值与成员集合;错误的事实源会让「读档后数值」静默偏离 | -| 公开 API 调用方 → 修饰器图 | `deleteModifierByIndex` / `getModifiers()` / `compareEquip` 允许外部改动或误删修饰器,簿记不全即产生与访问器视图分叉的状态 | +| 存档数据 → 装备/属性状态 | 读档决定修饰器的值与成员集合。**Q1 裁决后此边界的契约为**:装备自身属性(`value`/`percentage`)是事实源、修饰器是派生视图,读档由装备属性重建修饰器(跨读档丢失修饰器引用属预期) | +| 公开 API 调用方 → 修饰器图 | `deleteModifierByIndex` / `compareEquip` 允许外部按索引删除修饰器;簿记不全或删错对象即产生与访问器视图分叉的状态(`getModifiers()` 的 `Iterable<...>` 声明不构成该边界——不可转数组/不可变) | | 读档 → 录像路由 | `loadState` 经 `equip()` 触发录像记录,把只读语义变成可回放历史 | | 存档快照 → uid 计数器 | 空存档读档决定 `HeroEquipsStore.nextUid` 的复位;测试若锚定旧契约,即失去对已修行为的回归保护(且套件持续为红) | @@ -396,33 +412,34 @@ Phase 7 裁决对应(逐条引用):D-01: 本计划是 Phase 7 第 12 个 | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| -| T-7-31 | Tampering | `EquipmentState.saveDiff` / `saveNoCompression`(`equipStore.ts:67-102`) | medium | mitigate | Task 2:按 Q1 裁决以活修饰器值为存档源(或明写契约),消除「压缩档零持久化」与「运行时变更丢失」 | -| T-7-32 | Tampering | `HeroAttribute.deleteModifierByIndex`(`attribute.ts:230-239`) | medium | mitigate | Task 3:与 `deleteModifier` 同构的簿记(解绑 + 清理两表 + `markDirty`),并有四条后果的回归断言 | -| T-7-33 | Tampering | `compareEquip` 的索引错配(`equipment.ts:281-284`) | medium | mitigate | Task 3 按 Q4(i) 裁决:在克隆属性上定位修饰器(修)或不修并登记为已知缺陷 | -| T-7-34 | Repudiation | `HeroEquipment.loadState` 追加 `Equip` 指令(`equipment.ts:195` 经 `:345`) | medium | mitigate | Task 4:按 Q3 裁决禁用记录或无记录助手;断言 `route.add` 在读档期未被调用,且正常 `equip`/`unequip` 仍记录 | -| T-7-35 | Tampering | `EquipmentState.getModifiers()` 返回内部数组(`equipStore.ts:57-61`) | low | mitigate | 按 Q4(ii) 裁决:纳入则返回副本/只读视图,否则登记在册 | -| T-7-36 | Denial of Service | `replay.disable()`/`revert()` 配对失衡(Q3 选项 A) | low | mitigate | Task 4:用 `try/finally` 或与 `equip()` 一致的配对路径保证 `revert` 必达;`` 拦截「正常记录被永久关闭」 | -| T-7-37 | Repudiation | `HeroEquipsStore.loadState` 空存档的 uid 计数器契约(`equipStore.ts:300-309`,生产修复 `ba60ef9`) | low | mitigate | Task 1:测试锚定「空存档 → 随后 `add` 发号 0」且该路径不产生码 58;并在用例注释与 SUMMARY 登记「码 58 只剩非空畸形数组可达」的覆盖/契约变化(测试专用,生产逻辑零改动) | +| T-7-31 | Tampering | `EquipmentState` 的存档源(`equipStore.ts:67-102`)与 `getModifiers()`(`:57-61`) | low | accept | **Q1 裁决:设计如此,非缺陷**——修饰器是被动派生视图,装备自身 `value`/`percentage` 是事实源并随存档持久化,`loadState` 由它们重建修饰器;跨读档丢失修饰器引用属预期。本计划**不改代码**,仅由 Task 2 步骤 (3) 在 `types.ts` 补一条**非行为性**契约注释(同 Task 5 登记「设计如此」) | +| T-7-32 | Tampering | `HeroAttribute.deleteModifierByIndex`(`attribute.ts:230-239`) | medium | mitigate | Task 2(Q2=C):改为委托 `deleteModifier`(单一簿记:解绑 + 清理 `modifierName`/`modifierNosave` + `markDirty`),保留返回值;`-1`/越界**不删除**(A6);五条回归断言 | +| T-7-33 | Tampering | `compareEquip` 的索引错配(`equipment.ts:281-284`) | medium | mitigate | Task 4(Q4 仅修 (i)):在克隆属性上**按对象**取同槽位目标删除;`getModifierIndex` 为 `-1` 或越界时**不删除**;回归用例显式构造经 `rebuildModifiers()` 重建后的 `-1` 路径 | +| T-7-34 | Repudiation | `HeroEquipment.loadState` 经 `equip()` 追加 `Equip`/`Unequip` 指令(`equipment.ts:195`/`:212` 经 `:344-346`) | medium | mitigate | Task 3(Q3=A):`disable()`/`revert()` 包裹 + `try/finally`;断言读档期 `route.add` 未被调用、配对平衡,且正常 `equip`/`unequip` 仍记录 | +| T-7-35 | Denial of Service | `replay.disable()`/`revert()` 配对失衡(Q3=A 的包裹窗口) | low | mitigate | Task 3:`try/finally` 保证 `revert` 必达;用例断言 `disable` 与 `revert` 调用次数相等;`` 拦截「正常记录被永久关闭」 | +| T-7-36 | Repudiation | `HeroEquipsStore.loadState` 空存档的 uid 计数器契约(`equipStore.ts:300-309`,生产修复 `ba60ef9`) | low | mitigate | Task 1(测试专用):测试锚定「空存档 → 随后 `add` 发号 0」且该路径不产生码 58;用例注释与 SUMMARY 登记「码 58 只剩非空畸形数组可达」的覆盖/契约变化(生产逻辑零改动) | | T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 | -1. `pnpm exec vitest run packages-user/data-base/src/hero/saveLoad.test.ts packages-user/data-base/src/hero/attribute.test.ts packages-user/data-base/src/hero/equipment.test.ts packages-user/data-base/src/hero/equipStore.test.ts` 全绿(`saveLoad.test.ts` 的 IN-02 用例已按 `ba60ef9` 的新契约改写,不再是 1 failed / 26 passed)。 -2. `pnpm test:ci` 全绿:66 文件 / 690 通过 / 1 跳过(仅 `equipment.test.ts:306` 码 147);执行起点的 IN-02 失败已由 Task 1 消除。 +1. `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 packages-user/data-base/src/hero/equipStore.test.ts` 全绿(`saveLoad.test.ts` 的 IN-02 用例已按 `ba60ef9` 的新契约改写,不再是 1 failed / 26 passed)。 +2. `pnpm test:ci` **0 失败**,跳过项仍仅 1 条(`equipment.test.ts:306` 码 147);执行起点的 IN-02 失败已由 Task 1 消除;通过数不低于执行起点(689 通过 / 1 失败 / 1 跳过),并因本轮新增回归用例而上升。 3. `pnpm exec eslint ` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。 -4. Task 0 的 `` 中 Q1..Q4 均有裁决,代码(Task 2 / 3 / 4)与裁决逐条一致(SUMMARY 对照表);A1 授权的断言翻转与 A5 授权的 IN-02 用例改写均已逐条列出「旧断言 → 新断言 → 理由」。 -5. IN-02 为测试专用:`equipStore.ts` 的 `nextUid` 分支(`:300-309`)零改动(与 `ba60ef9` 逐字一致);改写后的用例断言「`saved: { equipments: [] }` → 随后 `add(10)` 返回 0」且该路径不产生码 58;码 58 的剩余可达条件(非空但 `maxBy` 返回 `undefined`)已在用例注释与 SUMMARY 登记。 -6. 无新增依赖、无新建源码文件、`packages-user/data-common` 下零改动、`WINDOWS.md` 未改动。 -7. Phase 7 验证失效与重跑要求已在 `07-12-SUMMARY.md` 明写,`ROADMAP.md` 已更新。 +4. Task 0 的 `` 中 Q1..Q4 均有裁决,代码与裁决逐条一致(SUMMARY 对照表):Q1=C(**不修**,仅 `types.ts` 非行为性注释)/ Q2=C(委托)/ Q3=A(`disable`-`revert` 包裹)/ Q4=B(仅修 (i))。**仅 A5 授权的 IN-02 用例改写**需逐条列出「旧断言 → 新断言 → 理由」,并明写「本轮无 WR-04 断言翻转」(`saveLoad.test.ts:229-245`、`:350-403` 逐字未改)。 +5. WR-04 无代码改动、无断言翻转;`EquipmentState.getModifiers()` 未被修改且**未出现在任何 known-issues / 登记列表**(连「已登记」都不写);`hero/equipStore.ts` 零改动(可用 `git diff --stat` 佐证)。 +6. IN-02 为测试专用:`equipStore.ts` 的 `nextUid` 分支(`:300-309`)零改动(与 `ba60ef9` 逐字一致);改写后的用例断言「`saved: { equipments: [] }` → 随后 `add(10)` 返回 0」且该路径不产生码 58;码 58 的剩余可达条件(非空但 `maxBy` 返回 `undefined`)已在用例注释与 SUMMARY 登记。 +7. 无新增依赖、无新建源码文件、`packages-user/data-common` 下零改动、`WINDOWS.md` 与 `ROADMAP.md` 未改动。 +8. Phase 7 验证失效与重跑要求已在 `07-12-SUMMARY.md` 明写。 - IN-02(测试专用)闭合:`saveLoad.test.ts:447-457` 改写为「空存档 → `nextUid` 归零、`add` 发号 0、不产生码 58」,该文件由 RED(1 failed / 26 passed)转绿;`equipStore.ts` 的 IN-02 生产逻辑零改动(`ba60ef9` 为基线);码 58 的契约/覆盖变化已登记。 -- WR-04 按 Q1 裁决闭合:装备存档的事实源与裁决一致,三档压缩下有对应断言(或契约明写并有测试锚定)。 -- WR-05 闭合:`deleteModifierByIndex` 后迭代器/索引/final/重新挂载四条后果全部自洽。 -- WR-06 按 Q3 裁决闭合:读档不产生录像命令,正常装备/卸下记录能力不变。 -- Q4 两处相邻缺陷按裁决修或登记,并在 SUMMARY 逐条记录。 -- `pnpm test:ci` 全绿、无新增跳过用例;D-44 三步门禁全绿;A1 / A5 授权的断言变更已逐条登记。 +- WR-05 闭合(Q2=C):`deleteModifierByIndex` 委托 `deleteModifier` 并返回被删修饰器,簿记只有一份实现;删除后迭代器/索引/final/重新挂载/`modifierNosave` 全部自洽;`-1`/越界不删除任何修饰器(A6 新增边界语义已登记)。 +- WR-06 闭合(Q3=A):`loadState` 期间 `route.add` 未被调用、`disable`/`revert` 配对平衡;`equip()`/`unequip()` 的正常记录能力不变。 +- compareEquip 索引错配(Q4 仅修 (i))闭合:删除目标在克隆属性上按对象定位,`-1`/越界不删除;回归用例显式锚定该路径。 +- **WR-04 登记为「设计如此 / 非缺陷」**:装备自身属性为事实源、修饰器为派生视图;**无代码改动、无断言翻转**(`saveLoad.test.ts:229-245`、`:350-403` 逐字保留);唯一落笔是 `types.ts` 的非行为性契约注释。 +- **`EquipmentState.getModifiers()` 未被修改,且未被登记为缺陷**(不修、不入 known-issues/登记列表)。 +- `pnpm test:ci` **0 失败**、无新增跳过用例;D-44 三步门禁全绿;A5 授权的 IN-02 用例改写已逐条登记,并明写「本轮无 WR-04 断言翻转」。 - Phase 7 验证失效与重跑要求已记录。