fix(diag): 消除静默降级 —— 让地图与怪物生成的数据缺失响亮失败 #58

Closed
opened 2026-09-17 11:12:41 +00:00 by troytt · 2 comments
Owner

背景

对 main 分支(origin/main = 4c2e399)的地图与怪物生成逻辑做了一次全量 hardcode / fallback 审计,覆盖约 15,000 行代码。完整报告见本 Issue 系列的父任务。

审计发现的最严重系统性问题不是任何单点 bug,而是贯穿三层的「静默降级」(silent degradation)设计:数据缺失时不报错、不中断,而是替换成合成值继续生成。

结果是:一个数据完全损坏的世界仍然能「成功」生成,并通过全部 885 个测试。

本 Issue 追踪「让失败变响亮」这一优先级最高的修复方向。


1. UNIMPLEMENTED_PASSES 谎报「零缺口」

src/game/maze.ts:852-856

/**
 * Per-act decoration passes D2MOO runs after the layout.
 * All 11 core dungeon passes are now implemented in `maze-special-passes.ts`.
 */
export const UNIMPLEMENTED_PASSES: readonly string[] = []

但同一文件 src/game/maze.ts:594-598 自相矛盾地写着:

"DRLGMAZE_PlaceAct2TombStuff、DRLGMAZE_PlaceAct3DungeonStuff、DRLGMAZE_PlaceAct5IceStuff 等 —— 均未复现,已列入 stats.unimplementedPasses 以便缺口可见。"

打包脚本 scripts/pack-act-assets.ts:733-740 亦证实:

"第二幕下水道、墓穴、第三幕地牢的 specials 表是空的,因为它们的楼梯 pass 尚未转写,所以它们不生成楼梯房间…"

后果:stats.unimplementedPasses 在每一关都无条件报告 [],设计好的预警通道彻底失效。

修复方向:从「specials 为空的 level type 集合」+ stats.unresolvedRoles / stats.notes 重新填充该数组。

这一项必须最先修,因为其他所有严重度评估都建立在这个数字诚实的前提上。


2. maze-objects.ts:238 地板检测是死代码

const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true))

末尾的 || true 让整个 prop1 / style / sequence 内容检测恒为真。

后果:物件会生成在没有真实地板美术的格子上。

修复方向:删掉 || true,并补一个回归测试断言「空内容地板格不被判定为 openFloor」。


3. 怪物美术三重吞异常 → 全场红色方块

flowchart TD
    C["catch 整个函数<br/>act-scene.ts:1250-1253"] --> A
    D["catch 单个 spec<br/>act-scene.ts:1231-1233"] --> A
    E["resolveMonsterArtSpec 返回 null<br/>monster-mapping.ts:785"] --> A
    F["无效 token XX(18 处)<br/>monster-mapping.ts"] --> A
    G["Monster COF not found throw<br/>monster-art.ts:135-137"] --> C
    H["单层 DCC 解码 catch<br/>monster-art.ts:155-160"] --> I
    A["monsterArtMap 为空或缺键"] --> B["act-scene.ts:1988<br/>drawSolid 红色矩形 [0.9,0.2,0.2,1]"]
    I["怪物缺肢体渲染<br/>无头 / 无臂"]

三层嵌套 catch 把「整个美术系统崩溃」变成「场景就绪,怪物是红的」,仅有一句 console.warn。

涉及位置:

  • src/scene/act-scene.ts:1250-1253 —— catch 包住整个 loadMonsterArtMap,返回空 Map,全场怪物变红方块
  • src/scene/act-scene.ts:1231-1233 —— 逐 spec catch,该类怪物全变红方块
  • src/scene/act-scene.ts:1969-1991 —— 红方块绘制点
  • src/game/monster-art.ts:155-160 —— 逐 COF 层 catch,静默丢层,怪物缺肢体渲染

