From b37c218f2d4c4ac2158d478795e2738d4edfbd69 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 17 Sep 2026 16:36:49 +0800 Subject: [PATCH] docs(07-13): lock user adjudications for CR-02/IN-01/load-dynamics cleanup --- .planning/phases/07-data-fixes/07-13-PLAN.md | 364 +++++++++++-------- 1 file changed, 222 insertions(+), 142 deletions(-) diff --git a/.planning/phases/07-data-fixes/07-13-PLAN.md b/.planning/phases/07-data-fixes/07-13-PLAN.md index 23779d4..a698de1 100644 --- a/.planning/phases/07-data-fixes/07-13-PLAN.md +++ b/.planning/phases/07-data-fixes/07-13-PLAN.md @@ -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 的返回内容前后一致;其验证为「既有断言 + 新增公开契约回归全绿」+逐行代码对照 ``,**禁止**用私有字段反射或连续 `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`,故清理路径按 `forEachHook` 调用钩子但**不** `await`(与可等待的 `deleteDynamic` 逐字不同);用例只固定「钩子被同步调用」这一事实,不等待其 Promise(A6)" + - "**Q2 = A:`setMapRef` 零改动、零 jsDoc 改动**:`MapLayer.setMapRef`(`:371-391`)与 `getMapRef`(`:393-395`)逐字保留现契约(旧 `mapData` 标 `expired = true` + 整对象替换),其既有 jsDoc/注释一行不动;裁决为「保留现契约、无代码改动、无 jsDoc 改动」,登记于 Task 0 `` 与 `07-13-SUMMARY.md`" + - "**零 jsDoc 改动(用户 2026-09-17 长期规则:永远不要擅自改 jsDoc 注释)**:本计划唯一被授权的 jsDoc 落笔是 Task 3 新增私有方法 `clearDynamics` 的 jsDoc(dev.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` 码 147,D-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`)——复用同一删除语义,唯一差异是读档路径不等待钩子" + - "`MapLayer.setMapRef`(`:371-391`)/ `getMapRef`(`:393-395`)/ `resize`+`resize2` 的另两处 `mapData.expired`(`:625`/`:668`)——Q2=A 裁定**零改动**:保留「标旧对象 `expired` + 整对象替换」现契约,契约文本落在 Task 0 `` 与 SUMMARY(源码 jsDoc 按用户长期规则不改)" assumptions: - - "A1(D-10 例外,本计划授权):本计划为修复计划,为 CR-02 与 `#06-17-8` 新增回归用例是必需项;既有断言不得弱化(`mapDamage.test.ts` 的 102/103/104/deleteEnemy/markEnemyDirty 用例、`map/saveLoad.test.ts` 的三档往返用例全部保持)" - - "A2(不引入新状态语义):CR-02 的修法只在既有结构(`dirtyIndexes`/`reducedCache`/`viewStore`/`damageStore`/`enemyStore`)内完成,不新增缓存层、不改 `IMapDamageReducer`/`IMapDamageConverter` 契约" - - "A3(项目未发布,与 07-09 的 A2 同口径):`IMapLayerSave`/存档形状不因本计划变更;动态块清理属读档行为而非格式变更" - - "A4(范围守卫):仅修 CR-02 与 `#06-17-8`;`07-REVIEW.md` 的 IN-01(删除后 `point.damages`/`affectedBy` 残留、`sourcelessDamage` 空点残留)与 IN-04/IN-05 不在本轮 15 项清单,只在 Task 0 作为可选项列出、默认登记不修" + - "A1(D-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-01(Q4=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`,故读档清理为「触发式、不等待」;用例只断言钩子被同步调用,不断言其 Promise 完成" + - "A7(IN-01 的见证边界):`deleteEnemy` 的剪除有自动化见证(死视图不再被 `refreshIndex` 调用);`deleteMapDamage` 的空点移除**无公开可观测差异**,不用私有字段反射、不用连续 `as` 断言伪造见证,改以公开契约回归 + 逐行代码对照 `` 确认并在 SUMMARY 登记" prohibitions: + - "**禁止擅自修改/新增 jsDoc 注释**(用户 2026-09-17 长期规则,逐字:永远不要擅自改 jsDoc 注释)。本计划唯一被授权的 jsDoc 落笔:Task 3 新增私有方法 `clearDynamics` 的 jsDoc(dev.md:73 要求方法必须有 jsDoc,dev.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 `;禁止 `git add -A`/`git add -a`、`git stash`、`git clean`、`git reset --hard`" --- -闭合地图侧两条缺陷:`MapDamage` 的幽灵伤害(CR-02)与 `MapLayer` 的读档失效/旧动态块契约(`#06-17-8` / 审计 G)。 +闭合地图侧三条缺陷:`MapDamage` 的幽灵伤害(CR-02)与删除后的点残留(IN-01),以及 `MapLayer` 读档时的旧动态块累积(`#06-17-8` / 审计 G)。`setMapRef` 的失效契约按用户裁决**零改动**。 现状(逐条锚点): -1. **CR-02(critical,`data-system/src/combat/mapDamage.ts:240-256, 290-333`)** `refreshEnemyAndClearCache()` 先 `removeEnemyAffecting(view)`(`:294`)——它删除该怪物的伤害对象并 `enemyStore.delete(view)`(`:255`),但**从不把被移除的坐标索引标脏、也不删对应 `reducedCache`**;随后只对新登记的索引做失效(`:329-332`),且视图集为空时在 `:300` 提前 `return`。后果:怪物光环/特殊属性消失(视图集变空)或范围收缩时,旧坐标的 `reducedCache` 永久停留在「含已移除贡献」的合并值——`getReducedDamage()`/`getSeparatedDamage()` 因索引不在 `dirtyIndexes` 而永不重算。`deleteEnemy()`(`:150-167`)标脏掩盖了整怪删除路径,`markEnemyDirty()`(`:135-148`,由 `EnemyContext.refreshEnemy()`/`setEnemyAt()` 触达)是真实可达路径。 -2. **`#06-17-8`(低,审计 G)** `MapLayer.setMapRef()`(`mapLayer.ts:371-391`)把旧 `mapData` 标 `expired` 后整对象替换、清 `staticTileCache`;`loadDynamics()`(`:841-849`)只按存档 `createDynamic` 新块、从不清理既有动态块——同一图层多次读档或读一份动态块更少的存档时旧块累积;`expired` 契约(持有者何时重取 `getMapRef()`)未成文。 +1. **CR-02(critical,`data-system/src/combat/mapDamage.ts:240-256, 290-333`)** `refreshEnemyAndClearCache()` 先 `removeEnemyAffecting(view)`(`:294`)——它删除该怪物的伤害对象并 `enemyStore.delete(view)`(`:255`),但**从不把被移除的坐标索引标脏、也不删对应 `reducedCache`**;随后只对新登记的索引做失效(`:329-332`),且视图集为空时在 `:300` 提前 `return`。后果:怪物光环/特殊属性消失(视图集变空)或范围收缩时,旧坐标的 `reducedCache` 永久停留在「含已移除贡献」的合并值——`getReducedDamage()`/`getSeparatedDamage()` 因索引不在 `dirtyIndexes` 而永不重算。`deleteEnemy()`(`:150-167`)标脏掩盖了整怪删除路径,`markEnemyDirty()`(`:135-148`,由 `EnemyContext.refreshEnemy()`/`setEnemyAt()` 触达)是真实可达路径。附带的隐式后果:空视图集不登记 `enemyStore` 条目,使该怪物下次 `markEnemyDirty` 走 `refreshAll()`(`:142`)整表重算。 +2. **IN-01(Info,`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 已裁决并锁入 ``(Q1=A / Q2=A 零改动 / Q3=A1 / Q4=A)。D-10: 本计划为修复计划,回归用例为必需项(A1)。D-11(契约变更需同步 jsDoc)在本计划**不适用**:Q2=A 明确「零代码、零 jsDoc 改动」,且用户 2026-09-17 立下长期规则「永远不要擅自改 jsDoc 注释」——故空集语义与 `setMapRef` 契约以 Task 0 `` + `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` 码 147,D-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` 码 147,D-06)。执行前重录当日基线并与本行对比;本计划新增回归用例会使通过数上升,跳过项必须仍只有 1 条。 @@ -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 的 `` 已由计划修订填入用户裁决,执行者**核对记录后直接进入 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` 条目)。 - Task 0: 契约裁决关卡(D-09)——CR-02 失效补全口径(标脏 + 空视图集是否登记)、`setMapRef` 失效契约(替换对象 vs 原地变更)、读档旧动态块处置、IN-01 是否纳入 + Task 0: 契约裁决关卡(D-09)——CR-02 失效补全口径(标脏 + 空视图集是否登记)、`setMapRef` 失效契约(替换对象 vs 原地变更)、读档旧动态块处置、IN-01 是否纳入【已于 2026-09-17 裁决:Q1=A / Q2=A(零改动)/ Q3=A1 / Q4=A;选项块保留为历史记录】 (只读汇报,不修改任何文件) - packages-user/data-system/src/combat/mapDamage.ts(:58-72 内部结构、:114-133 `deleteMapDamage`/`markDirtyIndex`/`markDirty`、:135-148 `markEnemyDirty`、:150-167 `deleteEnemy`、:169-222 读取与合并缓存、:224-256 `clearSourceState`/`removeEnemyAffecting`、:258-285 `registerSourcedDamage`、:290-333 `refreshEnemyAndClearCache`、:338-372 `refreshEnemy`、:374-390 `refreshAll`、:395-427 `refreshIndex`) - - packages-user/data-system/src/combat/types.ts(`IMapDamage` 的 `markEnemyDirty`/`deleteEnemy` 声明与 jsDoc 位置) - - packages-user/data-system/src/combat/mapDamage.test.ts(:1-2 文件头覆盖说明、:19-90 夹具、后半段 `deleteEnemy`/`markEnemyDirty` 用例,注意文件头是否需同步更新) - - packages-user/data-base/src/map/mapLayer.ts(:44-51 私有映射、:100-124 `removeTileFromPosMap`/`removeTile`、:371-395 `setMapRef`/`getMapRef`、:418-433 `createDynamic`、:461-507 删除动态块(含 `deleteDynamic` 的 `syncStaticEvent`/钩子)、:512-514 `iterateDynamicTiles`、:600-681 `isEqualToRef`/`compareWith`/`resize`/`resize2`(另两处 `mapData.expired`)、:841-849 `loadDynamics`、:855-931 三档 `load*`) - - packages-user/data-base/src/map/mapLayer.test.ts、packages-user/data-base/src/map/saveLoad.test.ts(:176-220 `MapLayer save and load round trips` 与 124 告警用例) - - .planning/phases/07-data-fixes/07-REVIEW.md(CR-02 与 IN-01 原文、建议修法) + - 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`)、: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.md(CR-02 原文与建议修法 `:98-132`;IN-01 原文与 `Fix` `:241-245`) - .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(G 行与「未确定项 2」) - 地图侧两条修复的契约点: + 地图侧修复的契约点(**用户已于 2026-09-17 逐条裁决,锁定结果见本任务 ``**): (Q1)CR-02 的失效补全口径(是否标脏;视图集为空时是否登记反向索引以免触发 `refreshAll()`);(Q2)`MapLayer.setMapRef` 的失效契约(替换 `mapData` 对象 + 旧对象标 `expired`,还是原地变更);(Q3)读档时旧动态块的处置(全清 / 只清存档外坐标 / 明写合并);(Q4)`IN-01`(删除后的点残留与 `sourcelessDamage` 空点)是否纳入本计划。 + **本关卡已裁决(2026-09-17):下述选项块保留为历史记录与决策依据,不再需要用户在场确认;执行者核对 `` 后直接进入 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 也不写(契约文本改由本 `` 与 `07-13-SUMMARY.md` 承载)。 + 动态块清理涉及是否复用 `deleteDynamic` 的 `syncStaticEvent(tile, false)` 与 `onDeleteDynamic` 钩子(渲染端可能在监听)。用户裁决 Q3=A1:全清并**触发**钩子;因 `loadState` 为同步方法,钩子为触发式、不等待(A6)。 + Q4 用户裁决为 A:IN-01 一并纳入本计划。 + (**历史选项说明**:本块为裁决时的选项记录。除标注【已选】者外,其余选项一律为**未选·历史**,仅保留决策依据;`revise` 兜底未触发。裁决结果见 ``。) - 待用户在关卡处逐条填写(Q1..Q4:勾选选项 id 或给出裁决文字)。 - **空的 `` 表示尚未裁决**:执行者必须停在本任务,不得按「建议默认」推进。 - 填写后 Task 1–3 一律按本记录执行;若裁决与 Task 1–3 的分支描述冲突,暂停并请用户澄清。 + 用户裁决(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` 的 jsDoc(dev.md:73 要求方法必须有 jsDoc);测试文件的 `it` 前单行中文注释与文件头覆盖说明属 dev.md:85 的既定要求,不在禁令范围。 + - **Q3 = A1(全清 + 触发钩子)**:读档时在装入动态块之前**清空本图层全部既有动态块**,逐块复用 `deleteDynamic` 的语义——`syncStaticEvent(tile, false)` + `removeTile(tile)` + 触发 `onDeleteDynamic` 钩子(渲染端可能正在监听,**必须触发**)。读档结果等于存档点(同一存档反复读、或读到动态块更少的存档,均**不累积**)。清理位于 `loadState` 入口(三档共用、且「存档不含 `dynamicBlocks`」时照样清理)。因 `loadState` 同步、`onDeleteDynamic` 返回 `Promise`,钩子为**触发式、不等待**(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 1–4 一律按本记录执行;若执行期发现与本记录冲突,暂停并请用户澄清。 - 逐条给出 Q1..Q4 的裁决(例如「Q1=A, Q2=A, Q3=A, Q4=B」);若要修订方案,回复修订点,执行者退出并按 D-09 修订本 PLAN.md - 向用户汇报上述 4 个契约点,重点说明 Q1 对 `refreshAll()` 触发频率的影响、Q2 对 `getMapRef()` 持有者的影响、Q3 对渲染端 `onDeleteDynamic` 通知的影响。不得在裁决前修改任何文件。 + 已裁决(2026-09-17):Q1=A, Q2=A(保留替换 + `expired`,**零代码改动、零 jsDoc 改动**), Q3=A1(全清 + 触发 `onDeleteDynamic`), Q4=A(一并修 IN-01)。执行者核对 `` 后直接进入 Task 1;若记录与用户实际意图不符,暂停并请用户澄清后再继续 + 本关卡已裁决:向执行者转述 `` 的五条口径——Q1=A(标脏 + 空集登记)、Q2=A(`setMapRef` **零代码、零 jsDoc** 改动)、用户长期规则(禁止擅自修改/新增 jsDoc,唯一例外是 Task 3 新增私有方法的 jsDoc)、Q3=A1(读档前全清 + **必须触发** `onDeleteDynamic`,钩子不等待)、Q4=A(IN-01 两处清理,`sourcedDamage` 空点不删)——核对无误后直接进入 Task 1。执行者**不得**在 Task 1 之前修改任何文件,也**不得**按任何【未选·历史】选项推进。 - `` 中 Q1..Q4 均有明确裁决,且与用户实际意图一致 - 记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」自行推进 + `` 中 Q1..Q4 均有明确裁决(含 Q2「保留现契约、零代码改动、零 jsDoc 改动」与用户长期规则、Q3「钩子必须触发」两条关键口径),且与用户实际意图一致 + 记录为空或与用户实际意图不符 —— 必须暂停并请用户澄清,不得按「建议默认」或【未选·历史】选项自行推进 - - Q1(CR-02 失效口径)、Q2(`setMapRef` 失效契约)、Q3(旧动态块处置)、Q4(IN-01 纳入与否)均有用户裁决并写入 `` - - Q3 已明确是否复用 `deleteDynamic` 的 `syncStaticEvent`/`onDeleteDynamic` 行为 - - 未在裁决前修改任何文件 + - Q1(CR-02:标脏 + 空集登记)、Q2(`setMapRef`:保留替换 + `expired`,零代码、零 jsDoc 改动)、Q3(旧动态块:全清 + 触发钩子)、Q4(IN-01 纳入并两处清理)四条裁决均已写入 `` + - Q3 已明确复用 `deleteDynamic` 的 `syncStaticEvent(tile, false)` + `removeTile` + `onDeleteDynamic`,且明写钩子**必须被触发**(因 `loadState` 同步而不等待) + - 用户长期规则「禁止擅自修改/新增 jsDoc 注释(除明确授权)」已写入 `` 并落到 frontmatter `prohibitions` + - 未在 Task 1 之前修改任何文件 - Q1..Q4 裁决已完整写入 ``;执行者据记录直接进入 Task 1,不再重复询问 + Q1..Q4 裁决与用户长期规则已完整写入 ``;执行者据记录直接进入 Task 1,不再重复询问 - Task 1: CR-02 —— `MapDamage` 失效补全(移除端标脏 + 空视图集登记),含「来源消失」与「范围收缩」两条幽灵伤害回归 - 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 + Task 1: CR-02(Q1=A)—— `MapDamage` 失效补全(移除端标脏 + 空视图集登记),含「来源消失」与「范围收缩」两条幽灵伤害回归及「不再整表刷新」见证 + packages-user/data-system/src/combat/mapDamage.ts, packages-user/data-system/src/combat/mapDamage.test.ts - - 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.md(CR-02 原文 `:98-132`,含建议修法与「`deleteEnemy` 标脏掩盖了整怪删除路径」的说明) - 按 Task 0 的 Q1 裁决修复。**若 Q1 = 选项 A**: - (1) `removeEnemyAffecting`(`:240-256`):在内层 `store.damages.forEach((dam, index) => …)`(`:246-252`)中把 `index` 收集进一个 `Set`;方法结束前对每个收集到的索引调用 `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 `` 的 Q1=A 修复(**无分支、无「建议默认」**): + (1) **移除端标脏**:在 `removeEnemyAffecting`(`mapDamage.ts:240-256`)内新增局部 `const removed = new Set();`,在内层 `store.damages.forEach((dam, index) => …)`(`:246-252`)回调的**第一条语句**处 `removed.add(index)`(该索引上确实登记了本视图的贡献,无论 `sourcedDamage` 是否仍有对应 point 条目);方法末尾(`this.enemyStore.delete(view)` 之前或之后均可,但必须在遍历结束后)对集合内每个索引调用 `this.markDirtyIndex(index)`。方法签名保持 `private removeEnemyAffecting(view: IEnemyView): void`(**不**改成返回 `Set`,不新增对外契约);两个调用点(`:294`、`:339`)逐字不变。 + (2) **空视图集登记**:`refreshEnemyAndClearCache`(`:290-333`)与 `refreshEnemy`(`:338-372`)中,把 + `const set = new Set>(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`、不弱化既有断言。 pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts - 来源消失或范围收缩后 `getReducedDamage` 仍返回含已移除贡献的缓存值(幽灵伤害);或既有 `deleteEnemy`/102/103/104 用例出现回归 —— 按 D-09 退出并修订计划,不得改用「让调用方手动 markDirty」的替代方案 + 来源消失或范围收缩后 `getReducedDamage` 仍返回含已移除贡献的缓存值(幽灵伤害);或空集登记后再次 `markEnemyDirty` 仍调用 `context.iterateEnemy()`(退回 `refreshAll()`);或既有 `deleteEnemy`/102/103/104/多来源叠加用例出现回归 —— 按 D-09 退出并修订计划,不得改用「让调用方手动 markDirty」「在 markEnemyDirty 里对空集早退」等替代方案 - - 移除端触达的每个坐标索引都被失效(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` - 怪物光环/特殊属性消失或范围收缩后不再有幽灵地图伤害,且失效路径保持局部刷新(CR-02 按裁决关闭) + 怪物光环/特殊属性消失或范围收缩后不再有幽灵地图伤害,且空视图集被登记后失效路径保持局部刷新(CR-02 按 Q1=A 关闭) - Task 2: `#06-17-8` —— `MapLayer.setMapRef` 失效契约 + 读档旧动态块处置(按 Q2/Q3 裁决) - 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 + Task 2: IN-01(Q4=A)—— 删除后的点残留清理(`deleteEnemy` 剪除 `affectedBy`/`damages`;`deleteMapDamage` 移除空 `IPointInfo`),含死视图不再被重建的自动化见证 + 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(: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.md(IN-01 原文与 `Fix` `:241-245`) - (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 `` 的 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` 等手法为它制造伪见证;其落地由本任务与 `` 的逐行对照确认,并在 Task 4 的 SUMMARY 中如实登记。 + + + pnpm exec vitest run packages-user/data-system/src/combat/mapDamage.test.ts + 删除怪物后惰性 `refreshIndex` 仍调用被删视图的 `getDamageWithoutCheck`(剪除未生效);或 `deleteMapDamage` 删净后该点仍返回伤害、或再次 `addMapDamage` 后读不到新值;或 Task 1 的 CR-02 用例与既有用例出现回归 —— 按 D-09 退出并修订计划 + + + - `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` + + 删除怪物/视图与删除无来源伤害后不再遗留可被惰性重建触达的残留条目(IN-01 按 Q4=A 关闭),且公开契约回归全绿 + + + + Task 3: `#06-17-8`(Q3=A1)—— `loadState` 读档前全清既有动态块(复用 `deleteDynamic` 语义 + 触发 `onDeleteDynamic`);`setMapRef` 零改动(Q2=A) + 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 + + - 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`;: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.md(G 行「旧动态块累积」与「未确定项 2」) + + + 按 Task 0 `` 的 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`(`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`、不弱化既有断言。 pnpm exec vitest run packages-user/data-base/src/map/mapLayer.test.ts packages-user/data-base/src/map/saveLoad.test.ts - 重复读档(或读到动态块更少的存档)后 `iterateDynamicTiles()` 的块数大于存档块数(累积);或 `setMapRef` 的失效契约与 Q2 裁决/文档不一致;或既有矩阵/事件/码 124 用例出现回归 —— 按 D-09 退出并修订计划 + 重复读档(或读到动态块更少的存档)后 `iterateDynamicTiles()` 的块数大于存档块数(累积);或读档清理未触发 `onDeleteDynamic`(Q3=A1 的验收点缺失);或读档后的动态块复用了清空前的旧实例(说明走了合并语义);或既有矩阵/事件/码 124/StaticTile/DynamicTile/GameMap/MapState 用例出现回归 —— 按 D-09 退出并修订计划 - - 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`、既有断言未弱化 - `MapLayer` 的失效契约成文且读档不再累积旧动态块(`#06-17-8` / 审计 G 按裁决关闭) + 读档不再累积旧动态块(读档结果等于存档点、重复读档不累积),清理复用 `deleteDynamic` 语义并触发渲染端钩子,而 `setMapRef` 保持零改动(`#06-17-8` 按 Q3=A1 关闭,Q2=A 零改动登记) - Task 3: D-12/D-44 门禁 + 全量套件 + 缺口登记(CR-02 / `#06-17-8` / IN-01 处置)与 Phase 7 验证失效说明 + Task 4: D-12/D-44 门禁 + 全量套件 + 原子提交 + 裁决/缺口登记(CR-02 / IN-01 / `#06-17-8` 闭合,Q2 零改动,IN-04/IN-05 保留在册)与 Phase 7 验证失效说明 .planning/phases/07-data-fixes/07-13-SUMMARY.md - - .planning/phases/07-data-fixes/07-REVIEW.md(CR-02 与 IN-01 编号) + - .planning/phases/07-data-fixes/07-REVIEW.md(CR-02 `:98-132`、IN-01 `:241-245`、IN-04/IN-05 编号) - .planning/phases/07-data-fixes/07-LOADSTATE-AUDIT.md(G 行) - .planning/phases/07-data-fixes/07-VERIFICATION.md(头部 verified/status) - .planning/ROADMAP.md(Phase 7 段 Plans 与 Wave 列表) - - .planning/WINDOWS.md(确认两条无入账 id) + - .planning/WINDOWS.md(确认三条无入账 id) + - .planning/phases/07-data-fixes/07-13-PLAN.md(Task 0 ``,用于逐条对照) - (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 1(CR-02)与 Task 2(`#06-17-8`)各自独立提交(`fix(07-13): …`)。 - (3) **缺口登记**:SUMMARY 逐条标注 CR-02 与 `#06-17-8` 已闭合(附 commit);`IN-01` 按 Q4 裁决标注「已修」或「保留在册」;`IN-04`/`IN-05` 标注「本轮清单外,保留在册」。`WINDOWS.md` 不新建、不 `fixed`/`waive`。 - (4) **Phase 7 验证失效**:SUMMARY 明写 `07-VERIFICATION.md` 已因 07-09 + 07-10..07-14 失效,结束后必须重跑 `/gsd-verify-work`(或 `gsd-verify-work 7`);`ROADMAP.md` 的 Phase 7 计划数与 Wave 列表同步。 + (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,D-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 `;**禁止** `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,附 commit);IN-01 已闭合(Q4=A,两处清理,附 commit——并如实标注 `deleteMapDamage` 的空点移除为簿记修复、无公开可观测差异,其见证为公开契约回归 + 代码对照);`#06-17-8`(读档旧动态块累积)已闭合(Q3=A1,附 commit);**Q2 = 「保留现契约、无代码改动、无 jsDoc 改动」**(`setMapRef` 保持「标旧对象 `expired` + 整对象替换」,与 `resize`/`resize2` 一致;契约文本登记于 Task 0 `` 与本 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 列表同步。 pnpm test:ci - `pnpm test:ci` 出现新增失败或新增 `it.skip`;或 `vue-tsc --noEmit` 输出含本计划改动文件的类型错误 —— 按 D-09 退出并修订计划 + `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 退出并修订计划 - - 改动文件 `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` 未被本计划改动 - D-44 三步门禁全绿、原子提交完成、缺口登记与 Phase 7 验证失效说明写入 SUMMARY + D-44 三步门禁全绿、三条按缺陷原子提交、Q2 零改动经 `git diff BASE..HEAD -- ` 核对为空(BASE = 起点提交 sha)、缺口登记与 Phase 7 验证失效说明写入 SUMMARY @@ -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 `` 与 `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 2(Q4=A):`deleteEnemy` 剪除死视图与其伤害条目(有 `getDamageWithoutCheck` 不被调用的自动化见证);`deleteMapDamage` 在两集合皆空时移除该点;`sourcedDamage` 空点为已登记非目标 | | T-7-SC | Tampering | npm/pnpm 依赖安装 | high | mitigate | 本计划不新增依赖、不改 `package.json`;出现安装需求即暂停并要求用户确认包合法性 | 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` 码 147(D-06)。 3. `pnpm exec eslint ` 0 错误;`pnpm exec vue-tsc --noEmit` 输出不含这些文件的类型错误。 -4. Task 0 的 `` 中 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 的 `` 中 Q1..Q4 均有裁决,代码与裁决逐条一致(SUMMARY 对照表):Q1=A(标脏 + 空集登记)、Q2=A(`setMapRef` 零代码零 jsDoc 改动;用显式区间 `<起点 sha>..HEAD -- ` 核对 `mapLayer.ts`/`combat/types.ts` 的输出不含 `setMapRef`/`getMapRef`/`resize`/`resize2`/`expired` 相关行)、Q3=A1(读档全清 + 触发 `onDeleteDynamic`)、Q4=A(IN-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` 已更新。 -- 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 `` 与 SUMMARY。 +- `pnpm test:ci` 0 失败、跳过项仍仅 1 条、无新增 `it.skip`、既有断言未弱化;D-44 三步门禁全绿;三条按缺陷原子提交且未牵连工作树中的无关改动。 - Phase 7 验证失效与重跑要求已记录。