docs(07-13): lock user adjudications for CR-02/IN-01/load-dynamics cleanup

This commit is contained in:
unanmed 2026-09-17 16:36:49 +08:00
parent 674b325c55
commit b37c218f2d

View File

@ -8,7 +8,6 @@ depends_on:
files_modified:
- packages-user/data-system/src/combat/mapDamage.ts
- packages-user/data-system/src/combat/mapDamage.test.ts
- packages-user/data-system/src/combat/types.ts
- packages-user/data-base/src/map/mapLayer.ts
- packages-user/data-base/src/map/mapLayer.test.ts
- packages-user/data-base/src/map/saveLoad.test.ts
@ -16,71 +15,84 @@ autonomous: false
requirements:
- FIX-01
estimate:
tokens: 52000
raw_tokens: 52000
tasks: 4
tokens: 54000
raw_tokens: 54000
tasks: 5
confidence: low
must_haves:
truths:
- "怪物来源的地图伤害在来源消失或范围收缩后不再残留:`markEnemyDirty` 路径下 `removeEnemyAffecting` 触达过的每个坐标索引都被标脏并按现有 `refreshIndex` 重新聚合CR-02 关闭),`getReducedDamage`/`getSeparatedDamage` 不再返回幽灵伤害"
- "转换视图集变为空时(伤害来源被移除,如光环/特殊属性消失)仍完成缓存失效,且该怪物的反向索引被显式登记,后续 `markEnemyDirty` 不走整表 `refreshAll()`(按 Task 0 Q1 裁决)"
- "范围收缩(同一怪物产生的伤害点位减少)时,跌出范围的坐标不再保留包含已移除贡献的缓存值"
- "`MapLayer.setMapRef` 的失效契约按 Task 0 Q2 裁决明确:`getMapRef()` 的持有者能据 `expired`/对象身份判断何时重取(`#06-17-8` / 审计 G 关闭或明写契约)"
- "`MapLayer.loadDynamics` 不再让旧动态图块累积:读档后该图层的动态图块集合与存档一致(按 Task 0 Q3 裁决:清空旧块 / 只清存档外坐标 / 明写合并语义)"
- "`pnpm test:ci` 全绿,不新增 `it.skip`、不弱化既有断言(既有 `mapDamage.test.ts` 的 102/103/104、`deleteEnemy`、`markEnemyDirty` 用例全部保持通过)"
- "**CR-02 按 Q1=A 关闭(移除端标脏 + 空视图集登记)**`removeEnemyAffecting` 收集被移除的坐标索引并在方法末尾逐个 `markDirtyIndex(index)`;因此来源消失(视图集变空)与范围收缩(同一怪物伤害点位减少)两条路径下,旧坐标都会经既有 `refreshIndex` 按剩余贡献重新聚合,`getReducedDamage`/`getSeparatedDamage` 不再返回幽灵伤害"
- "**空视图集被显式登记**`refreshEnemyAndClearCache` 与 `refreshEnemy` 不再在 `set.size === 0` 处提前返回——它们先 `enemyStore.set(view, set)`(空集也登记)再结束,使后续 `markEnemyDirty` 走局部路径(`refreshEnemyAndClearCache`)而不是整表 `refreshAll()`;两条路径各有自动化见证(`vi.spyOn(context, 'iterateEnemy')` 未被调用 + `converter.calls` 恰增 1"
- "**空集合的读法不得被误当作「未登记」**`markEnemyDirty``:135-148`)与 `deleteEnemy``:150-167`**不新增任何早退分支**——空的 `Set` 本身是真值,`if (!store)` 天然不误判;用例固定「空集登记后再次标记怪物脏仍局部刷新」与「空集合下 `deleteEnemy` 只做 `enemyStore.delete(view)`」"
- "**IN-01 按 Q4=A 落地(两处清理)**`deleteEnemy` 在删除每个视图时把该视图从 `point.affectedBy` 剪除、并清除其 `point.damages` 条目(不再依赖 `refreshIndex` 的惰性跳过);`deleteMapDamage` 在删除后该点的 `damages``affectedBy` 均为空时把该 `IPointInfo``sourcelessDamage` 中移除。其中 `deleteEnemy` 的剪除有**自动化见证**:删除后触发惰性 `refreshIndex` 时,被删视图的 `getDamageWithoutCheck``:414`**不再被调用**`vi.spyOn` 计数为 0"
- "**IN-01 的 `deleteMapDamage` 分支无公开可观测差异(如实登记,不伪造见证)**`sourcelessDamage` 空条目的移除是簿记/内存修复,公开 API 的返回内容前后一致;其验证为「既有断言 + 新增公开契约回归全绿」+逐行代码对照 `<record>`**禁止**用私有字段反射或连续 `as` 断言去制造伪见证"
- "**`#06-17-8`(读档旧动态块累积)按 Q3=A1 关闭**`MapLayer.loadState` 在派发三档压缩之前**先清空本图层全部既有动态块**,逐块复用 `deleteDynamic` 的语义——`syncStaticEvent(tile, false)``mapLayer.ts:133-145`+ `removeTile(tile)``:118-124`+ 触发 `onDeleteDynamic` 钩子(`types.ts:257`);读档后动态块集合严格等于存档(`[...iterateDynamicTiles()].length === 存档块数`),重复读档不累积,且**不**复用旧实例(全清 → 重建,与合并语义区分)"
- "**读档清理触发的 `onDeleteDynamic` 为「触发式、不等待」**`loadState` 是同步方法而 `IMapLayerHooks.onDeleteDynamic` 返回 `Promise<void>`,故清理路径按 `forEachHook` 调用钩子但**不** `await`(与可等待的 `deleteDynamic` 逐字不同);用例只固定「钩子被同步调用」这一事实,不等待其 PromiseA6"
- "**Q2 = A`setMapRef` 零改动、零 jsDoc 改动**`MapLayer.setMapRef``:371-391`)与 `getMapRef``:393-395`)逐字保留现契约(旧 `mapData``expired = true` + 整对象替换),其既有 jsDoc/注释一行不动;裁决为「保留现契约、无代码改动、无 jsDoc 改动」,登记于 Task 0 `<record>``07-13-SUMMARY.md`"
- "**零 jsDoc 改动(用户 2026-09-17 长期规则:永远不要擅自改 jsDoc 注释)**:本计划唯一被授权的 jsDoc 落笔是 Task 3 新增私有方法 `clearDynamics` 的 jsDocdev.md:73 要求方法必须有 jsDoc`combat/types.ts` 逐字不改Q1=A 不需要任何接口/类型变更);测试文件的 `it` 前单行中文注释与文件头覆盖说明同步属 dev.md:85 的既定要求,不在禁令范围"
- "`pnpm test:ci` 0 失败、不新增 `it.skip`、既有断言未弱化基线07-12 收尾)为 66 文件 / 729 通过 / 0 失败 / 1 跳过(跳过项仍只能是 `equipment.test.ts:306` 码 147D-06本计划对 `packages-user/client-base`、`packages-user/client-modules` 的既有未暂存改动**零触碰**"
artifacts:
- path: packages-user/data-system/src/combat/mapDamage.ts
provides: "`removeEnemyAffecting` 返回/登记被移除的坐标索引并逐个 `markDirtyIndex``refreshEnemyAndClearCache` 在视图集为空时仍完成失效与反向索引登记(按 Q1 裁决)"
- path: packages-user/data-system/src/combat/types.ts
provides: "`IMapDamage.markEnemyDirty`/`deleteEnemy` 的失效契约 jsDoc何时保证缓存失效、空视图集语义"
provides: "`removeEnemyAffecting` 收集被移除索引并逐个 `markDirtyIndex`Q1=A`refreshEnemyAndClearCache`/`refreshEnemy` 空集也 `enemyStore.set(view, set)` 后再结束;`deleteMapDamage` 移除空 `IPointInfo`、`deleteEnemy` 剪除 `point.affectedBy``point.damages`Q4=A`setMapRef` 同区的既有结构(`dirtyIndexes`/`reducedCache`/`viewStore`/`damageStore`/`enemyStore`)不新增缓存层"
- path: packages-user/data-system/src/combat/mapDamage.test.ts
provides: "CR-02 回归:来源消失(空视图集)与范围收缩两条路径的幽灵伤害断言"
provides: "CR-02 回归 4 条(视图集变空后无幽灵伤害 / 范围收缩后跌出范围的坐标无旧缓存 / 空集后 `markEnemyDirty` 走局部刷新 / `refreshAll` 同样登记空集后走局部刷新)+ IN-01 回归 2 条(`deleteEnemy` 剪除的自动化见证 / `deleteMapDamage` 空点的公开契约回归)+ 文件头覆盖说明同步"
- path: packages-user/data-base/src/map/mapLayer.ts
provides: "`setMapRef`/`loadState`/`loadDynamics` 的失效与旧动态块处置(按 Q2/Q3 裁决"
provides: "`loadState` 入口新增「清空本图层全部既有动态块」步骤Q3=A1经新增私有方法 `clearDynamics` 逐块复用 `deleteDynamic` 语义(`syncStaticEvent(tile, false)` + `removeTile` + 触发 `onDeleteDynamic`,不等待);三档压缩共用同一入口;`setMapRef`/`getMapRef` 与其 jsDoc **零改动**Q2=A"
- path: packages-user/data-base/src/map/mapLayer.test.ts
provides: "`#06-17-8` 回归:`setMapRef` 的 `expired`/对象身份契约;旧动态块不再累积(实例级)"
provides: "`#06-17-8` 实例级回归:读档前创建多于存档的动态块 → `loadState``iterateDynamicTiles()` 数等于存档、旧实例不再被管理(且读档后非同一实例,排除合并语义)、清理时 `onDeleteDynamic` 被同步触发"
- path: packages-user/data-base/src/map/saveLoad.test.ts
provides: "`#06-17-8` 回归:`MapLayer.loadState` 三档往返后动态图块集合等于存档(不累积)"
provides: "`#06-17-8` 三档往返回归:动态块多于存档时读档后块数等于存档;同一存档连续两次 `loadState`(模拟重复读档)块数仍等于存档;既有 `restores the matrix and point events across all compressions` 与码 124 用例逐字保留"
key_links:
- "`removeEnemyAffecting``mapDamage.ts:240-256`)↔ `markDirtyIndex``:126-129`)——移除端不标脏是 CR-02 的根因;`refreshEnemyAndClearCache``:290-333`)与 `refreshEnemy``:338-372`)都经由它"
- "`refreshEnemyAndClearCache` 的 `if (set.size === 0) return;``:300`)↔ `enemyStore.delete(view)``:255`)—— 视图集为空时不登记反向索引,后续 `markEnemyDirty``:135-148`)会因 `enemyStore.get(view)` 为空而走 `refreshAll()``:142`"
- "`refreshIndex``:395-427`)↔ `viewStore.get(store.sourceView)``:406-407`)——重新聚合只在 `viewStore` 仍登记的视图上重建,故被移除视图的贡献随 `point.affectedBy.delete(viewItem)``:249`)自然消失"
- "`MapLayer.setMapRef``mapLayer.ts:371-391`)↔ `getMapRef()``:393-395`/`MapLayerHookController.getMapData``:950-952`)——旧 `mapData` 被标 `expired` 后整对象被替换(`:380-386`"
- "`MapLayer.loadDynamics``:841-849`)↔ `iterateDynamicTiles``:512-514`/`tilePosMap`/`posTileMap``:48-51`/`removeTile``:118-124`)——读档只创建不清理,旧动态块累积"
- "`MapLayer.deleteDynamic``:498-507`)的 `syncStaticEvent(tile, false)` + `removeTile` + `onDeleteDynamic` 钩子 ↔ 读档清理是否复用同一路径(决定静态事件同步与渲染端通知是否触发)"
- "`removeEnemyAffecting``mapDamage.ts:240-256`)↔ `markDirtyIndex``:126-129`)——移除端不标脏是 CR-02 的根因;`refreshEnemyAndClearCache``:294`)与 `refreshEnemy``:339`)都经由它,故标脏放在移除端即可覆盖两条路径"
- "`refreshEnemyAndClearCache` 的 `if (set.size === 0) return;``:300`)↔ `enemyStore.set(view, set)``:301`/ `enemyStore.delete(view)``:255`)↔ `markEnemyDirty``:135-148`)的 `refreshAll()``:142`)——空集不登记会让下次 `markEnemyDirty``enemyStore.get(view)` 为空而整表重算;登记后走 `refreshEnemyAndClearCache` 局部路径"
- "`refreshEnemyAndClearCache` 的重登失效(`:329-332` 的 `dirtyIndexes.delete`/`reducedCache.delete`)↔ `removeEnemyAffecting` 末尾的逐个 `markDirtyIndex`——重登的索引清脏、跌出范围的索引保留脏标记,下次读取时经 `refreshIndex``:395-427`)按剩余贡献重算"
- "`refreshIndex``:395-427`)的 `point.affectedBy.forEach``view.getDamageWithoutCheck(locator)``:413-414`)↔ `deleteEnemy``:150-167`是否剪除死视图——不剪除时惰性重建仍会调用已删视图的方法IN-01 的可观测差异,即测试见证点)"
- "`deleteMapDamage``:114-120`)↔ `sourcelessDamage``:45`)的空 `IPointInfo`——`affectedBy` 只在 `registerSourcedDamage``:266-285`)写入且只作用于 `sourcedDamage`,故无来源点的「两集合皆空」在实践中等价于 `damages.size === 0``sourcedDamage` 的空点条目**不在**本次清理范围(非目标)"
- "`MapLayer.loadState``mapLayer.ts:922-931`)↔ 三档 `load*``:855-931`)↔ `if (save.dynamicBlocks)` 守卫(`:862`/`:886`/`:917`)——清理必须放在 `loadState` 入口而非 `loadDynamics` 内部,否则「存档不含动态块」时不会清理、读档结果与存档点不一致"
- "新增 `clearDynamics``deleteDynamic``:498-507`/ `syncStaticEvent``:133-145`/ `removeTile``:118-124`/ `tilePosMap`+`posTileMap``:48-51`/ `IMapLayerHooks.onDeleteDynamic``types.ts:257`,返回 `Promise<void>`)——复用同一删除语义,唯一差异是读档路径不等待钩子"
- "`MapLayer.setMapRef``:371-391`/ `getMapRef``:393-395`/ `resize`+`resize2` 的另两处 `mapData.expired``:625`/`:668`——Q2=A 裁定**零改动**:保留「标旧对象 `expired` + 整对象替换」现契约,契约文本落在 Task 0 `<record>` 与 SUMMARY源码 jsDoc 按用户长期规则不改)"
assumptions:
- "A1D-10 例外,本计划授权):本计划为修复计划,为 CR-02 与 `#06-17-8` 新增回归用例是必需项;既有断言不得弱化(`mapDamage.test.ts` 的 102/103/104/deleteEnemy/markEnemyDirty 用例、`map/saveLoad.test.ts` 的三档往返用例全部保持)"
- "A2不引入新状态语义CR-02 的修法只在既有结构(`dirtyIndexes`/`reducedCache`/`viewStore`/`damageStore`/`enemyStore`)内完成,不新增缓存层、不改 `IMapDamageReducer`/`IMapDamageConverter` 契约"
- "A3项目未发布与 07-09 的 A2 同口径):`IMapLayerSave`/存档形状不因本计划变更;动态块清理属读档行为而非格式变更"
- "A4范围守卫仅修 CR-02 与 `#06-17-8``07-REVIEW.md` 的 IN-01删除后 `point.damages`/`affectedBy` 残留、`sourcelessDamage` 空点残留)与 IN-04/IN-05 不在本轮 15 项清单,只在 Task 0 作为可选项列出、默认登记不修"
- "A1D-10 例外,本计划授权):本计划为修复计划,为 CR-02 与 IN-01 新增回归用例是必需项;**既有断言一字不改**`mapDamage.test.ts` 的 102/103/104、`deleteEnemy`、`markEnemyDirty`、多来源叠加用例,`map/saveLoad.test.ts` 的三档往返与码 124 用例全部保持);仅同步 `mapDamage.test.ts:1` 的文件头覆盖说明与新增用例的 `it` 前注释dev.md:85"
- "A2不引入新状态语义CR-02 与 IN-01 的修法只在既有结构(`dirtyIndexes`/`reducedCache`/`viewStore`/`damageStore`/`enemyStore`/`sourcelessDamage`/`sourcedDamage`)内完成,不新增缓存层、不改 `IMapDamageReducer`/`IMapDamageConverter` 契约、不改 `IMapDamage` 的方法签名"
- "A3项目未发布与 07-09 的 A2 同口径):`IMapLayerSave`/存档形状不因本计划变更;动态块清理属读档行为而非格式变更,不新增存档版本字段、不保留兼容分支"
- "A4范围守卫修 CR-02、IN-01Q4=A`#06-17-8` 的读档累积Q3=A1三项`setMapRef` 按 Q2=A **零改动**`sourcedDamage` 空点条目不删(非目标);`IN-04`/`IN-05` 不在本轮清单,只登记不修"
- "A5空集合不是「未登记」`Set` 为真值,故 `markEnemyDirty`/`deleteEnemy`/`removeEnemyAffecting` **不需要**为空集新增分支;明令禁止把空集当作未登记处理(那会让 `markEnemyDirty` 回退到 `refreshAll()`,或让光环重新出现后永不刷新),该口径须有用例固定"
- "A6读档清理的钩子语义`loadState` 为同步方法,`onDeleteDynamic` 返回 `Promise<void>`,故读档清理为「触发式、不等待」;用例只断言钩子被同步调用,不断言其 Promise 完成"
- "A7IN-01 的见证边界):`deleteEnemy` 的剪除有自动化见证(死视图不再被 `refreshIndex` 调用);`deleteMapDamage` 的空点移除**无公开可观测差异**,不用私有字段反射、不用连续 `as` 断言伪造见证,改以公开契约回归 + 逐行代码对照 `<record>` 确认并在 SUMMARY 登记"
prohibitions:
- "**禁止擅自修改/新增 jsDoc 注释**(用户 2026-09-17 长期规则,逐字:永远不要擅自改 jsDoc 注释)。本计划唯一被授权的 jsDoc 落笔Task 3 新增私有方法 `clearDynamics` 的 jsDocdev.md:73 要求方法必须有 jsDocdev.md:118 要求私有方法置于调用者之前);测试文件的 `it` 前单行中文注释与文件头覆盖说明属 dev.md:85 的既定要求,不在禁令内。既有 jsDoc/注释一律逐字保留"
- "`MapLayer.setMapRef``mapLayer.ts:371-391`)与 `getMapRef``:393-395`**零改动**Q2=A保留「旧 `mapData``expired = true` + 整对象替换」现契约);本计划不得出现任何针对 `setMapRef` 的代码改动或注释改动,也不得改动 `resize`/`resize2` 的 `expired` 口径"
- "不改 `packages-user/data-system/src/combat/types.ts``IMapDamage` 的声明与 jsDoc 逐字不改Q1=A 不需要任何接口/类型变更)"
- "不删除 `sourcedDamage` 中的空点条目Q4=A 未要求;该惰性残留不产生错误结果,登记于 SUMMARY"
- "不改 `packages-user/data-base/src/map/gameMap.ts` / `mapState.ts`(审计判定 `GameMap`/`MapState.loadState` 为原地读档、安全)"
- "不改 `packages-user/data-base/src/map/dynamicTile.ts`07-05 已修 num 恢复)"
- "不改 `packages-user/data-system/src/combat/context.ts``refreshEnemy`/`setEnemyAt` 调用点(只在 `mapDamage.ts` 内修复)"
- "不改渲染端 `@user/client-*` 与 legacy 接线;不新增依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言"
- "不改渲染端 `@user/client-*` 与 legacy 接线;不新增依赖、不新建源码文件、不新增 `import type`、不使用连续 `as` 断言`as unknown as …`"
- "不新增 `it.skip`;不为转绿改写既有断言"
- "不得暂存或提交与本计划无关的既有未暂存改动(`packages-user/client-base/src/types.ts`、`packages-user/client-modules/src/types.ts`):提交一律按显式路径 `git add <file>`;禁止 `git add -A`/`git add -a`、`git stash`、`git clean`、`git reset --hard`"
---
<objective>
闭合地图侧两条缺陷:`MapDamage` 的幽灵伤害CR-02`MapLayer` 的读档失效/旧动态块契约(`#06-17-8` / 审计 G
闭合地图侧三条缺陷:`MapDamage` 的幽灵伤害CR-02与删除后的点残留IN-01以及 `MapLayer` 读档时的旧动态块累积(`#06-17-8` / 审计 G。`setMapRef` 的失效契约按用户裁决**零改动**
现状(逐条锚点):
1. **CR-02critical`data-system/src/combat/mapDamage.ts:240-256, 290-333`** `refreshEnemyAndClearCache()``removeEnemyAffecting(view)``:294`)——它删除该怪物的伤害对象并 `enemyStore.delete(view)``:255`),但**从不把被移除的坐标索引标脏、也不删对应 `reducedCache`**;随后只对新登记的索引做失效(`:329-332`),且视图集为空时在 `:300` 提前 `return`。后果:怪物光环/特殊属性消失(视图集变空)或范围收缩时,旧坐标的 `reducedCache` 永久停留在「含已移除贡献」的合并值——`getReducedDamage()`/`getSeparatedDamage()` 因索引不在 `dirtyIndexes` 而永不重算。`deleteEnemy()``:150-167`)标脏掩盖了整怪删除路径,`markEnemyDirty()``:135-148`,由 `EnemyContext.refreshEnemy()`/`setEnemyAt()` 触达)是真实可达路径。
2. **`#06-17-8`(低,审计 G** `MapLayer.setMapRef()``mapLayer.ts:371-391`)把旧 `mapData``expired` 后整对象替换、清 `staticTileCache``loadDynamics()``:841-849`)只按存档 `createDynamic` 新块、从不清理既有动态块——同一图层多次读档或读一份动态块更少的存档时旧块累积;`expired` 契约(持有者何时重取 `getMapRef()`)未成文。
1. **CR-02critical`data-system/src/combat/mapDamage.ts:240-256, 290-333`** `refreshEnemyAndClearCache()``removeEnemyAffecting(view)``:294`)——它删除该怪物的伤害对象并 `enemyStore.delete(view)``:255`),但**从不把被移除的坐标索引标脏、也不删对应 `reducedCache`**;随后只对新登记的索引做失效(`:329-332`),且视图集为空时在 `:300` 提前 `return`。后果:怪物光环/特殊属性消失(视图集变空)或范围收缩时,旧坐标的 `reducedCache` 永久停留在「含已移除贡献」的合并值——`getReducedDamage()`/`getSeparatedDamage()` 因索引不在 `dirtyIndexes` 而永不重算。`deleteEnemy()``:150-167`)标脏掩盖了整怪删除路径,`markEnemyDirty()``:135-148`,由 `EnemyContext.refreshEnemy()`/`setEnemyAt()` 触达)是真实可达路径。附带的隐式后果:空视图集不登记 `enemyStore` 条目,使该怪物下次 `markEnemyDirty``refreshAll()``:142`)整表重算。
2. **IN-01Info`07-REVIEW.md`** `deleteMapDamage()``:114-120`)删除无来源伤害后不清理已空的 `IPointInfo``deleteEnemy()``:150-167`)删除后仍把已删视图留在 `point.affectedBy`、把已删伤害留在 `point.damages`,依赖 `refreshIndex()``viewStore` 判空(`:417`)惰性跳过——属簿记/内存泄漏而非错误结果。
3. **`#06-17-8`(低,审计 G** `MapLayer.loadDynamics()``mapLayer.ts:841-849`)只按存档 `createDynamic` 新块、从不清理既有动态块——同一图层多次读档或读一份动态块更少的存档时旧块累积,读档结果与存档点不一致;`setMapRef()``:371-391`)的 `expired` 契约无成文文本(用户裁决保留现契约、不作任何改动)。
Purpose: 地图伤害是「怪物在哪里、谁该受伤」的唯一事实源,缓存残留会让玩家在光环消失后仍持续受伤(或显示已不存在的伤害);动态块累积会让读档后的地图与存档点不一致。两者都在「读档/刷新」的失效边界上,属同一主题。
Purpose: 地图伤害是「怪物在哪里、谁该受伤」的唯一事实源,缓存残留会让玩家在光环消失后仍持续受伤(或显示已不存在的伤害);读档后动态块与存档点不一致会让同一存档反复读出的地图不同。三条都在「读档/刷新」的失效边界上,属同一主题。
Output: `mapDamage.ts`(失效补全)+ `combat/types.ts`(失效契约 jsDoc+ `mapDamage.test.ts`CR-02 回归);`mapLayer.ts``setMapRef`/`loadDynamics` 按裁决)+ `mapLayer.test.ts` + `saveLoad.test.ts``#06-17-8` 回归)。
**Artifacts this phase produces本计划产出物**:修改文件见 frontmatter `files_modified`;回归用例见 `must_haves.artifacts`;无新建源码文件、无新增依赖。
Output本计划产出物 = frontmatter `files_modified` 的 5 个文件):`mapDamage.ts`Q1=A 标脏 + 空集登记Q4=A 两处清理)+ `mapDamage.test.ts`CR-02 与 IN-01 回归);`mapLayer.ts`Q3=A1 读档前全清,新增私有 `clearDynamics`+ `mapLayer.test.ts` + `saveLoad.test.ts``#06-17-8` 回归)。**`combat/types.ts` 不在改动清单**Q1=A 无需接口变更,且用户长期规则禁止擅自改动 jsDoc**`setMapRef` 零改动**Q2=A。无新建源码文件、无新增依赖。
**Phase 7 验证已失效**`07-VERIFICATION.md`2026-09-16T06:04:11Z已因 07-09 与本轮 07-10..07-14 失效。本计划执行完毕后**必须重跑 `/gsd-verify-work`**(或 `gsd-verify-work 7`)重新出具验证结论。
Phase 7 裁决对应逐条引用D-01: 本计划是 Phase 7 第 13 个计划,承接 `07-REVIEW.md` CR-02 与 `07-LOADSTATE-AUDIT.md` G 行(`#06-17-8`。D-02: 属 map 系统(`data-base/src/map`)与 combat 系统(`data-system/src/combat`的地图伤害子系统按严重度递减排序。D-03: `#06-01-3` before 语义不涉及。D-04: `#06-06-1` 越图码 128不涉及(本计划不改 `transferToDynamic`/`transferToStatic` 的诊断码)。D-05: 码 178 语义不涉及。D-06: 码 147 保留不涉及。D-07: path 接线不涉及。D-08: enemy 复用映射不涉及。D-09: 由 Task 0 关卡承载。D-10: 本计划为修复计划回归用例为必需项A1。D-11: 失效契约与 `setMapRef` 语义变更需同步 jsDoc。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按缺陷原子提交Task 1 / Task 2。D-14: 两条均无 `WINDOWS.md` 条目,只登记不新建。
Phase 7 裁决对应逐条引用D-01: 本计划是 Phase 7 第 13 个计划,承接 `07-REVIEW.md` CR-02/IN-01`07-LOADSTATE-AUDIT.md` G 行(`#06-17-8`。D-02: 属 map 系统(`data-base/src/map`)与 combat 系统(`data-system/src/combat`的地图伤害子系统按严重度递减排序。D-03: `#06-01-3` before 语义不涉及。D-04: `#06-06-1` 越图码 128不涉及。D-05: 码 178 语义不涉及。D-06: 码 147 保留不涉及。D-07: path 接线不涉及。D-08: enemy 复用映射不涉及。D-09: 由 Task 0 关卡承载,用户 2026-09-17 已裁决并锁入 `<record>`Q1=A / Q2=A 零改动 / Q3=A1 / Q4=A。D-10: 本计划为修复计划回归用例为必需项A1。D-11(契约变更需同步 jsDoc在本计划**不适用**Q2=A 明确「零代码、零 jsDoc 改动」,且用户 2026-09-17 立下长期规则「永远不要擅自改 jsDoc 注释」——故空集语义与 `setMapRef` 契约以 Task 0 `<record>` + `07-13-SUMMARY.md` 承载不改源码注释。D-12: 沿用 06-CONTEXT D-44 文件级三步门禁。D-13: 按缺陷原子提交Task 4 落三条CR-02 / IN-01 / `#06-17-8`。D-14: 三条均无 `WINDOWS.md` 条目,只登记不新建。
执行序:`depends_on: 07-12`Wave 13。门禁型依赖D-09 逐计划裁决关卡 + D-12 全量测试基线逐计划重录);源码层面与 07-12、07-11 无耦合,但因 `pnpm test:ci` 基线需逐计划重录,不并波执行。
**基线2026-09-16 实测)**`pnpm test:ci` = 66 文件 / 690 通过 / 1 跳过(`equipment.test.ts:306` 码 147D-06。执行前重录当日基线并与本行对比。
**隔离要求(执行前必读)**:工作树存在与本计划**无关**的既有未暂存改动——`packages-user/client-base/src/types.ts` 与 `packages-user/client-modules/src/types.ts`。本计划不得触碰、不得暂存、不得提交它们(见 prohibitions 最后一条)。
**基线07-12 收尾实测2026-09-17**`pnpm test:ci` = 66 文件 / **729 通过 / 0 失败 / 1 跳过**`equipment.test.ts:306` 码 147D-06。执行前重录当日基线并与本行对比本计划新增回归用例会使通过数上升跳过项必须仍只有 1 条。
</objective>
<execution_context>
@ -101,199 +113,263 @@ Phase 7 裁决对应逐条引用D-01: 本计划是 Phase 7 第 13 个
@packages-user/data-system/src/combat/mapDamage.ts
@packages-user/data-system/src/combat/mapDamage.test.ts
@packages-user/data-base/src/map/mapLayer.ts
@packages-user/data-base/src/map/mapLayer.test.ts
@packages-user/data-base/src/map/saveLoad.test.ts
**执行起点提示裁决2026-09-17**Task 0 的 `<record>` 已由计划修订填入用户裁决,执行者**核对记录后直接进入 Task 1**,不再重复询问。要点:**Q1=A**(移除端标脏 + 空视图集登记);**Q2=A 零改动**`setMapRef` 保留现契约,**不改代码、不改 jsDoc**,用户长期规则:永远不要擅自改 jsDoc 注释);**Q3=A1**(读档前全清既有动态块,复用 `deleteDynamic` 语义并触发 `onDeleteDynamic` 钩子);**Q4=A**IN-01 一并修:`deleteMapDamage` 在两集合皆空时移除 `sourcelessDamage` 的空 `IPointInfo``deleteEnemy` 剪除被删视图的 `point.affectedBy` 条目并清除其 `point.damages` 条目)。
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 契约裁决关卡D-09——CR-02 失效补全口径(标脏 + 空视图集是否登记)、`setMapRef` 失效契约(替换对象 vs 原地变更、读档旧动态块处置、IN-01 是否纳入</name>
<name>Task 0: 契约裁决关卡D-09——CR-02 失效补全口径(标脏 + 空视图集是否登记)、`setMapRef` 失效契约(替换对象 vs 原地变更、读档旧动态块处置、IN-01 是否纳入【已于 2026-09-17 裁决Q1=A / Q2=A零改动/ Q3=A1 / Q4=A选项块保留为历史记录】</name>
<files>(只读汇报,不修改任何文件)</files>
<read_first>
- packages-user/data-system/src/combat/mapDamage.ts:58-72 内部结构、:114-133 `deleteMapDamage`/`markDirtyIndex`/`markDirty`、:135-148 `markEnemyDirty`、:150-167 `deleteEnemy`、:169-222 读取与合并缓存、:224-256 `clearSourceState`/`removeEnemyAffecting`、:258-285 `registerSourcedDamage`、:290-333 `refreshEnemyAndClearCache`、:338-372 `refreshEnemy`、:374-390 `refreshAll`、:395-427 `refreshIndex`
- packages-user/data-system/src/combat/types.ts`IMapDamage` 的 `markEnemyDirty`/`deleteEnemy` 声明与 jsDoc 位置)
- packages-user/data-system/src/combat/mapDamage.test.ts:1-2 文件头覆盖说明、:19-90 夹具、后半段 `deleteEnemy`/`markEnemyDirty` 用例,注意文件头是否需同步更新)
- packages-user/data-base/src/map/mapLayer.ts:44-51 私有映射、:100-124 `removeTileFromPosMap`/`removeTile`、:371-395 `setMapRef`/`getMapRef`、:418-433 `createDynamic`、:461-507 删除动态块(含 `deleteDynamic``syncStaticEvent`/钩子)、:512-514 `iterateDynamicTiles`、:600-681 `isEqualToRef`/`compareWith`/`resize`/`resize2`(另两处 `mapData.expired`)、:841-849 `loadDynamics`、:855-931 三档 `load*`
- packages-user/data-base/src/map/mapLayer.test.ts、packages-user/data-base/src/map/saveLoad.test.ts:176-220 `MapLayer save and load round trips` 与 124 告警用例)
- .planning/phases/07-data-fixes/07-REVIEW.mdCR-02 与 IN-01 原文、建议修法)
- packages-user/data-system/src/combat/mapDamage.test.ts:1-2 文件头覆盖说明、:19-90 夹具与 `Map.prototype.getOrInsertComputed` 补丁、:101-203 `FakeRange`/`FakeView`/`FakeConverter`/`FakeReducer`、:378-546 sourceless 与 sourced 用例群、后半段 `deleteEnemy`/`markEnemyDirty` 用例)
- packages-user/data-base/src/map/mapLayer.ts:44-51 私有映射、:100-124 `removeTileFromPosMap`/`removeTile`、:128-145 `syncStaticEvent`、:371-395 `setMapRef`/`getMapRef`、:418-433 `createDynamic`、:460-507 删除与转换动态块(含 `deleteDynamic``syncStaticEvent`/钩子)、:512-514 `iterateDynamicTiles`、:600-681 `isEqualToRef`/`compareWith`/`resize`/`resize2`(另两处 `mapData.expired`)、:837-849 `loadDynamics`、:851-931 三档 `load*``loadState`
- packages-user/data-base/src/map/types.ts:189-191 `IMapLayerData.expired`、:207-257 `IMapLayerHooks.onDeleteDynamic?`(返回 `Promise<void>`)、:470 `IHookable`
- packages-user/data-base/src/map/mapState.ts:409-425 `MapState.loadState` 为**同步**逐层读档——决定读档清理无法 `await` 钩子)
- packages-user/data-base/src/map/mapLayer.test.ts、packages-user/data-base/src/map/saveLoad.test.ts:176-218 `MapLayer save and load round trips` 与码 124 用例、:109-112 `setLayerReference`
- .planning/phases/07-data-fixes/07-REVIEW.mdCR-02 原文与建议修法 `:98-132`IN-01 原文与 `Fix` `:241-245`
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.mdG 行与「未确定项 2」
</read_first>
<decision>
地图侧两条修复的契约点:
地图侧修复的契约点**用户已于 2026-09-17 逐条裁决,锁定结果见本任务 `<record>`**
Q1CR-02 的失效补全口径(是否标脏;视图集为空时是否登记反向索引以免触发 `refreshAll()`Q2`MapLayer.setMapRef` 的失效契约(替换 `mapData` 对象 + 旧对象标 `expired`还是原地变更Q3读档时旧动态块的处置全清 / 只清存档外坐标 / 明写合并Q4`IN-01`(删除后的点残留与 `sourcelessDamage` 空点)是否纳入本计划。
</decision>
<context>
**本关卡已裁决2026-09-17下述选项块保留为历史记录与决策依据不再需要用户在场确认执行者核对 `<record>` 后直接进入 Task 1。**
两个决定都直接改变对外可观测行为:
`refreshEnemyAndClearCache``set.size === 0` 早退(`:300`)与 `enemyStore.delete(view)``:255`)组合出一个隐式后果——**视图集为空的怪物在下次 `markEnemyDirty` 时会走 `refreshAll()` 整表重算**`:142`)。修复 CR-02 时是否顺手登记空视图集(`enemyStore.set(view, new Set())`)会决定这是「按需重算」还是「整表重算」。
`setMapRef` 现在把旧 `mapData``expired` 再整对象替换(`:380-386`):这是外部持有者唯一的失效信号,但持有者拿到的对象上 `array` 仍是旧缓冲。是否改成原地变更(同引用原则的另一种形式)会决定外部持有者是「必须重取」还是「自动看到新缓冲」,两者不能同时成立。
动态块清理涉及是否复用 `deleteDynamic``syncStaticEvent(tile, false)``onDeleteDynamic` 钩子(渲染端可能在监听),故必须由用户点名。
`refreshEnemyAndClearCache``set.size === 0` 早退(`:300`)与 `enemyStore.delete(view)``:255`)组合出一个隐式后果——视图集为空的怪物在下次 `markEnemyDirty` 时会走 `refreshAll()` 整表重算(`:142`)。用户裁决 Q1=A既补标脏也顺手登记空视图集使后续刷新走局部路径。
`setMapRef` 现在把旧 `mapData``expired` 再整对象替换(`:380-386`)。用户裁决 Q2=A**保留现契约,零改动**——并追加一条长期规则「永远不要擅自改 jsDoc 注释」,故连该契约的 jsDoc 也不写(契约文本改由本 `<record>``07-13-SUMMARY.md` 承载)。
动态块清理涉及是否复用 `deleteDynamic``syncStaticEvent(tile, false)``onDeleteDynamic` 钩子(渲染端可能在监听)。用户裁决 Q3=A1全清并**触发**钩子;因 `loadState` 为同步方法钩子为触发式、不等待A6
Q4 用户裁决为 AIN-01 一并纳入本计划。
</context>
<options>
**历史选项说明**:本块为裁决时的选项记录。除标注【已选】者外,其余选项一律为**未选·历史**,仅保留决策依据;`revise` 兜底未触发。裁决结果见 `<record>`。)
<option id="q1-mark-dirty">
<name>Q1 选项 A【建议默认】`removeEnemyAffecting` 收集被移除的坐标索引并逐个 `markDirtyIndex(index)``refreshEnemyAndClearCache`(与 `refreshEnemy`)在视图集为空时**仍登记 `enemyStore.set(view, emptySet)`** 并完成失效,不再提前 `return` 跳过登记</name>
<name>【已选Q1=AQ1 选项 A`removeEnemyAffecting` 收集被移除的坐标索引并逐个 `markDirtyIndex(index)``refreshEnemyAndClearCache`(与 `refreshEnemy`)在视图集为空时**仍登记 `enemyStore.set(view, emptySet)`** 并完成失效,不再提前 `return` 跳过登记</name>
<pros>`07-REVIEW.md` 的建议修法一致;幽灵伤害在两条路径(来源消失 / 范围收缩)上一次消除;空视图集被登记后,后续 `markEnemyDirty` 走局部刷新而不是 `refreshAll()` 整表重算(避免性能与行为双重抖动)</pros>
<cons>`enemyStore` 会开始保存「空集合」条目`markEnemyDirty`/`deleteEnemy` 的读法需相应容错(`Set.size === 0` 分支);需在 jsDoc 写明空集语义</cons>
<cons>`enemyStore` 会开始保存「空集合」条目;因空 `Set` 本身是真值,`markEnemyDirty`/`deleteEnemy` 的既有 `if (!store)` 天然不误判,故**无需**新增分支但必须以用例固定该口径防止后续被改成「空集即未登记」A5</cons>
</option>
<option id="q1-cache-only">
<name>Q1 选项 B只在移除端删除 `reducedCache`(不标脏、不登记空集),让 `getReducedDamage` 下次按剩余伤害重算</name>
<name>【未选·历史】Q1 选项 B只在移除端删除 `reducedCache`(不标脏、不登记空集),让 `getReducedDamage` 下次按剩余伤害重算</name>
<pros>改动最小(一行 `reducedCache.delete` 的循环),不动 `dirtyIndexes`/`enemyStore` 结构</pros>
<cons>`refreshIndex` 的重新聚合不再执行,`point.damages` 中的其它陈旧项不会被清理;`enemyStore` 为空的行为(下次 `markEnemyDirty``refreshAll()`保持不变CR-02 的「范围收缩」场景只被间接修复</cons>
</option>
<option id="q1-document">
<name>Q1 选项 C不改行为只把「`markEnemyDirty` 之后旧坐标缓存可能残留」写进 `IMapDamage` 契约并登记</name>
<name>【未选·历史】Q1 选项 C不改行为只把「`markEnemyDirty` 之后旧坐标缓存可能残留」写进 `IMapDamage` 契约并登记</name>
<pros>零风险</pros>
<cons>CR-02 是 review 判定的 correctness 缺陷(幽灵伤害可被玩家观测),与「本轮修复 15 项」的指令冲突(清单第 13 条)</cons>
<cons>CR-02 是 review 判定的 correctness 缺陷(幽灵伤害可被玩家观测),与「本轮修复」的指令冲突(清单第 13 条)</cons>
</option>
<option id="q2-replace">
<name>Q2 选项 A【建议默认】保持「旧 `mapData``expired` + 替换为新对象」的既有契约,并把该契约写进 `IMapLayerData`/`getMapRef` 的 jsDoc持有者见 `expired === true` 必须重取;`array` 引用可能失效)</name>
<name>【已选Q2=AQ2 选项 A保持「旧 `mapData``expired` + 替换为新对象」的既有契约。**用户追加裁决:本计划不得改动 `setMapRef` 的代码,也不得为该契约改写/新增任何 jsDoc 注释**(长期规则:永远不要擅自改 jsDoc 注释)——契约文本落在本 `<record>` 与 SUMMARY</name>
<pros>零行为变更、零风险;`expired` 语义与 `resize`/`resize2``:625`/`:668`)保持一致(三处都是替换 + 标旧)</pros>
<cons>持有者必须主动轮询/检查 `expired`,契约靠文档而非类型约束</cons>
<cons>持有者必须主动检查 `expired`;且该契约不进源码注释(按用户明令),只能靠计划/SUMMARY 存档与后续阅读者自觉</cons>
</option>
<option id="q2-inplace">
<name>Q2 选项 B改为原地变更`this.mapData.expired = false; this.mapData.array = this.mapArray`),不再替换对象</name>
<name>【未选·历史】Q2 选项 B改为原地变更`this.mapData.expired = false; this.mapData.array = this.mapArray`),不再替换对象</name>
<pros>外部持有者的引用始终有效、自动看到新缓冲(同引用原则的又一处应用)</pros>
<cons>`expired` 失效信号将**永不生效**(没有可观测的过期窗口),现有依赖该信号重取的调用方必须改成其它判据;`resize`/`resize2` 需一并改,改动面扩大</cons>
</option>
<option id="q3-clear-all">
<name>Q3 选项 A【建议默认】`loadState`(三档)在读入动态块之前**清空本图层全部既有动态块**(复用内部 `removeTile` 解除两张映射,并按裁决决定是否 `syncStaticEvent(tile, false)` 与触发 `onDeleteDynamic`),使读档后动态块集合等于存档</name>
<pros>读档语义与「回到存档点」一致;多次读档不再累积;与 `MapLayer` 其它读档字段(静态块原地 `loadState`、矩阵 `setMapRef`口径统一</pros>
<cons>读档会销毁旧动态图块实例(外部持有者脱钩——与「读档即替换」语义一致,但需在 jsDoc 写明);若勾选触发 `onDeleteDynamic`,渲染端会在读档期收到一批删除通知</cons>
<name>【已选Q3=A1】Q3 选项 AA1 口径):`loadState` 在读入动态块之前**清空本图层全部既有动态块**——逐块复用 `deleteDynamic` 的语义(`syncStaticEvent(tile, false)` + `removeTile(tile)` + 触发 `onDeleteDynamic` 钩子),使读档后动态块集合等于存档;清理放在 `loadState` 入口(而非 `loadDynamics` 内部的 `if (save.dynamicBlocks)` 之后),以保证三档一致且「存档不含动态块」时也清理</name>
<pros>读档语义与「回到存档点」一致;多次读档不再累积;与 `MapLayer` 其它读档字段口径统一;清理位置在入口使其对三档与缺字段情形**按构造**成立</pros>
<cons>读档会销毁旧动态图块实例(外部持有者脱钩——与「读档即替换」语义一致,需在 SUMMARY 写明);渲染端会在读档期收到一批 `onDeleteDynamic` 通知,且因 `loadState` 同步而**不被等待**A6</cons>
</option>
<option id="q3-clear-missing">
<name>Q3 选项 B只清理「存档中不存在」的坐标上的动态块合并语义存档里出现的坐标保留并复用</name>
<name>【未选·历史】Q3 选项 B只清理「存档中不存在」的坐标上的动态块合并语义存档里出现的坐标保留并复用</name>
<pros>旧实例尽量保留,减少脱钩</pros>
<cons>同一坐标上「存档块数 < 现有块数的场景如同一坐标叠了 3 存档只有 1 仍需逐块比对逻辑复杂且容易出现半合并状态实现成本明显高于收益</cons>
<cons>同一坐标上「存档块数 < 现有块数的场景仍需逐块比对逻辑复杂且容易出现半合并状态实现成本明显高于收益</cons>
</option>
<option id="q3-document">
<name>Q3 选项 C不改行为把「`loadState` 会与既有动态块合并/累积」写进 `IMapLayer`/`IMapLayerSave` 契约并登记</name>
<name>【未选·历史】Q3 选项 C不改行为把「`loadState` 会与既有动态块合并/累积」写进 `IMapLayer`/`IMapLayerSave` 契约并登记</name>
<pros>零代码风险</pros>
<cons>`#06-17-8` 未修(审计 G 明确「旧动态块累积」);读档结果与存档点不一致,属可观测的正确性问题</cons>
</option>
<option id="q4-include">
<name>Q4 选项 A`IN-01``deleteMapDamage`/`deleteEnemy` 后的 `point.damages`/`affectedBy` 残留与 `sourcelessDamage` 空点)纳入本计划一并清理</name>
<pros>CR-02 的清理路径与 IN-01 相邻(都在 `removeEnemyAffecting`/`deleteEnemy` 的清理语句上),一次改动收敛</pros>
<cons>超出用户点名的 15 项清单(属 review Info 级),需要明确授权;会扩大 CR-02 的回归面</cons>
<name>【已选Q4=A】Q4 选项 A`IN-01` 纳入本计划一并清理——`deleteMapDamage` 在删除后该点两集合皆空时把 `IPointInfo``sourcelessDamage` 中移除;`deleteEnemy` 剪除已删视图的 `point.affectedBy` 条目并清除其 `point.damages` 条目</name>
<pros>CR-02 的清理路径与 IN-01 相邻(都在 `removeEnemyAffecting`/`deleteEnemy`/`deleteMapDamage` 的清理语句上),一次改动收敛`deleteEnemy` 的剪除有自动化见证(死视图不再被 `refreshIndex` 调用)</pros>
<cons>超出用户点名的 15 项清单(属 review Info 级),需明确授权(用户已授权);`deleteMapDamage` 的空点移除无公开可观测差异须如实登记其为簿记修复A7</cons>
</option>
<option id="q4-register">
<name>Q4 选项 B【建议默认】`IN-01` 只登记不修(本轮清单未含)</name>
<name>【未选·历史】Q4 选项 B`IN-01` 只登记不修(本轮清单未含)</name>
<pros>严格守住清单边界CR-02 的改动面最小(只补失效,不动移除语义)</pros>
<cons>`point.affectedBy`/`sourcelessDamage` 的空条目泄漏保留在册review 已判定为 lazy cleanup 而非错误结果)</cons>
</option>
<option id="revise">
<name>(兜底)修订方案后再执行</name>
<name>【兜底·未触发】修订方案后再执行</name>
<pros>避免带错误契约落地</pros>
<cons>按 D-09 退出本次修改、修订本 PLAN.md 后重新执行,不得自行另辟他法</cons>
</option>
</options>
<record>
待用户在关卡处逐条填写Q1..Q4勾选选项 id 或给出裁决文字)。
**空的 `<record>` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。
填写后 Task 13 一律按本记录执行;若裁决与 Task 13 的分支描述冲突,暂停并请用户澄清。
用户裁决2026-09-17逐条本节由计划修订填入执行者核对后直接进入 Task 1不再重复询问
- **Q1 = A标脏 + 登记空集)**`removeEnemyAffecting``mapDamage.ts:240-256`)收集被移除的坐标索引,并在方法末尾对每个索引调用 `markDirtyIndex(index)``refreshEnemyAndClearCache``:290-333`)与 `refreshEnemy``:338-372`**不再在 `set.size === 0` 时早退**——它们先登记 `enemyStore.set(view, emptySet)` 并完成既有失效(移除端的标脏已在 `removeEnemyAffecting` 内发生),使该怪物后续的 `markEnemyDirty` 走**局部刷新**`refreshEnemyAndClearCache`)而不是 `refreshAll()``:142`)。空视图集的读法口径:空 `Set` 为真值,`markEnemyDirty`/`deleteEnemy` 的既有 `if (!store)` 天然不误判,**不得**为空集新增早退分支A5
- **Q2 = A保留替换 + `expire`)——但附用户明令的例外:不修 jsDoc**`MapLayer.setMapRef``:371-391`**保持现有行为**(旧 `mapData``expired = true` + 整对象替换为新对象,与 `resize`/`resize2` 的 `:625`/`:668` 口径一致)**且保持其现有注释与 jsDoc——本计划对 `setMapRef` 无代码改动、无 jsDoc 改动**(用户逐字强调:按 A 走,但是永远不要擅自改 jsDoc 注释)。契约登记:本记录 + `07-13-SUMMARY.md`(源码注释不改,见 D-11 在本计划不适用的说明)。
- **用户长期规则(对本计划全程生效)****禁止擅自修改/新增 jsDoc 注释**(除非该计划任务被明确授权)。本计划唯一被授权的 jsDoc 落笔是 Task 3 新增私有方法 `clearDynamics` 的 jsDocdev.md:73 要求方法必须有 jsDoc测试文件的 `it` 前单行中文注释与文件头覆盖说明属 dev.md:85 的既定要求,不在禁令范围。
- **Q3 = A1全清 + 触发钩子)**:读档时在装入动态块之前**清空本图层全部既有动态块**,逐块复用 `deleteDynamic` 的语义——`syncStaticEvent(tile, false)` + `removeTile(tile)` + 触发 `onDeleteDynamic` 钩子(渲染端可能正在监听,**必须触发**)。读档结果等于存档点(同一存档反复读、或读到动态块更少的存档,均**不累积**)。清理位于 `loadState` 入口(三档共用、且「存档不含 `dynamicBlocks`」时照样清理)。因 `loadState` 同步、`onDeleteDynamic` 返回 `Promise<void>`,钩子为**触发式、不等待**A6
- **Q4 = A一并修 IN-01**`deleteMapDamage``:114-120`)在该点 `damages``affectedBy` 均为空时,把该 `IPointInfo``sourcelessDamage``:45`)中移除;`deleteEnemy``:150-167`)把被删视图从 `point.affectedBy` 剪除并清除其 `point.damages` 条目,**不再**依赖 `refreshIndex``:395-427`)的 `viewStore` 判空惰性跳过。`sourcedDamage` 中的空点条目**不删除**(非目标)。
填写后 Task 14 一律按本记录执行;若执行期发现与本记录冲突,暂停并请用户澄清。
</record>
<resume-signal>逐条给出 Q1..Q4 的裁决例如「Q1=A, Q2=A, Q3=A, Q4=B」若要修订方案回复修订点执行者退出并按 D-09 修订本 PLAN.md</resume-signal>
<action>向用户汇报上述 4 个契约点,重点说明 Q1 对 `refreshAll()` 触发频率的影响、Q2 对 `getMapRef()` 持有者的影响、Q3 对渲染端 `onDeleteDynamic` 通知的影响。不得在裁决前修改任何文件。</action>
<resume-signal>已裁决2026-09-17Q1=A, Q2=A保留替换 + `expired`**零代码改动、零 jsDoc 改动**, Q3=A1全清 + 触发 `onDeleteDynamic`, Q4=A一并修 IN-01。执行者核对 `<record>` 后直接进入 Task 1若记录与用户实际意图不符暂停并请用户澄清后再继续</resume-signal>
<action>本关卡已裁决:向执行者转述 `<record>` 的五条口径——Q1=A标脏 + 空集登记、Q2=A`setMapRef` **零代码、零 jsDoc** 改动)、用户长期规则(禁止擅自修改/新增 jsDoc唯一例外是 Task 3 新增私有方法的 jsDoc、Q3=A1读档前全清 + **必须触发** `onDeleteDynamic`钩子不等待、Q4=AIN-01 两处清理,`sourcedDamage` 空点不删)——核对无误后直接进入 Task 1。执行者**不得**在 Task 1 之前修改任何文件,也**不得**按任何【未选·历史】选项推进</action>
<verify>
<human-check>`<record>` 中 Q1..Q4 均有明确裁决,且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进</fails_when>
<human-check>`<record>` 中 Q1..Q4 均有明确裁决(含 Q2「保留现契约、零代码改动、零 jsDoc 改动」与用户长期规则、Q3「钩子必须触发」两条关键口径,且与用户实际意图一致</human-check>
<fails_when>记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」或【未选·历史】选项自行推进</fails_when>
</verify>
<acceptance_criteria>
- Q1CR-02 失效口径、Q2`setMapRef` 失效契约、Q3旧动态块处置、Q4IN-01 纳入与否)均有用户裁决并写入 `<record>`
- Q3 已明确是否复用 `deleteDynamic``syncStaticEvent`/`onDeleteDynamic` 行为
- 未在裁决前修改任何文件
- Q1CR-02标脏 + 空集登记、Q2`setMapRef`:保留替换 + `expired`,零代码、零 jsDoc 改动、Q3旧动态块全清 + 触发钩子、Q4IN-01 纳入并两处清理)四条裁决均已写入 `<record>`
- Q3 已明确复用 `deleteDynamic``syncStaticEvent(tile, false)` + `removeTile` + `onDeleteDynamic`,且明写钩子**必须被触发**(因 `loadState` 同步而不等待)
- 用户长期规则「禁止擅自修改/新增 jsDoc 注释(除明确授权)」已写入 `<record>` 并落到 frontmatter `prohibitions`
- 未在 Task 1 之前修改任何文件
</acceptance_criteria>
<done>Q1..Q4 裁决已完整写入 `<record>`;执行者据记录直接进入 Task 1不再重复询问</done>
<done>Q1..Q4 裁决与用户长期规则已完整写入 `<record>`;执行者据记录直接进入 Task 1不再重复询问</done>
</task>
<task type="auto">
<name>Task 1: CR-02 —— `MapDamage` 失效补全(移除端标脏 + 空视图集登记),含「来源消失」与「范围收缩」两条幽灵伤害回归</name>
<files>packages-user/data-system/src/combat/mapDamage.ts, packages-user/data-system/src/combat/types.ts, packages-user/data-system/src/combat/mapDamage.test.ts</files>
<name>Task 1: CR-02Q1=A—— `MapDamage` 失效补全(移除端标脏 + 空视图集登记),含「来源消失」与「范围收缩」两条幽灵伤害回归及「不再整表刷新」见证</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:126-148 `markDirtyIndex`/`markEnemyDirty`、:150-167 `deleteEnemy` 的标脏参照、:224-256 `clearSourceState`/`removeEnemyAffecting`、:290-333 `refreshEnemyAndClearCache`、:338-372 `refreshEnemy`、:395-427 `refreshIndex`
- packages-user/data-system/src/combat/types.ts`IMapDamage` 方法声明与 jsDoc 位置
- packages-user/data-system/src/combat/mapDamage.test.ts:1-2 文件头覆盖说明、夹具(假视图/转换器/reducer、`deleteEnemy` 与 `markEnemyDirty` 现有用例
- packages-user/data-system/src/combat/mapDamage.ts:126-133 `markDirtyIndex`/`markDirty`、:135-148 `markEnemyDirty``refreshAll()` 分支、:150-167 `deleteEnemy` 的标脏参照、:240-256 `removeEnemyAffecting`、:266-285 `registerSourcedDamage`、:290-333 `refreshEnemyAndClearCache`:294 移除、:300 早退、:301 登记、:329-332 重登失效)、:338-372 `refreshEnemy`、:374-390 `refreshAll`、:395-427 `refreshIndex`
- packages-user/data-system/src/combat/mapDamage.test.ts:1 文件头覆盖说明、:19-43 全局补丁、:101-203 `FakeRange``indexes` 内容可变)/`FakeView`/`FakeConverter``views` 数组内容可变、`calls` 计数)/`FakeReducer``calls` 计数)、:282-360 `createFixture``damageView` 为 `FakeView(7, [index(1,0)])`)、:502-546 既有 `markEnemyDirty`/`deleteEnemy` 用例
- .planning/phases/07-data-fixes/07-REVIEW.mdCR-02 原文 `:98-132`,含建议修法与「`deleteEnemy` 标脏掩盖了整怪删除路径」的说明
</read_first>
<action>
按 Task 0 的 Q1 裁决修复。**若 Q1 = 选项 A**
(1) `removeEnemyAffecting``:240-256`):在内层 `store.damages.forEach((dam, index) => …)``:246-252`)中把 `index` 收集进一个 `Set<number>`;方法结束前对每个收集到的索引调用 `this.markDirtyIndex(index)`;返回类型与调用点按实现需要调整(两个调用点:`:294`、`:339`
(2) `refreshEnemyAndClearCache``:290-333`)与 `refreshEnemy``:338-372`):把 `if (set.size === 0) return;` 改为——**先** `this.enemyStore.set(view, set)`(空集也登记,使后续 `markEnemyDirty` 走局部路径而非 `refreshAll()`),再在 `set.size === 0` 时直接结束(无需继续遍历视图);非空时保持既有登记与新索引失效(`:329-332` 的 `dirtyIndexes.delete`/`reducedCache.delete` 不变);
(3) `enemyStore` 的读法容错:`markEnemyDirty``:135-148`)与 `deleteEnemy``:150-167`)在 `store` 为**空集合**时不得视为「未登记」——`markEnemyDirty` 对空集合应直接结束(无视图可刷),`deleteEnemy` 对空集合应只做 `enemyStore.delete(view)``removeEnemyAffecting` 对空集合的遍历保持无副作用;
(4) `IMapDamage``combat/types.ts`)的 `markEnemyDirty`/`deleteEnemy` jsDoc 补写失效契约:调用后该怪物此前影响的坐标索引缓存保证失效;该怪物的反向索引(含空集)保持登记,后续刷新为局部而非整表。
**若 Q1 = 选项 B**:只在 `removeEnemyAffecting` 中收集索引并 `this.reducedCache.delete(index)`(不动 `dirtyIndexes`、不登记空集),并按裁决在 jsDoc 写明其局限。
**若 Q1 = 选项 C**:不改行为,仅在 jsDoc 写明已知限制并在 SUMMARY 登记。
回归用例(`mapDamage.test.ts`,中文单行注释;文件头覆盖说明同步更新):
(a) **来源消失**:注册带伤害的怪物视图 → `getReducedDamage(loc)` 预热缓存 → 使该怪物的转换视图集变为空(例如让假转换器返回空数组后调用 `refreshEnemy`/`markEnemyDirty`,或移除其特殊属性)→ 再次 `getReducedDamage(loc)` 断言为 `null`(或不再包含已移除贡献)、`getSeparatedDamage(loc)` 为空;
(b) **范围收缩**:假视图的范围/命中点位在第二次转换时变小 → `markEnemyDirty(view)` 后断言跌出范围的坐标 `getReducedDamage` 不再包含已移除贡献(用「多点位 + 局部收缩」构造,避免与 (a) 重复);
(c) **整怪删除回归不破**:既有 `removes enemy-sourced damage when the enemy is deleted``:106-…` 附近)用例保持通过;
(d) **不触发整表重算**Q1 选项 A 时):在空视图集场景下再次 `markEnemyDirty(view)`,断言行为仍为局部刷新(例如通过 reducer/转换器调用次数或 `enemyStore` 可观测状态断言,不使用对私有字段的反射)。
按 Task 0 `<record>` 的 Q1=A 修复(**无分支、无「建议默认」**
(1) **移除端标脏**:在 `removeEnemyAffecting``mapDamage.ts:240-256`)内新增局部 `const removed = new Set<number>();`,在内层 `store.damages.forEach((dam, index) => …)``:246-252`)回调的**第一条语句**处 `removed.add(index)`(该索引上确实登记了本视图的贡献,无论 `sourcedDamage` 是否仍有对应 point 条目);方法末尾(`this.enemyStore.delete(view)` 之前或之后均可,但必须在遍历结束后)对集合内每个索引调用 `this.markDirtyIndex(index)`。方法签名保持 `private removeEnemyAffecting(view: IEnemyView<TEnemy>): void`**不**改成返回 `Set`,不新增对外契约);两个调用点(`:294`、`:339`)逐字不变。
(2) **空视图集登记**`refreshEnemyAndClearCache``:290-333`)与 `refreshEnemy``:338-372`)中,把
`const set = new Set<IMapDamageView<any>>(views);` 之后的 `if (set.size === 0) return;``this.enemyStore.set(view, set);` 两行**换序**为:先 `this.enemyStore.set(view, set);`(空集也登记),再 `if (set.size === 0) return;`。`!this.converter` 与 `!createReadonlyHandler` 两个既有失败分支保持原样(它们不是「空视图集」路径,不属本次裁决)。`refreshEnemyAndClearCache` 的 `collection``collection.forEach(v => { this.dirtyIndexes.delete(v); this.reducedCache.delete(v); })``:302`、`:329-332`**逐字不改**——重登的索引清脏,跌出范围的索引保留 `removeEnemyAffecting` 留下的脏标记,下次读取时经 `refreshIndex` 按剩余贡献重算。
(3) **不改 `markEnemyDirty` 与 `deleteEnemy`**:空的 `Set` 本身是真值,故 `if (!store)``:138`、`:152`)不会把「已登记的空集合」误判为「未登记」——`markEnemyDirty` 会走 `refreshEnemyAndClearCache` 局部路径,`deleteEnemy` 对空集合只执行 `enemyStore.delete(view)`。**禁止**为空集新增任何早退分支(那会让 `markEnemyDirty` 回退到 `refreshAll()`或让光环重新出现后永不刷新该口径以用例固定A5
(4) **不修改 `combat/types.ts`**`IMapDamage` 的方法声明与 jsDoc 逐字不改Q1=A 不需要任何接口/类型变更),本任务**零 jsDoc 改动**(用户长期规则)。
(5) **回归用例(`mapDamage.test.ts` 新增 4 条;既有用例逐字保留)**,每条 `it` 前写一行中文注释dev.md:85并同步 `:1` 的文件头覆盖说明追加「CR-02 空视图集/范围收缩」字样):
(a) **来源消失 → 幽灵伤害消失**`useReducer` + `useConverter` 后先 `getReducedDamage(fixture.locator)?.damage` 为 7预热缓存`fixture.converter.views.length = 0``readonly` 字段的**数组内容**可变)后调用 `fixture.damage.markEnemyDirty(fixture.view)`;断言 `getReducedDamage(fixture.locator)``null``[...getSeparatedDamage(fixture.locator)]` 长度为 0。修复前该断言会读到残留的 7。
(b) **范围收缩 → 跌出范围的坐标无旧缓存**:用局部变量持有视图 `const view = new FakeView(7, [indexA, indexB])``indexA = context.indexer.locToIndex(fixture.locator.x, fixture.locator.y)``indexB` 取另一合法坐标如 `locToIndex(2, 1)``useReducer` + `useConverter(new FakeConverter([view]))`;对两个坐标各预热一次缓存(均为 7`view.range.indexes` 收缩为只含 `indexA``indexes.length = 1` 或等效)后 `markEnemyDirty(fixture.view)`;断言 `getReducedDamage(locatorB)``null`、`[...getSeparatedDamage(locatorB)]` 为空,且 `getReducedDamage(fixture.locator)?.damage` 仍为 7。
(c) **空集后 `markEnemyDirty` 走局部刷新(`refreshEnemyAndClearCache` 路径)**`useReducer` + `useConverter``fixture.converter.views.length = 0``markEnemyDirty(fixture.view)`(完成空集登记)→ `const iterateSpy = vi.spyOn(fixture.context, 'iterateEnemy'); const calls = fixture.converter.calls;` → 再次 `markEnemyDirty(fixture.view)` → 断言 `expect(iterateSpy).not.toHaveBeenCalled()``refreshAll()` 才会遍历怪物,`:387`)且 `fixture.converter.calls === calls + 1`
(d) **`refreshAll` 同样登记空集(`refreshEnemy` 路径)**`useReducer` + `useConverter``fixture.converter.views.length = 0``fixture.damage.refreshAll()` → 同 (c) 的 spy/`calls` 断言(再次 `markEnemyDirty(fixture.view)` 不触发 `iterateEnemy`、`converter.calls` 恰增 1。修复前该路径因 `refreshEnemy` 早退而不登记,`markEnemyDirty` 会退回 `refreshAll()`,断言失败。
(e) **整怪删除回归不破**:既有 `removes enemy-sourced damage when the enemy is deleted``:533-546`)与 `refreshes sourced damage when a registered enemy is marked dirty``:503-515`、102/103/104 用例逐字保留、必须继续通过。
注意:全部断言只经公开 API 与 `vi.spyOn` 观测(`converter.calls` 为既有字段),**不得**反射私有字段、不得使用连续 `as` 断言、不新增 `it.skip`、不弱化既有断言。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts</automated>
<fails_when>来源消失或范围收缩后 `getReducedDamage` 仍返回含已移除贡献的缓存值(幽灵伤害);或既有 `deleteEnemy`/102/103/104 用例出现回归 —— 按 D-09 退出并修订计划,不得改用「让调用方手动 markDirty」替代方案</fails_when>
<fails_when>来源消失或范围收缩后 `getReducedDamage` 仍返回含已移除贡献的缓存值(幽灵伤害);或空集登记后再次 `markEnemyDirty` 仍调用 `context.iterateEnemy()`(退回 `refreshAll()`);或既有 `deleteEnemy`/102/103/104/多来源叠加用例出现回归 —— 按 D-09 退出并修订计划,不得改用「让调用方手动 markDirty」「在 markEnemyDirty 里对空集早退」等替代方案</fails_when>
</verify>
<acceptance_criteria>
- 移除端触达的每个坐标索引都被失效Q1 选项 A`markDirtyIndex`;选项 B`reducedCache.delete`
- 视图集为空时按裁决完成失效与反向索引处置,并有 `markEnemyDirty` 重复调用不触发 `refreshAll()` 的断言(选项 A
- `IMapDamage` 的 jsDoc 与最终契约一致;既有 mapDamage 用例全部保持通过
- `removeEnemyAffecting` 对每个被移除的坐标索引都调用 `markDirtyIndex`;方法签名与两个调用点逐字不变
- `refreshEnemyAndClearCache``refreshEnemy` 均为「先 `enemyStore.set(view, set)``if (set.size === 0) return;`」;`collection` 的失效循环逐字未改
- 空视图集后再次 `markEnemyDirty``iterateEnemy` 未被调用 + `converter.calls` 恰增 1 的双重断言(`markEnemyDirty` 与 `refreshAll` 两入口各一条)
- `markEnemyDirty`/`deleteEnemy` 的代码逐字未改(未新增空集分支);`combat/types.ts` 未被改动;本条任务零 jsDoc 改动
- 既有 `mapDamage.test.ts` 用例全部保持通过,无新增 `it.skip`
</acceptance_criteria>
<done>怪物光环/特殊属性消失或范围收缩后不再有幽灵地图伤害且失效路径保持局部刷新CR-02 按裁决关闭)</done>
<done>怪物光环/特殊属性消失或范围收缩后不再有幽灵地图伤害,且空视图集被登记后失效路径保持局部刷新CR-02 按 Q1=A 关闭)</done>
</task>
<task type="auto">
<name>Task 2: `#06-17-8` —— `MapLayer.setMapRef` 失效契约 + 读档旧动态块处置(按 Q2/Q3 裁决)</name>
<files>packages-user/data-base/src/map/mapLayer.ts, packages-user/data-base/src/map/mapLayer.test.ts, packages-user/data-base/src/map/saveLoad.test.ts</files>
<name>Task 2: IN-01Q4=A—— 删除后的点残留清理(`deleteEnemy` 剪除 `affectedBy`/`damages``deleteMapDamage` 移除空 `IPointInfo`),含死视图不再被重建的自动化见证</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-base/src/map/mapLayer.ts:44-51 私有映射、:100-124 `removeTileFromPosMap`/`removeTile`、:133-145 `syncStaticEvent`、:371-395 `setMapRef`/`getMapRef`、:418-433 `createDynamic`、:461-507 删除动态块、:512-514 `iterateDynamicTiles`、:600-681 `resize`/`resize2`(另两处 `expired`)、:814-822 `saveState`、:841-849 `loadDynamics`、:855-931 三档 `load*`
- packages-user/data-base/src/map/mapLayer.test.ts现有动态块与 `setMapRef` 用例)
- packages-user/data-base/src/map/saveLoad.test.ts:176-220 `MapLayer save and load round trips` 与码 124 用例)
- packages-user/data-base/src/map/types.ts`IMapLayerData`/`IMapLayer`/`IMapLayerSave` 的 jsDoc 位置)
- packages-user/data-system/src/combat/mapDamage.ts:15-20 `IPointInfo`、:44-47 `sourcelessDamage`/`sourcedDamage`、:104-120 `addMapDamage`/`deleteMapDamage`、:150-167 `deleteEnemy`、:240-256 `removeEnemyAffecting`(已有 `affectedBy.delete`/`damages.delete` 的写法参照)、:395-427 `refreshIndex`:403-411 遍历 `point.damages`、:413-426 遍历 `point.affectedBy` 并调用 `view.getDamageWithoutCheck(locator)`、:417 的 `viewStore` 惰性跳过))
- packages-user/data-system/src/combat/mapDamage.test.ts:378-407 sourceless 用例群、:517-546 `deleteEnemy` 用例、:282-360 `createFixture``damageView` 为 `FakeView` 实例,可 `vi.spyOn`
- .planning/phases/07-data-fixes/07-REVIEW.mdIN-01 原文与 `Fix` `:241-245`
</read_first>
<action>
(1) **Q2 裁决落地(`setMapRef` 契约)**:选项 A → 保持替换 + 标旧对象 `expired``:380-386`)的实现不变,在 `IMapLayerData`/`getMapRef()` 的 jsDoc 写明「持有者见 `expired === true` 必须重取;`array` 引用可能失效」,并确保 `resize`/`resize2``:625`/`:668`)的三处行为与文档一致(不修改其行为,只核对口径);选项 B → 三处一并改为原地变更(`this.mapData.expired = false` + `this.mapData.array = this.mapArray`),并同步更新 jsDoc 与任何依赖 `expired` 信号的既有用例(逐条在 SUMMARY 说明)。
(2) **Q3 裁决落地(`loadDynamics`/`loadState`**:选项 A → 在 `loadDynamics``:841-849`)入口先清空本图层全部既有动态块:遍历 `iterateDynamicTiles()` 收集快照 → 逐个复用内部 `removeTile(tile)``:118-124`,解除 `tilePosMap`/`posTileMap`),并按裁决决定是否对每个块执行 `syncStaticEvent(tile, false)`;再按存档创建新块(既有逻辑)。**注意**`setMapRef` 已清 `staticTileCache``syncStaticEvent` 在清理时是否有意义由裁决决定;若裁决要求触发 `onDeleteDynamic`,须用 `forEachHook``deleteDynamic``:498-507`)保持一致的参数顺序(`hook.onDeleteDynamic?.(tile)`)。选项 B → 只清理存档中不存在坐标上的动态块;选项 C → 不改行为,只在 jsDoc 写明合并/累积语义并登记。
(3) **三档一致性**:清理逻辑放在 `loadDynamics` 内(三档共用 `:862-863`/`:886-887`/`:917-918` 三处调用点),避免三档行为分叉;若裁决要求「无 `dynamicBlocks` 字段时也清理」,则在三档 `load*``loadDynamics` 调用处按存档字段缺失情况补齐。
回归用例:
(a) `mapLayer.test.ts``createDynamic` 若干块 → 记录实例 → `setMapRef`(按 Q2 裁决断言 `expired`/对象身份/`array` 可见性);读档前动态块数为 N按 Q3 裁决断言清理后的集合与实例状态);
(b) `saveLoad.test.ts`:同一图层先创建多于存档的动态块 → `loadState`(三档各自循环)→ 断言 `[...iterateDynamicTiles()].length` 等于存档中的块数(不累积);再 `loadState` 第二次(模拟重复读档)→ 数量仍等于存档块数;既有 `restores the matrix and point events across all compressions` 与码 124 用例保持通过。
按 Task 0 `<record>` 的 Q4=A 落地(**无分支**)。本任务在 Task 1 之后执行,只在此基础上追加;不得回改 Task 1 已定的口径。
(1) **`deleteEnemy` 剪除(`:150-167`**:在内层 `affecting.damages.forEach((dam, index) => { … })` 中,于 `this.damageStore.delete(dam);` 旁补上来源侧的剪除——先 `const point = this.sourcedDamage.get(index);`,若存在则 `point.affectedBy.delete(viewItem);``point.damages.delete(dam);`。`collection.add(index)` 与 `this.viewStore.delete(viewItem)`、`this.enemyStore.delete(view)`、方法末尾的 `collection.forEach(v => this.markDirtyIndex(v))` 逐字保留。写法与 `removeEnemyAffecting``:246-252`)的既有剪除保持一致(同一份语义、两处实现),便于对照阅读。**不删除** `sourcedDamage` 中的空点条目非目标A4
(2) **`deleteMapDamage` 移除空点(`:114-120`**:在 `current.damages.delete(info);` 之后、`this.markDirtyIndex(index);` 之前补一步——当 `current.damages.size === 0 && current.affectedBy.size === 0``this.sourcelessDamage.delete(index);`。说明:无来源点的 `affectedBy` 结构上从不被写入(只有 `registerSourcedDamage``:266-285`)会写 `affectedBy`,且只作用于有来源点),故「两集合皆空」在实践中等价于 `damages.size === 0`;按裁决字面写作两者合取。`markDirtyIndex(index)` 必须保留(缓存失效与本次清理是两件事)。
(3) **不修改 `combat/types.ts`**;本任务**零 jsDoc 改动**(用户长期规则)。
(4) **回归用例(`mapDamage.test.ts` 新增 2 条;既有用例逐字保留)**,每条 `it` 前一行中文注释dev.md:85并同步 `:1` 的文件头覆盖说明追加「IN-01 删除后点残留」字样):
(a) **`deleteEnemy` 剪除的自动化见证**(这是 IN-01 唯一有公开可观测差异的一面):`useReducer` + `useConverter``damageView` 覆盖 `fixture.locator` 所在索引)后,`const spy = vi.spyOn(fixture.damageView, 'getDamageWithoutCheck');` → `fixture.damage.deleteEnemy(fixture.view);``expect(fixture.damage.getReducedDamage(fixture.locator)).toBeNull();`(该读取会触发脏索引的惰性 `refreshIndex`)→ 断言 `expect(spy).not.toHaveBeenCalled();`。原理:修复前被删视图仍在 `point.affectedBy` 中,`refreshIndex``:413-414`)会对它调用 `getDamageWithoutCheck`;剪除后不再触达。再断言 `[...getSeparatedDamage(fixture.locator)]` 长度为 0公开结果正确
(b) **`deleteMapDamage` 空点分支的两条臂**`addMapDamage` 两条无来源伤害于同一点 → `getReducedDamage` 为两者之和 → 删其中一条后为剩下那条的值(非空臂)→ 删最后一条后 `getReducedDamage``null`、`[...getSeparatedDamage]` 为 0空点臂→ 再次 `addMapDamage``getReducedDamage` 为新值(证明清理未破坏后续使用)。
**见证边界A7必须照办**`deleteMapDamage` 的空点移除**没有公开可观测差异**`getReducedDamage`/`getSeparatedDamage` 的返回值前后一致)。**禁止**用私有字段反射、`as unknown as` 连续断言、`Object.getOwnPropertyDescriptor` 等手法为它制造伪见证;其落地由本任务与 `<record>` 的逐行对照确认,并在 Task 4 的 SUMMARY 中如实登记。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts</automated>
<fails_when>删除怪物后惰性 `refreshIndex` 仍调用被删视图的 `getDamageWithoutCheck`(剪除未生效);或 `deleteMapDamage` 删净后该点仍返回伤害、或再次 `addMapDamage` 后读不到新值;或 Task 1 的 CR-02 用例与既有用例出现回归 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- `deleteEnemy` 对被删视图执行 `point.affectedBy.delete(viewItem)``point.damages.delete(dam)`(当 `sourcedDamage` 尚有该 index 的 point 条目时);其余语句逐字保留
- `deleteMapDamage` 在两集合皆空时移除该 `sourcelessDamage` 条目,且保留 `markDirtyIndex(index)`
- 新增用例证明「删除后惰性 `refreshIndex` 不再触达被删视图」(`vi.spyOn` 计数为 0与「`deleteMapDamage` 两条臂的公开契约正确」
- `sourcedDamage` 的空点条目未被删除(非目标);`combat/types.ts` 未改动;本任务零 jsDoc 改动;无新增 `it.skip`
</acceptance_criteria>
<done>删除怪物/视图与删除无来源伤害后不再遗留可被惰性重建触达的残留条目IN-01 按 Q4=A 关闭),且公开契约回归全绿</done>
</task>
<task type="auto">
<name>Task 3: `#06-17-8`Q3=A1—— `loadState` 读档前全清既有动态块(复用 `deleteDynamic` 语义 + 触发 `onDeleteDynamic``setMapRef` 零改动Q2=A</name>
<files>packages-user/data-base/src/map/mapLayer.ts, packages-user/data-base/src/map/mapLayer.test.ts, packages-user/data-base/src/map/saveLoad.test.ts</files>
<read_first>
- packages-user/data-base/src/map/mapLayer.ts:44-51 `tilePosMap`/`posTileMap`、:100-124 `removeTileFromPosMap`/`removeTile`、:128-145 `syncStaticEvent`、:418-433 `createDynamic`、:460-478 `transferToStatic`、:498-507 `deleteDynamic``removeTile` → `forEachHook(hook => hook.onDeleteDynamic?.(tile))``await Promise.all(hooks)`)、:512-514 `iterateDynamicTiles`、:837-849 `loadDynamics`、:851-931 三档 `load*``loadState``:862`/`:886`/`:917` 的 `if (save.dynamicBlocks)` 守卫))
- packages-user/data-base/src/map/types.ts:207-257 `IMapLayerHooks`,尤其 `onDeleteDynamic?(tile: IDynamicTile): Promise<void>`:257 的返回类型是「不等待」约束的来源)
- packages-user/data-base/src/map/mapState.ts:409-425 `MapState.loadState` 同步逐层读档——证明读档清理不能 `await` 钩子)
- packages-user/data-base/src/map/mapLayer.test.ts:86-119 `createFixture`2×2、事件层别名 `event`)、:341-359 动态创建与 `onCreateDynamic` 钩子用例、:563-580 `deleteDynamic``onDeleteDynamic` 钩子用例的写法、:595-668 脏标记与门用例)
- packages-user/data-base/src/map/saveLoad.test.ts:47-52 `SAVE_COMPRESSIONS`、:60-112 `createMapFixture`/`setLayerReference`、:176-218 `MapLayer save and load round trips` 与码 124 用例)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.mdG 行「旧动态块累积」与「未确定项 2」
</read_first>
<action>
按 Task 0 `<record>` 的 Q3=A1 落地(**无分支**)。本任务**不碰 `setMapRef`**Q2=A零代码改动、零 jsDoc 改动)。
(1) **新增私有方法 `clearDynamics()`**:放在存读档 region 内、**其调用者 `loadState``:922-931`)之前**(建议紧随 `loadDynamics``:841-849`之后dev.md:118 要求私有方法置于调用者之前)。方法体:先用快照数组收集当前全部动态块(`const tiles = [...this.iterateDynamicTiles()];`,避免遍历中改动 `posTileMap`),再逐块按 `deleteDynamic``:498-507`)的语义清理——`this.syncStaticEvent(tile, false);` → `this.removeTile(tile);``this.forEachHook(hook => hook.onDeleteDynamic?.(tile));`。要点:
- **必须触发** `onDeleteDynamic`Q3=A1 裁决,渲染端可能在监听);参数顺序与 `deleteDynamic`/`transferToStatic` 一致(只传 `tile`)。
- **不等待**钩子:本方法是同步的(`loadState` 同步、`MapState.loadState` 同步),而 `onDeleteDynamic` 返回 `Promise<void>``types.ts:257`)——按 A6 保留 `forEachHook` 的调用但不 `await`,并在该方法的 jsDoc 里写明「读档清理为触发式、不等待钩子完成」(这是本计划**唯一被授权**的 jsDoc 落笔)。
- 该方法**只**解除两张索引映射与触发钩子;不得改 `createDynamic`/`deleteDynamic`/`transferToStatic`。
(2) **`loadState` 首行调用**:在 `loadState``:922`)分派三档压缩**之前**加入 `this.clearDynamics();`。理由(必须写进实现说明/SUMMARY三档 `load*``loadDynamics` 调用点都包在 `if (save.dynamicBlocks)` 守卫内(`:862`/`:886`/`:917`),若把清理放进 `loadDynamics` 内部,则「存档不含动态块」时不会清理、读档结果与存档点不一致;放在 `loadState` 入口使三档与缺字段情形**按构造**一致。`loadDynamics``:841-849`)本身逐字不改。
(3) **`setMapRef`/`getMapRef`/`resize`/`resize2` 零改动**:这三处 `mapData.expired` 口径(`:380-386`、`:625`、`:668`)与其注释逐字保留;本任务不得出现任何与之相关的 diff。
(4) **回归用例**
(a) `mapLayer.test.ts` 新增一条(`it` 前一行中文注释):
- 夹具 + `layer.addHook({ onDeleteDynamic: async tile => { deleted.push(tile); } }).load();`(沿用 `:563-580` 的写法;`async` 钩子体在首个 `await` 前**同步**执行,故 `loadState` 返回后 `deleted` 已可断言,必要时 `await Promise.resolve()` 兜底);
- `const first = layer.createDynamic(1, 0, 0); const second = layer.createDynamic(1, 1, 0);``const saved = layer.saveState(SaveCompression.NoCompression);`(存档含 2 块)→ 再创建 `const extra = layer.createDynamic(1, 0, 1);`(超量)→ `layer.loadState(saved, SaveCompression.NoCompression);`
- 断言:`[...layer.iterateDynamicTiles()].length === 2`(等于存档块数,`extra` 已被清理);`[...layer.getDynamicTilesAt(0, 1)]` 为空(超量块不再被索引);`deleted` 含 `first`/`second`/`extra`(清理时**钩子被触发**Q3=A1 的验收点);读档后的实例**不是**原实例之一(`expect([...layer.iterateDynamicTiles()]).not.toContain(first)`——排除「合并语义」,证明走的是全清 + 重建)。
(b) `saveLoad.test.ts` 新增一条(`it` 前一行中文注释,放在 `MapLayer save and load round trips` describe 内):
- `for (const compression of SAVE_COMPRESSIONS)` 循环(沿用文件既有风格),每轮 `createMapFixture()` + `setLayerReference(layer)`
- `layer.createDynamic(1, 0, 0);``const saved = layer.saveState(compression);`(存档 1 块)→ `layer.createDynamic(1, 1, 0); layer.createDynamic(1, 1, 0);`(读到 3 块)→ `layer.loadState(saved, compression);` → 断言 `[...layer.iterateDynamicTiles()].length === 1`**再** `layer.loadState(saved, compression);` → 断言仍为 1重复读档不累积
- 既有 `restores the matrix and point events across all compressions``:176-199`)与码 124 用例(`:201-217`)、以及 `StaticTile`/`DynamicTile`/`GameMap`/`MapState` 各 describe 用例**逐字保留、必须继续通过**。
注意:无新增依赖、无新建源码文件、不新增 `it.skip`、不弱化既有断言。
</action>
<verify>
<automated>pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts</automated>
<fails_when>重复读档(或读到动态块更少的存档)后 `iterateDynamicTiles()` 的块数大于存档块数(累积);或 `setMapRef` 的失效契约与 Q2 裁决/文档不一致;或既有矩阵/事件/码 124 用例出现回归 —— 按 D-09 退出并修订计划</fails_when>
<fails_when>重复读档(或读到动态块更少的存档)后 `iterateDynamicTiles()` 的块数大于存档块数(累积);或读档清理未触发 `onDeleteDynamic`Q3=A1 的验收点缺失);或读档后的动态块复用了清空前的旧实例(说明走了合并语义);或既有矩阵/事件/码 124/StaticTile/DynamicTile/GameMap/MapState 用例出现回归 —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- Q2 裁决落地:`setMapRef`(及 `resize`/`resize2` 的口径)与 jsDoc 一致,并有 `getMapRef()` 失效行为的断言
- Q3 裁决落地:读档后动态图块集合等于存档(不累积)并有断言;重复读档的断言通过
- 三档压缩的清理行为一致;既有 `mapLayer.test.ts`/`saveLoad.test.ts` 断言未弱化
- `loadState` 在分派三档之前调用新增的私有 `clearDynamics()`;三档与「存档不含 `dynamicBlocks`」情形均被覆盖(按构造一致)
- 清理逐块复用 `deleteDynamic` 语义:`syncStaticEvent(tile, false)` + `removeTile(tile)` + 触发 `onDeleteDynamic`**必须触发**),且不等待钩子;用例断言钩子被触发
- 读档后动态块集合等于存档(`iterateDynamicTiles()` 计数)、重复读档不累积、旧实例不再被管理
- `setMapRef`/`getMapRef`/`resize`/`resize2` 与其 jsDoc/注释**零改动**`loadDynamics` 逐字未改;`combat/types.ts` 未改动
- 本任务仅新增一处被授权的 jsDoc`clearDynamics`),其余注释逐字保留;无新增 `it.skip`、既有断言未弱化
</acceptance_criteria>
<done>`MapLayer` 的失效契约成文且读档不再累积旧动态块(`#06-17-8` / 审计 G 按裁决关闭)</done>
<done>读档不再累积旧动态块(读档结果等于存档点、重复读档不累积),清理复用 `deleteDynamic` 语义并触发渲染端钩子,而 `setMapRef` 保持零改动(`#06-17-8` 按 Q3=A1 关闭Q2=A 零改动登记)</done>
</task>
<task type="auto">
<name>Task 3: D-12/D-44 门禁 + 全量套件 + 缺口登记CR-02 / `#06-17-8` / IN-01 处置)与 Phase 7 验证失效说明</name>
<name>Task 4: D-12/D-44 门禁 + 全量套件 + 原子提交 + 裁决/缺口登记CR-02 / IN-01 / `#06-17-8` 闭合Q2 零改动IN-04/IN-05 保留在册)与 Phase 7 验证失效说明</name>
<files>.planning/phases/07-data-fixes/07-13-SUMMARY.md</files>
<read_first>
- .planning/phases/07-data-fixes/07-REVIEW.mdCR-02 与 IN-01 编号)
- .planning/phases/07-data-fixes/07-REVIEW.mdCR-02 `:98-132`、IN-01 `:241-245`、IN-04/IN-05 编号)
- .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.mdG 行)
- .planning/phases/07-data-fixes/07-VERIFICATION.md头部 verified/status
- .planning/ROADMAP.mdPhase 7 段 Plans 与 Wave 列表)
- .planning/WINDOWS.md确认两条无入账 id
- .planning/WINDOWS.md确认三条无入账 id
- .planning/phases/07-data-fixes/07-13-PLAN.mdTask 0 `<record>`,用于逐条对照)
</read_first>
<action>
(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
(2) **原子提交D-13**Task 1CR-02与 Task 2`#06-17-8`)各自独立提交(`fix(07-13): …`)。
(3) **缺口登记**SUMMARY 逐条标注 CR-02 与 `#06-17-8` 已闭合(附 commit`IN-01` 按 Q4 裁决标注「已修」或「保留在册」;`IN-04`/`IN-05` 标注「本轮清单外,保留在册」。`WINDOWS.md` 不新建、不 `fixed`/`waive`。
(4) **Phase 7 验证失效**SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7``ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。
(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` 一条(码 147D-06。执行前先重录当日基线并与本计划 objective 的「66 文件 / 729 通过 / 0 失败 / 1 跳过」对比;本计划新增用例后通过数应上升、失败数必须为 0。
(2) **原子提交D-13按缺陷落三条**Task 1 = `fix(07-13): mark removed indexes dirty and register empty view sets`Task 2 = `fix(07-13): prune deleted views and empty sourceless points`Task 3 = `fix(07-13): clear existing dynamic tiles before loading`。提交**一律按显式路径** `git add <file>`**禁止** `git add -A`/`git add -a`、`git stash`、`git clean`、`git reset --hard`。工作树存在与本计划无关的既有未暂存改动(`packages-user/client-base/src/types.ts`、`packages-user/client-modules/src/types.ts`)——**不得**暂存、不得提交、不得改动它们;提交前后用 `git status --short` 核对暂存区只含本计划文件。
(3) **Q2 零改动核对(必做,不得只依赖裸 `git diff` 的空输出)**:提交前用工作树对比 `git diff -- packages-user/data-base/src/map/mapLayer.ts packages-user/data-system/src/combat/types.ts`;提交后用**显式区间**对比「本计划起点 sha`depends_on` 的前一个计划收尾提交)..HEAD」并断言输出中**不含** `setMapRef`/`getMapRef`/`resize`/`resize2`/`expired` 相关行、也不含 `combat/types.ts` 的任何改动;核对结论写入 SUMMARY 的对照表。同时核对全计划除 Task 3 新增的 `clearDynamics` jsDoc 外**无任何 jsDoc 改动**(用户长期规则)。
(4) **缺口登记SUMMARY 逐条)**CR-02 已闭合Q1=A附 commitIN-01 已闭合Q4=A两处清理附 commit——并如实标注 `deleteMapDamage` 的空点移除为簿记修复、无公开可观测差异,其见证为公开契约回归 + 代码对照);`#06-17-8`读档旧动态块累积已闭合Q3=A1附 commit**Q2 = 「保留现契约、无代码改动、无 jsDoc 改动」**`setMapRef` 保持「标旧对象 `expired` + 整对象替换」,与 `resize`/`resize2` 一致;契约文本登记于 Task 0 `<record>` 与本 SUMMARY源码注释按用户长期规则未改`sourcedDamage` 空点条目为**已知惰性残留**Q4=A 非目标,不产生错误结果);`IN-04`/`IN-05` 本轮清单外、保留在册。`WINDOWS.md` 不新建、不 `fixed`/`waive`。
(5) **Phase 7 验证失效**SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7``ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。
</action>
<verify>
<automated>pnpm test:ci</automated>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误 —— 按 D-09 退出并修订计划</fails_when>
<fails_when>`pnpm test:ci` 出现新增失败或新增 `it.skip`(跳过数 ≠ 1;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误;或暂存区/提交中出现 `packages-user/client-base/**`、`packages-user/client-modules/**` 的既有改动;或 `setMapRef`/`getMapRef`/`combat/types.ts` 出现任何 diff —— 按 D-09 退出并修订计划</fails_when>
</verify>
<acceptance_criteria>
- 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 全绿且跳过项仍仅 1 条
- Task 1/2 各有独立提交并含对应锚点
- SUMMARY 完成缺口登记CR-02、`#06-17-8`、IN-01/IN-04/IN-05 处置)与 Phase 7 验证失效说明
- 改动文件 `eslint` 0 错误、`vue-tsc --noEmit` 该文件 0 类型错误、`pnpm test:ci` 0 失败且跳过项仍仅 1 条(`equipment.test.ts:306` 码 147
- Task 1/2/3 各有独立原子提交(`fix(07-13): …`),且暂存内容只含本计划文件(无关的 `client-base`/`client-modules` 改动未被触碰)
- Q2 零改动已核对:提交后用显式区间 `git diff BASE..HEAD -- packages-user/data-base/src/map/mapLayer.ts packages-user/data-system/src/combat/types.ts`BASE = 本计划起点提交的显式 sha即前一计划收尾提交断言输出中不含 `setMapRef`/`getMapRef`/`resize`/`resize2`/`expired` 相关行、也不含 `combat/types.ts` 的任何改动(提交前若需查未提交树,命令形状相同但去掉区间);除 Task 3 新增 `clearDynamics` 的 jsDoc 外无任何 jsDoc 改动
- SUMMARY 完成缺口登记CR-02、IN-01、`#06-17-8`、Q2 零改动、`sourcedDamage` 空点残留、IN-04/IN-05与 Phase 7 验证失效说明
- `WINDOWS.md` 未被本计划改动
</acceptance_criteria>
<done>D-44 三步门禁全绿、原子提交完成、缺口登记与 Phase 7 验证失效说明写入 SUMMARY</done>
<done>D-44 三步门禁全绿、三条按缺陷原子提交、Q2 零改动经 `git diff BASE..HEAD -- <path>` 核对为空BASE = 起点提交 sha、缺口登记与 Phase 7 验证失效说明写入 SUMMARY</done>
</task>
</tasks>
@ -304,7 +380,8 @@ Phase 7 裁决对应逐条引用D-01: 本计划是 Phase 7 第 13 个
| Boundary | Description |
|----------|-------------|
| 怪物状态变更 → 地图伤害缓存 | 光环/特殊属性增删与范围变化由战斗侧驱动,缓存失效不全即产生与真实状态不一致的伤害 |
| 存档数据 → 地图图层 | 读档决定矩阵、静态块、动态块;清理不全即与存档点不一致 |
| 渲染端/外部持有者 → 读档清理钩子 | 读档清空动态块会触发 `onDeleteDynamic`,渲染端可能在监听;钩子不被等待,读档不被其阻塞 |
| 存档数据 → 地图图层 | 读档决定矩阵、静态块、动态块;清理不全即与存档点不一致(重复读档累积) |
| 图层内部状态 → 外部持有者 | `getMapRef()` / `iterateDynamicTiles()` / 图块实例被外部(渲染端、事件层)持有 |
## STRIDE Threat Register
@ -312,28 +389,31 @@ Phase 7 裁决对应逐条引用D-01: 本计划是 Phase 7 第 13 个
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-7-37 | Tampering | `MapDamage.removeEnemyAffecting``mapDamage.ts:240-256` | high | mitigate | Task 1收集被移除索引并逐个 `markDirtyIndex`,使旧坐标按 `refreshIndex` 重新聚合CR-02 主修复) |
| T-7-38 | Tampering | `refreshEnemyAndClearCache` 的空视图集早退(`:300` | high | mitigate | Task 1空集也登记 `enemyStore.set(view, set)` 并完成失效,避免缓存永久残留与 `refreshAll()` 级联 |
| T-7-39 | Repudiation | 空集登记后的 `markEnemyDirty`/`deleteEnemy` 读法 | low | mitigate | Task 1`markEnemyDirty`/`deleteEnemy`/`removeEnemyAffecting` 对空集合显式容错,并有重复调用断言 |
| T-7-40 | Tampering | `MapLayer.loadDynamics` 不清理旧动态块(`:841-849` | medium | mitigate | Task 2按裁决在读入前清理既有动态块复用内部 `removeTile`),三档一致,并有重复读档断言 |
| T-7-41 | Repudiation | `setMapRef` 的失效信号 `expired` 无成文契约(`:371-391` | low | mitigate | Task 2 按 Q2 裁决:保持替换+标过期并写入 jsDoc或原地变更并同步全部依赖点逐条登记 |
| T-7-42 | Denial of Service | 清空动态块时触发大量 `onDeleteDynamic`/`syncStaticEvent` | low | mitigate | Task 0 Q3 已把「是否复用 `deleteDynamic` 的同步与钩子」列为裁决点;实现按裁决执行并在测试中断言通知口径 |
| T-7-38 | Tampering | `refreshEnemyAndClearCache` 的空视图集早退(`:300`)与 `refreshEnemy``:345` | high | mitigate | Task 1两处均改为「先 `enemyStore.set(view, set)``if (set.size === 0) return;`」,避免缓存永久残留与 `refreshAll()` 级联;两条路径各有 `iterateEnemy` spy + `converter.calls` 断言 |
| T-7-39 | Repudiation | 空集登记后 `markEnemyDirty`/`deleteEnemy` 的既有读法 | low | mitigate | Task 1**不新增分支**(空 `Set` 为真值),并以用例固定「空集≠未登记」——防止后续被改为「空集即未登记」而退回整表重算或永不刷新 (A5) |
| T-7-40 | Tampering | `MapLayer.loadDynamics` 不清理旧动态块(`:841-849` | medium | mitigate | Task 3`loadState` 入口全清既有动态块(新增私有 `clearDynamics`,复用 `deleteDynamic` 语义),三档与缺 `dynamicBlocks` 情形按构造一致;有重复读档与「旧实例不再被管理」断言 |
| T-7-41 | Repudiation | `setMapRef` 的失效信号 `expired` 无成文源码契约(`:371-391` | low | accept | Q2=A 用户裁决:**保留现契约、零代码改动、零 jsDoc 改动**(长期规则:永远不要擅自改 jsDoc。契约文本改由 Task 0 `<record>``07-13-SUMMARY.md` 承载;`resize`/`resize2` 三处口径一致,风险维持在 low |
| T-7-42 | Denial of Service | 读档全清动态块时触发大量 `onDeleteDynamic``syncStaticEvent` | low | mitigate | Task 3钩子为**触发式、不等待**`loadState` 同步A6读档不被渲染端异步钩子阻塞`syncStaticEvent(tile, false)` 的写入随后被矩阵读档覆盖,保留它只为与 `deleteDynamic` 语义逐字一致 |
| T-7-43 | Denial of Service | `deleteEnemy` 不剪除 `point.affectedBy`/`point.damages`、`deleteMapDamage` 遗留空 `IPointInfo` | low | mitigate | Task 2Q4=A`deleteEnemy` 剪除死视图与其伤害条目(有 `getDamageWithoutCheck` 不被调用的自动化见证);`deleteMapDamage` 在两集合皆空时移除该点;`sourcedDamage` 空点为已登记非目标 |
| T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 |
</threat_model>
<verification>
1. `pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts` 全绿。
2. `pnpm test:ci` 全绿66 文件 / 690 通过 / 1 跳过(仅 `equipment.test.ts:306` 码 147)。
2. `pnpm test:ci` 0 失败基线07-12 收尾66 文件 / 729 通过 / 0 失败 / 1 跳过;本计划新增用例使通过数上升,跳过项仍只为 `equipment.test.ts:306` 码 147D-06)。
3. `pnpm exec eslint <files_modified 中每个改动文件>` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。
4. Task 0 的 `<record>` 中 Q1..Q4 均有裁决代码与裁决逐条一致SUMMARY 对照表)。
5. 无新增依赖、无新建源码文件、`WINDOWS.md` 未改动、`gameMap.ts`/`mapState.ts`/`dynamicTile.ts`/`context.ts` 未改动。
6. Phase 7 验证失效与重跑要求已在 `07-13-SUMMARY.md` 明写,`ROADMAP.md` 已更新。
4. Task 0 的 `<record>` 中 Q1..Q4 均有裁决代码与裁决逐条一致SUMMARY 对照表Q1=A标脏 + 空集登记、Q2=A`setMapRef` 零代码零 jsDoc 改动;用显式区间 `<起点 sha>..HEAD -- <path>` 核对 `mapLayer.ts`/`combat/types.ts` 的输出不含 `setMapRef`/`getMapRef`/`resize`/`resize2`/`expired` 相关行、Q3=A1读档全清 + 触发 `onDeleteDynamic`、Q4=AIN-01 两处清理)。
5. 无新增依赖、无新建源码文件、`combat/types.ts` 未改动、`WINDOWS.md` 未改动、`gameMap.ts`/`mapState.ts`/`dynamicTile.ts`/`context.ts` 未改动;无关的未暂存改动(`client-base`/`client-modules` 的 `types.ts`)未被触碰或提交。
6. 除 Task 3 新增私有方法 `clearDynamics` 的 jsDoc本计划唯一授权落笔本计划无任何 jsDoc/注释改动(用户长期规则)。
7. Phase 7 验证失效与重跑要求已在 `07-13-SUMMARY.md` 明写,`ROADMAP.md` 已更新。
</verification>
<success_criteria>
- CR-02 按 Q1 裁决闭合:来源消失与范围收缩两条路径都不再有幽灵伤害,且失效路径保持局部刷新。
- `#06-17-8` 按 Q2/Q3 裁决闭合:失效契约成文;读档后动态块集合等于存档(不累积),三档一致。
- `IN-01`/`IN-04`/`IN-05` 按裁决登记或修复,并在 SUMMARY 逐条记录。
- `pnpm test:ci` 全绿、无新增 `it.skip`、既有断言未弱化D-44 三步门禁全绿。
- CR-02 按 Q1=A 闭合:移除端标脏使来源消失与范围收缩两条路径都不再有幽灵伤害;空视图集被登记后 `markEnemyDirty` 保持局部刷新(两入口各有用例见证)。
- IN-01 按 Q4=A 落地:`deleteEnemy` 剪除死视图(有自动化见证)与 `deleteMapDamage` 空点移除(簿记修复,公开契约回归全绿);`sourcedDamage` 空点为已登记非目标。
- `#06-17-8` 按 Q3=A1 闭合:读档前全清既有动态块(复用 `deleteDynamic` 语义、触发 `onDeleteDynamic`、不等待)、三档与缺字段情形一致、重复读档不累积、旧实例不被复用。
- Q2 零改动:`setMapRef`/`getMapRef` 保留「标旧对象 `expired` + 整对象替换」现契约,无代码改动、无 jsDoc 改动;契约登记于 Task 0 `<record>` 与 SUMMARY。
- `pnpm test:ci` 0 失败、跳过项仍仅 1 条、无新增 `it.skip`、既有断言未弱化D-44 三步门禁全绿;三条按缺陷原子提交且未牵连工作树中的无关改动。
- Phase 7 验证失效与重跑要求已记录。
</success_criteria>