修复方向:

  1. 在 ActSceneState 中新增 missingMonsterArt: string[] / monsterArtErrors: number 并在 HUD 显示。
  2. 逐层 DCC 失败时至少 console.error 并计数。
  3. 考虑在开发模式下直接 throw。

4. MonLvl 缺行时怪物强度暴涨约 20 倍

src/game/monsters.ts:485-494

return scaling.get(level) ?? UNSCALED   // UNSCALED = {hp:100, dmg:100, ac:100, xp:100, ...}

缩放公式是 原始表值 × scale ÷ 100。回退到 UNSCALED 意味着直接使用原始表值:一只堕落者会拿到 21-61 HP 而非约 3 HP。

看起来像平衡 bug,实为数据 bug。

修复方向:monsterScaleFor 在缺行时 console.warn 或直接 throw,而非静默返回 UNSCALED。


5. 数据失败 → 12 只假怪物冒充真内容

src/scene/act-scene.ts:1578-1592

if (nextResolvedPacks.length > 0) spawnMonsterPacks(...)
else if (next.monsterCount > 0) spawnMonsters(world, next.monsterStats /* = DEMO_MONSTERS */, 12, ...)

DEMO_MONSTERS 是 src/game/demo-data.ts:6-10 手写的 3 只假怪(demo-fallen hp 12 dmg 3 cd 24 reach 36…)。

更糟的是「是否城镇」的判断(act-scene.ts:734 / :953)是对英文显示名做 includes('town') 子串匹配 —— 中文或改名后的城镇关卡会在城里刷出 12 只假怪。

且 demo-* id 不在 MONSTER_ART_MAP 中,所以它们同时也是红方块。

修复方向:删除整条 DEMO_MONSTERS / PLACEHOLDER_MONSTER_COUNT = 12 路径;规划失败时留空并上报。


6. 伪造的遥测数据(让验证脚本报告虚假健康状态)

位置 伪造内容
src/game/maze.ts:856 unimplementedPasses 恒为 [](同第 1 条)
src/game/wilderness-jungles.ts:611-616 junglePathCells: 100(字面常量);riverBridges: Math.max(2, bridges.length)(无论实际如何至少报 2)
src/game/wilderness-acts.ts:768-777 第四幕「高原」循环什么都不画,只递增 plateauCells 计数
src/game/wilderness.ts:5049 特殊路径恒报 subTheme: 0
src/game/wilderness.ts:2443-2454 BordType 0 的「整关仅一个」上限因 min(1, Infinity) 恒等于 1,实际是「每行一个」,预算从未起约束作用

修复方向:所有 stats 字段必须反映真实计数;伪造值一律改为实测值或显式 null。


验收标准

  • UNIMPLEMENTED_PASSES 反映真实缺口,并有测试断言其非空(当 specials 为空的 level type 仍存在时)
  • maze-objects.ts:238 的 || true 删除,附回归测试
  • 美术加载失败可在 ActSceneState / HUD 中观测到,附测试
  • monsterScaleFor 缺行时告警,附测试
  • DEMO_MONSTERS 路径删除
  • 上表 5 处伪造遥测全部改为真实计数,附测试
  • npm run typecheck 零错误,npx vitest run 全绿

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1

## 背景 对 `main` 分支(`origin/main` = `4c2e399`)的地图与怪物生成逻辑做了一次全量 hardcode / fallback 审计,覆盖约 15,000 行代码。完整报告见本 Issue 系列的父任务。 审计发现的**最严重系统性问题不是任何单点 bug,而是贯穿三层的「静默降级」(silent degradation)设计**:数据缺失时不报错、不中断,而是替换成合成值继续生成。 **结果是:一个数据完全损坏的世界仍然能「成功」生成,并通过全部 885 个测试。** 本 Issue 追踪「让失败变响亮」这一优先级最高的修复方向。 --- ## 1. `UNIMPLEMENTED_PASSES` 谎报「零缺口」 `src/game/maze.ts:852-856` ```ts /** * Per-act decoration passes D2MOO runs after the layout. * All 11 core dungeon passes are now implemented in `maze-special-passes.ts`. */ export const UNIMPLEMENTED_PASSES: readonly string[] = [] ``` 但**同一文件** `src/game/maze.ts:594-598` 自相矛盾地写着: > "`DRLGMAZE_PlaceAct2TombStuff`、`DRLGMAZE_PlaceAct3DungeonStuff`、`DRLGMAZE_PlaceAct5IceStuff` 等 —— **均未复现**,已列入 `stats.unimplementedPasses` 以便缺口可见。" 打包脚本 `scripts/pack-act-assets.ts:733-740` 亦证实: > "第二幕下水道、墓穴、第三幕地牢的 `specials` 表是空的,因为它们的楼梯 pass 尚未转写,所以它们不生成楼梯房间…" **后果**:`stats.unimplementedPasses` 在每一关都无条件报告 `[]`,设计好的预警通道彻底失效。 **修复方向**:从「`specials` 为空的 level type 集合」+ `stats.unresolvedRoles` / `stats.notes` 重新填充该数组。 > 这一项必须**最先**修,因为其他所有严重度评估都建立在这个数字诚实的前提上。 --- ## 2. `maze-objects.ts:238` 地板检测是死代码 ```ts const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true)) ``` 末尾的 `|| true` 让整个 `prop1 / style / sequence` 内容检测恒为真。 **后果**:物件会生成在**没有真实地板美术**的格子上。 **修复方向**:删掉 `|| true`,并补一个回归测试断言「空内容地板格不被判定为 openFloor」。 --- ## 3. 怪物美术三重吞异常 → 全场红色方块 ```mermaid flowchart TD C["catch 整个函数<br/>act-scene.ts:1250-1253"] --> A D["catch 单个 spec<br/>act-scene.ts:1231-1233"] --> A E["resolveMonsterArtSpec 返回 null<br/>monster-mapping.ts:785"] --> A F["无效 token XX(18 处)<br/>monster-mapping.ts"] --> A G["Monster COF not found throw<br/>monster-art.ts:135-137"] --> C H["单层 DCC 解码 catch<br/>monster-art.ts:155-160"] --> I A["monsterArtMap 为空或缺键"] --> B["act-scene.ts:1988<br/>drawSolid 红色矩形 [0.9,0.2,0.2,1]"] I["怪物缺肢体渲染<br/>无头 / 无臂"] ``` 三层嵌套 `catch` 把「整个美术系统崩溃」变成「场景就绪,怪物是红的」,仅有一句 `console.warn`。 涉及位置: - `src/scene/act-scene.ts:1250-1253` —— `catch` 包住**整个** `loadMonsterArtMap`,返回空 Map,**全场怪物变红方块** - `src/scene/act-scene.ts:1231-1233` —— 逐 spec `catch`,该类怪物全变红方块 - `src/scene/act-scene.ts:1969-1991` —— 红方块绘制点 - `src/game/monster-art.ts:155-160` —— 逐 COF 层 `catch`,静默丢层,怪物**缺肢体**渲染 **修复方向**: 1. 在 `ActSceneState` 中新增 `missingMonsterArt: string[]` / `monsterArtErrors: number` 并在 HUD 显示。 2. 逐层 DCC 失败时至少 `console.error` 并计数。 3. 考虑在开发模式下直接 throw。 --- ## 4. `MonLvl` 缺行时怪物强度暴涨约 20 倍 `src/game/monsters.ts:485-494` ```ts return scaling.get(level) ?? UNSCALED // UNSCALED = {hp:100, dmg:100, ac:100, xp:100, ...} ``` 缩放公式是 `原始表值 × scale ÷ 100`。回退到 `UNSCALED` 意味着**直接使用原始表值**:一只堕落者会拿到 21-61 HP 而非约 3 HP。 **看起来像平衡 bug,实为数据 bug。** **修复方向**:`monsterScaleFor` 在缺行时 `console.warn` 或直接 throw,而非静默返回 `UNSCALED`。 --- ## 5. 数据失败 → 12 只假怪物冒充真内容 `src/scene/act-scene.ts:1578-1592` ```ts if (nextResolvedPacks.length > 0) spawnMonsterPacks(...) else if (next.monsterCount > 0) spawnMonsters(world, next.monsterStats /* = DEMO_MONSTERS */, 12, ...) ``` `DEMO_MONSTERS` 是 `src/game/demo-data.ts:6-10` 手写的 3 只假怪(`demo-fallen` hp 12 dmg 3 cd 24 reach 36…)。 更糟的是「是否城镇」的判断(`act-scene.ts:734` / `:953`)是**对英文显示名做 `includes('town')` 子串匹配** —— 中文或改名后的城镇关卡会在城里刷出 12 只假怪。 且 `demo-*` id 不在 `MONSTER_ART_MAP` 中,所以它们**同时也是红方块**。 **修复方向**:删除整条 `DEMO_MONSTERS` / `PLACEHOLDER_MONSTER_COUNT = 12` 路径;规划失败时留空并上报。 --- ## 6. 伪造的遥测数据(让验证脚本报告虚假健康状态) | 位置 | 伪造内容 | | :--- | :--- | | `src/game/maze.ts:856` | `unimplementedPasses` 恒为 `[]`(同第 1 条) | | `src/game/wilderness-jungles.ts:611-616` | `junglePathCells: 100`(字面常量);`riverBridges: Math.max(2, bridges.length)`(无论实际如何至少报 2) | | `src/game/wilderness-acts.ts:768-777` | 第四幕「高原」循环**什么都不画**,只递增 `plateauCells` 计数 | | `src/game/wilderness.ts:5049` | 特殊路径恒报 `subTheme: 0` | | `src/game/wilderness.ts:2443-2454` | `BordType 0` 的「整关仅一个」上限因 `min(1, Infinity)` 恒等于 1,实际是「每行一个」,预算从未起约束作用 | **修复方向**:所有 `stats` 字段必须反映真实计数;伪造值一律改为实测值或显式 `null`。 --- ## 验收标准 - [ ] `UNIMPLEMENTED_PASSES` 反映真实缺口,并有测试断言其非空(当 `specials` 为空的 level type 仍存在时) - [ ] `maze-objects.ts:238` 的 `|| true` 删除,附回归测试 - [ ] 美术加载失败可在 `ActSceneState` / HUD 中观测到,附测试 - [ ] `monsterScaleFor` 缺行时告警,附测试 - [ ] `DEMO_MONSTERS` 路径删除 - [ ] 上表 5 处伪造遥测全部改为真实计数,附测试 - [ ] `npm run typecheck` 零错误,`npx vitest run` 全绿 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
troytt added this to the [M15] 生成器数据保真度:消除 hardcode 与静默降级 milestone 2026-09-17 11:12:55 +00:00
Author
Owner

父追踪 Issue:#64(完整审计报告与修复路线图)

父追踪 Issue:#64(完整审计报告与修复路线图)
troytt referenced this issue from a commit 2026-09-17 12:12:23 +00:00
Author
Owner

主控独立复验记录

已在 9d568ad(Merge branch 'fix/issue-58')合并到 main 并推送。

主控在合并前(分支上)与合并后(main 上)各独立跑了一次完整验证,不依赖子进程自报:

项 基线 结果
npm run typecheck 零错误 ✅ 零错误
npx vitest run 58 files / 923 tests ✅ 61 files passed | 1 skipped · 954 tests passed | 2 skipped · 0 failed
npm run verify:generators 19 条失败 ✅ 逐位一致,无真实数据回归

净增 3 个测试文件、31 个用例。

两处实现取舍(已认可)

  1. Act 4 关卡 104/106 的玄武岩高原未补画 —— 改为如实报 plateauCells: 0 并在 stats.notes 点名「未实现」。理由:按 4 格步长在整图点单格只会画出一片麻点,那是把假数据换成假地形。真正实现该 pass 应另开 feature。
  2. 丛林 junglePathCells 语义明确化 —— 定义为「clearing/head/tail 宏格内实际画出的可走地面格数」,并新增「地面 + 水面之和必须等于画布实测」这条更强约束,而非放宽断言。

值得记录的实现亮点

UNIMPLEMENTED_PASSES 没有被换成另一张手写列表,而是改为从 MAZE_LEVEL_TYPE_PROFILES 推导(无 profile 行 / specials 为空且无 singleRoom),因此它无法再次与它所描述的表脱节。同时 generateMaze 改为按关卡类型上报 unimplementedPassesFor(levelTypeName),而不是把全局并集当成单关的缺口。

衍生发现(已分流到其他 Issue)

  • 打包器为无楼梯 pass 的关卡凭空造 fallback warp → #60
  • verify:generators 的 19 条失败是生成器本体缺陷(Act 1 Wilderness 2/4/6 仅 62.5% 可达面积;Kurast 4 有 16% 贴图引用缺失)→ #60
  • src/scene/net-scene.ts:172/374 有另一套本地 DEMO_MONSTERS 兜底(从解析表构造,非手写假怪)→ 待评估
  • src/game/monster-art.ts 中部约 293 行有夹在类型定义之间的 import,结构异味 → #63
## 主控独立复验记录 已在 `9d568ad`(`Merge branch 'fix/issue-58'`)合并到 main 并推送。 主控在**合并前(分支上)与合并后(main 上)各独立跑了一次**完整验证,不依赖子进程自报: | 项 | 基线 | 结果 | | :--- | :--- | :--- | | `npm run typecheck` | 零错误 | ✅ 零错误 | | `npx vitest run` | 58 files / 923 tests | ✅ **61 files passed \| 1 skipped · 954 tests passed \| 2 skipped · 0 failed** | | `npm run verify:generators` | 19 条失败 | ✅ 逐位一致,无真实数据回归 | 净增 3 个测试文件、31 个用例。 ### 两处实现取舍(已认可) 1. **Act 4 关卡 104/106 的玄武岩高原未补画** —— 改为如实报 `plateauCells: 0` 并在 `stats.notes` 点名「未实现」。理由:按 4 格步长在整图点单格只会画出一片麻点,那是把假数据换成假地形。真正实现该 pass 应另开 feature。 2. **丛林 `junglePathCells` 语义明确化** —— 定义为「clearing/head/tail 宏格内实际画出的可走地面格数」,并新增「地面 + 水面之和必须等于画布实测」这条更强约束,而非放宽断言。 ### 值得记录的实现亮点 `UNIMPLEMENTED_PASSES` 没有被换成另一张手写列表,而是改为从 `MAZE_LEVEL_TYPE_PROFILES` **推导**(无 profile 行 / `specials` 为空且无 `singleRoom`),因此它**无法再次与它所描述的表脱节**。同时 `generateMaze` 改为按关卡类型上报 `unimplementedPassesFor(levelTypeName)`,而不是把全局并集当成单关的缺口。 ### 衍生发现(已分流到其他 Issue) - 打包器为无楼梯 pass 的关卡凭空造 `fallback` warp → **#60** - `verify:generators` 的 19 条失败是生成器本体缺陷(Act 1 Wilderness 2/4/6 仅 62.5% 可达面积;Kurast 4 有 16% 贴图引用缺失)→ **#60** - `src/scene/net-scene.ts:172/374` 有另一套本地 `DEMO_MONSTERS` 兜底(从解析表构造,非手写假怪)→ 待评估 - `src/game/monster-art.ts` 中部约 293 行有夹在类型定义之间的 `import`,结构异味 → **#63**
Sign in to join this conversation.
No Label
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#58
No description provided.