From 5b3a9db98662bd9fb8a7953dc54f9e3ebe4d0582 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:29:06 +0000 Subject: [PATCH 1/6] =?UTF-8?q?fix(maze):=20=E8=AE=A9=20UNIMPLEMENTED=5FPA?= =?UTF-8?q?SSES=20=E5=8F=8D=E6=98=A0=E7=9C=9F=E5=AE=9E=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UNIMPLEMENTED_PASSES` 此前被硬编码为 `[]`,注释声称 11 个核心地牢 pass 全部 实现,于是 `stats.unimplementedPasses` 在每一关都无条件报告「零缺口」——包括 那些 `specials` 表为空、根本不生成楼梯房间的 level type。 改为从 `MAZE_LEVEL_TYPE_PROFILES` 实测推导: - 新增 `unimplementedPassesFor(levelTypeName)`,按 level type 返回缺口: 没有 profile 行,或 `specials` 为空且不是 `singleRoom` 定制关。 - `UNIMPLEMENTED_PASSES` 改为所有 level type 缺口的并集,因此非空。 - `generateMaze` 的 `stats.unimplementedPasses` 改报本关自己的缺口, 而不是全局常量;`unresolvedRoles` / `notes` 本来就已随 stats 返回。 同时修正两处已经与代码不符的注释:`maze.ts` 里「per-act 装饰 pass 均未复现」 (它们已在 `maze-special-passes.ts` 中实现),以及 `pack-act-assets.ts` 里 「第二幕下水道 / 墓穴 / 第三幕地牢 specials 为空」(这三者现在都有 specials, 真正为空的是 Harem、Basement、Spider、Act 5 Lava)。 测试:原 `UNIMPLEMENTED_PASSES is completely empty` 断言的是被修正的错误行为, 改为断言它恰好列出所有 `specials` 为空的 level type;另加 4 个用例覆盖 按 level type 查询与 `generateMaze` 的 stats 透出。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- scripts/pack-act-assets.ts | 7 ++-- src/game/maze.ts | 52 ++++++++++++++++++++++--- tests/maze-special-passes.test.ts | 64 +++++++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index f7b9cc4..ebb47fc 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -730,9 +730,10 @@ function buildSceneLinks( placedSeams.add(edge.to) } - // 5. Last resort. Several level types — the Act 2 sewers, the tombs, the - // Act 3 dungeons — have an empty `specials` table in `MAZE_LEVEL_TYPE_PROFILES` - // because their DRLG staircase pass has not been transcribed, so they + // 5. Last resort. Several level types — the Act 2 Harem and Basement, the + // Act 3 Spider Cavern, the Act 5 Infernal Pit — have an empty `specials` + // table in `MAZE_LEVEL_TYPE_PROFILES` because their DRLG staircase pass + // has not been transcribed (`unimplementedPassesFor` names them), so they // stamp no stair room, and their pieces carry no marker either. Leaving // those edges unplaced makes a level you can enter and never leave, which // is a worse lie than an approximate staircase. Each one is spread to a diff --git a/src/game/maze.ts b/src/game/maze.ts index df5dd3a..c6b70fd 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -594,8 +594,11 @@ function entries(plainSides: readonly string[] = NESW): Omit candidate.levelTypeName === levelTypeName) + if (profile === undefined) return [`DRLGMAZE_GenerateLevel profile (${levelTypeName})`] + const gaps: string[] = [] + if (profile.specials.length === 0 && profile.singleRoom === undefined) { + gaps.push(`DRLGMAZE_GenerateLevel specials table (${levelTypeName})`) + } + return gaps +} + +/** + * Every gap {@link unimplementedPassesFor} can name, across all level types. + * + * Non-empty while any level type is still missing its replacement table. Prefer + * the per-level-type function: a caller that reports this constant for one level + * is reporting other levels' gaps as its own. + */ +export const UNIMPLEMENTED_PASSES: readonly string[] = MAZE_LEVEL_TYPE_PROFILES + .flatMap(profile => unimplementedPassesFor(profile.levelTypeName)) /* ------------------------------------------------------------------------- * * Geometry @@ -2327,7 +2367,7 @@ export function generateMaze(request: MazeRequest): MazeResult { } : null, unresolvedRoles: stats.unresolvedRoles, - unimplementedPasses: UNIMPLEMENTED_PASSES, + unimplementedPasses: unimplementedPassesFor(levelTypeName), maxCellsX, maxCellsY, boundsRejected: stats.boundsRejected ?? 0, diff --git a/tests/maze-special-passes.test.ts b/tests/maze-special-passes.test.ts index c79a5da..9931269 100644 --- a/tests/maze-special-passes.test.ts +++ b/tests/maze-special-passes.test.ts @@ -13,7 +13,8 @@ * 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect) * 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers) * 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint) - * 12. Full generateMaze integration tests verifying specialPassesApplied and UNIMPLEMENTED_PASSES == []. + * 12. Full generateMaze integration tests verifying specialPassesApplied and the + * per-level-type `unimplementedPasses` gap list. */ import { describe, expect, test } from 'vitest' @@ -21,7 +22,9 @@ import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' import { generateMaze, classifyMazePieceName, + MAZE_LEVEL_TYPE_PROFILES, UNIMPLEMENTED_PASSES, + unimplementedPassesFor, type MazePiece, type MazePieceKind, type MazeRequest, @@ -106,11 +109,64 @@ function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[] } describe('Diablo II DRLG Maze Special Passes', () => { - test('UNIMPLEMENTED_PASSES is completely empty', () => { - expect(UNIMPLEMENTED_PASSES).toEqual([]) - expect(UNIMPLEMENTED_PASSES.length).toBe(0) + // Was `expect(UNIMPLEMENTED_PASSES).toEqual([])`: the constant was hardcoded + // to `[]`, so it asserted a claim the profile table contradicts. The gap list + // is now derived, so the honest assertion is "it names exactly the level types + // whose `specials` table is still empty". + test('UNIMPLEMENTED_PASSES names every level type with no transcribed specials table', () => { + const emptySpecials = MAZE_LEVEL_TYPE_PROFILES + .filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined) + .map(profile => profile.levelTypeName) + expect(emptySpecials.length).toBeGreaterThan(0) + expect(UNIMPLEMENTED_PASSES).toEqual( + emptySpecials.map(name => `DRLGMAZE_GenerateLevel specials table (${name})`), + ) }) + test('unimplementedPassesFor is empty for a fully transcribed level type', () => { + expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([]) + expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([]) + }) + + test('unimplementedPassesFor reports the gap for a level type with no specials', () => { + expect(unimplementedPassesFor('Act 3 - Spider')) + .toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)']) + }) + + test('unimplementedPassesFor reports a level type that has no profile at all', () => { + expect(unimplementedPassesFor('Act 9 - Nowhere')) + .toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)']) + }) + + test('generateMaze reports the level type gap, not a global constant', () => { + const spider = generateMaze({ + levelId: 84, + levelName: 'Spider Cavern', + sectionSize: 10, + minRooms: 5, + merge: 500, + seed: 8484, + pieces: createStandardPieceSet('Act 3 - Spider'), + }) + expect(spider.stats.unimplementedPasses) + .toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)']) + // The other two diagnostic channels must travel with it. + expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true) + expect(Array.isArray(spider.stats.notes)).toBe(true) + + const cave = generateMaze({ + levelId: 8, + levelName: 'Den of Evil', + sectionSize: 10, + minRooms: 5, + merge: 500, + seed: 8484, + pieces: createStandardPieceSet('Act 1 - Cave'), + }) + expect(cave.stats.unimplementedPasses).toEqual([]) + }) + + /* ----------------------------------------------------------------------- * * 1. Act 2 - Tomb * ----------------------------------------------------------------------- */ From e817ace5ef4b1391dc9ade2d9ad290e11d781519 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:30:47 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix(maze-objects):=20=E5=88=A0=E6=8E=89=20i?= =?UTF-8?q?sOpenFloor=20=E9=87=8C=E6=81=92=E7=9C=9F=E7=9A=84=20`||=20true`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasFloor` 的内容检测写成 `(f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true)`,末尾的 `|| true` 让整个条件恒为真:DS1 每个格子的每一层地板都有一条 记录,未绘制处是全零槽位,于是「没有地板美术的空格」也被判定为可放物件的地面。 删除 `|| true` 后,全零地板槽位不再算作地面;新增 6 个回归用例覆盖 painted / 全零 / 仅 style / 仅 sequence / hidden / 多层,以及「整关地板全为空槽 时不生成任何动态物件」。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/maze-objects.ts | 8 ++++-- tests/maze-objects.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts index 854f955..8060f7c 100644 --- a/src/game/maze-objects.ts +++ b/src/game/maze-objects.ts @@ -234,8 +234,12 @@ export function isOpenFloor(cells: readonly (readonly Ds1Cell[])[], cx: number, const cell = row[cx] if (!cell) return false - // Must contain floor - const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true)) + // Must contain floor *art*. A `Ds1Floor` whose `prop1`, `style` and `sequence` + // are all zero is an empty slot, not a tile: DS1 stores one entry per floor + // layer for every cell, so a one-layer map has a zero-filled entry everywhere + // the level does not paint. The `|| true` that used to close this condition + // made the whole content test vacuous, so objects were placed on void cells. + const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0)) if (!hasFloor) return false // Must not have a solid, visible wall diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts index 1e259b7..ff44275 100644 --- a/tests/maze-objects.test.ts +++ b/tests/maze-objects.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' import { populateDungeonObjects, + isOpenFloor, CANONICAL_SHRINES, CHEST_IDS_BY_ACT, RACK_IDS_BY_ACT, @@ -482,3 +483,56 @@ describe('generateMaze end-to-end dynamic object integration', () => { expect(foundStatic).toBe(true) }) }) + +/** + * Regression cover for the `|| true` that used to close `isOpenFloor`'s content + * test (issue #58 item 2). With it, every `Ds1Floor` entry counted as floor art, + * so objects were seeded on cells the level never paints. + */ +describe('isOpenFloor content check', () => { + /** One cell grid with the given floor entries and no walls. */ + function grid(floors: Ds1Cell['floors']): Ds1Cell[][] { + return [[{ walls: [], floors, shadows: [], substitutions: [] }]] + } + + const painted = { prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } + const empty = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } + + test('a painted floor cell is open floor', () => { + expect(isOpenFloor(grid([painted]), 0, 0)).toBe(true) + }) + + test('a cell whose only floor entry is all-zero is not open floor', () => { + expect(isOpenFloor(grid([empty]), 0, 0)).toBe(false) + }) + + test('style or sequence alone still counts as art', () => { + expect(isOpenFloor(grid([{ ...empty, style: 3 }]), 0, 0)).toBe(true) + expect(isOpenFloor(grid([{ ...empty, sequence: 7 }]), 0, 0)).toBe(true) + }) + + test('a hidden painted floor does not rescue an otherwise empty cell', () => { + expect(isOpenFloor(grid([{ ...painted, hidden: true }]), 0, 0)).toBe(false) + }) + + test('a multi-layer cell is open floor when any layer carries art', () => { + expect(isOpenFloor(grid([empty, painted]), 0, 0)).toBe(true) + expect(isOpenFloor(grid([empty, empty]), 0, 0)).toBe(false) + }) + + test('objects are not seeded into a level whose floors are all empty slots', () => { + const { level, rooms } = createSyntheticDungeon() + for (const row of level.cells) { + for (const cell of row) { + ;(cell.floors as Ds1Cell['floors'][number][]).splice( + 0, + cell.floors.length, + { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }, + ) + } + } + const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { density: 1.0 }, new Rng(7)) + expect(result.placedObjects.length).toBe(0) + }) +}) + From 952ad004292b94805b1cff5e5477698edec0b810 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:35:13 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(monsters):=20MonLvl=20=E7=BC=BA?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E5=91=8A=E8=AD=A6=E8=80=8C=E9=9D=9E=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E8=BF=94=E5=9B=9E=20UNSCALED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缩放公式是 `原始表值 × scale ÷ 100`,回退到 `UNSCALED`(全 100)等于直接使用 `MonStats.txt` 的原始值:一只堕落者拿到 21-61 HP 而不是约 3 HP,约 20 倍。 原来这一步完全静默。 - `monsterScaleFor` 缺行时 `console.warn`(按 level 去重,避免每只怪刷一行), 并新增 `setMonsterScalingStrict(true)` 让烘焙 / 校验脚本直接 throw。 - 新增 `monsterScaleLookup`,返回 `{ scale, missing }`,供调用方在不读 console 的前提下自行判断。 - `PlannedLevel` 新增 `missingScalingLevels`,`planLevelMonsters` 把本关所有 缺行的 level 汇总上报,烘焙方因此可以拒绝一个「怪物强度是假的」的关卡。 新增 4 个用例:告警一次、strict 抛错、lookup 不写 console、 `planLevelMonsters` 在 MonLvl 抽掉 level 1 行时报告 `[1]`。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monsters.ts | 99 +++++++++++++++++++++++++++++++++++++++--- tests/monsters.test.ts | 79 ++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 7 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index d4133e4..ed687bd 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -482,15 +482,85 @@ export function monsterLevelFor(kind: MonsterKind, difficulty: Difficulty, areaL return areaLevel > 0 ? areaLevel : kind.level[tier] } +/** + * Whether a missing `MonLvl.txt` row is fatal. + * + * Off by default, because a hand-built fixture legitimately ships a two-row + * table. Turn it on in a bake or a verification script, where a missing row is a + * broken input rather than a deliberately small one. + */ +let scalingStrict = false + +/** + * Levels already warned about, so one missing row is one line and not one line + * per monster rolled at that level. + */ +const warnedScaleLevels = new Set() + +/** + * Make a missing `MonLvl.txt` row throw instead of warn. + * + * @param strict - true to throw. + */ +export function setMonsterScalingStrict(strict: boolean): void { + scalingStrict = strict + if (!strict) warnedScaleLevels.clear() +} + +/** The levels {@link monsterScaleFor} has had to fall back to {@link UNSCALED} for. */ +export function monsterScalingMisses(): readonly number[] { + return [...warnedScaleLevels].sort((a, b) => a - b) +} + +/** A scale lookup that says whether it found a row. */ +export interface MonsterScaleLookup { + readonly scale: MonsterScale + /** True when the level had no `MonLvl.txt` row and {@link UNSCALED} was used. */ + readonly missing: boolean +} + +/** + * The multipliers for a level, with the miss reported rather than hidden. + * + * The caller needs this distinction because of what {@link UNSCALED} means: the + * formula is `raw × scale ÷ 100`, so falling back to 100 across the board uses + * the *raw table values* as game values. A Fallen then arrives with `MonStats`' + * 21–61 HP instead of the ~3 HP the level-1 row scales it down to, which reads + * as a balance problem and is in fact a missing row. + * + * @param scaling - the table, from {@link readMonsterScaling}. + * @param level - the level, from {@link monsterLevelFor}. + * @returns the scale and whether the row was missing. + */ +export function monsterScaleLookup(scaling: ReadonlyMap, level: number): MonsterScaleLookup { + const found = scaling.get(level) + if (found !== undefined) return { scale: found, missing: false } + return { scale: UNSCALED, missing: true } +} + /** * The multipliers for a level. * + * A level with no row used to return {@link UNSCALED} silently; it now warns + * once per level, and throws when {@link setMonsterScalingStrict} is on. Callers + * that need to react rather than read the console use {@link monsterScaleLookup}. + * * @param scaling - the table, from {@link readMonsterScaling}. * @param level - the level, from {@link monsterLevelFor}. * @returns the scale, or {@link UNSCALED} when the level has no row. + * @throws when strict mode is on and the level has no row. */ export function monsterScaleFor(scaling: ReadonlyMap, level: number): MonsterScale { - return scaling.get(level) ?? UNSCALED + const lookup = monsterScaleLookup(scaling, level) + if (!lookup.missing) return lookup.scale + const message = `MonLvl.txt has no row for level ${String(level)}: ` + + `monsters there keep their raw MonStats values (about 20x too strong)` + if (scalingStrict) throw new Error(message) + if (!warnedScaleLevels.has(level)) { + warnedScaleLevels.add(level) + console.warn(message) + } + return lookup.scale } /** What `Levels.txt` says should live on one level. */ @@ -1358,6 +1428,15 @@ export interface PlannedLevel { readonly packs: readonly MonsterPack[] /** How many packs are champion or unique. */ readonly elitePacks: number + /** + * Levels whose `MonLvl.txt` row was missing while rolling this population. + * + * Non-empty means some of {@link PlannedLevel.packs} carry raw `MonStats.txt` + * numbers rather than scaled ones — roughly 20x too strong — because + * {@link monsterScaleFor} had to fall back to {@link UNSCALED}. Empty is the + * healthy case, and the only one a bake should accept. + */ + readonly missingScalingLevels: readonly number[] /** Fixed SuperUnique bosses planned for this level (e.g. Bishibosh, Rakanishu). */ readonly superUniques?: readonly string[] } @@ -1399,12 +1478,21 @@ export function planLevelMonsters( }[] }, ): PlannedLevel { - const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 } + const empty: PlannedLevel = { + levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0, missingScalingLevels: [], + } const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty) if (plan === null) return empty const kinds = readMonsterKinds(tables.monstats, difficulty) const scaling = readMonsterScaling(tables.monlvl, difficulty) + // Every scale lookup goes through here so a missing `MonLvl.txt` row reaches + // the caller as data (`missingScalingLevels`) as well as the console. + const missingScaling = new Set() + const scaleAt = (level: number): MonsterScale => { + if (monsterScaleLookup(scaling, level).missing) missingScaling.add(level) + return monsterScaleFor(scaling, level) + } // Three independent streams off the one seed. Sharing a single stream would // make the type choice depend on how many monsters the density happened to // ask for, so a level would change its monsters when its size changed. @@ -1418,7 +1506,7 @@ export function planLevelMonsters( const members: MonsterStats[] = [] for (let i = 0; i < group.count; i += 1) { const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel) - const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, monsterScaleFor(scaling, level)) + const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level)) // An elite pack is one leader plus its minions: the leader carries the // rank's full bonus, the minions a smaller one, which is why a champion // pack reads as "a tough one and its friends" rather than as a wall. @@ -1446,7 +1534,7 @@ export function planLevelMonsters( const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3) - const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel)) + const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel)) const bossScaled: MonsterStats = { ...bossBase, id: suSpec.monsterId, @@ -1458,7 +1546,7 @@ export function planLevelMonsters( const suMembers: MonsterStats[] = [bossStats] for (let m = 0; m < minionCount; m += 1) { const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel) - const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel)) + const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel)) const mScaled: MonsterStats = { ...mBase, id: suSpec.minionMonsterId, @@ -1486,6 +1574,7 @@ export function planLevelMonsters( budget, packs, elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length, + missingScalingLevels: [...missingScaling].sort((a, b) => a - b), ...(superUniques.length > 0 ? { superUniques } : {}), } } diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index b7fb471..625980f 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -7,7 +7,7 @@ * the expected answer exactly. `scripts/verify-monsters.ts` covers the other * half, that the column names match the real 255-column file. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { D2Table } from '../src/game/acts.ts' import { DENSITY_CELLS_PER_MONSTER, @@ -16,7 +16,10 @@ import { planMonsterGroups, readEliteModifiers, readLevelMonsterPlan, + planLevelMonsters, monsterLevelFor, monsterScaleFor, + monsterScaleLookup, + setMonsterScalingStrict, readMonsterArt, readMonsterKinds, readMonsterScaling, @@ -570,7 +573,79 @@ describe('monsterScaleFor', () => { // Zero multipliers would silently produce a monster with one health and one // damage, which looks like a working monster rather than like a gap. const offTable: MonsterKind = { ...kinds.get('fallen1')!, level: [7, 7, 7] } - expect(monsterScaleFor(scaling, offTable.level[0])).toBe(UNSCALED) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(monsterScaleFor(scaling, offTable.level[0])).toBe(UNSCALED) + } finally { + warn.mockRestore() + } + }) + + it('warns instead of silently substituting UNSCALED', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + // 4242 has never been warned about, so the once-per-level guard cannot + // swallow this one. + monsterScaleFor(scaling, 4242) + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0]?.[0])).toContain('MonLvl.txt has no row for level 4242') + // Once per level, not once per monster rolled at that level. + monsterScaleFor(scaling, 4242) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) + + it('throws in strict mode', () => { + setMonsterScalingStrict(true) + try { + expect(() => monsterScaleFor(scaling, 4243)).toThrow(/MonLvl\.txt has no row for level 4243/) + // A level the table does carry is still fine. + expect(monsterScaleFor(scaling, 1)).toEqual(scaling.get(1)) + } finally { + setMonsterScalingStrict(false) + } + }) + + it('reports the miss to a caller that asks, without touching the console', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(monsterScaleLookup(scaling, 1)).toEqual({ scale: scaling.get(1), missing: false }) + expect(monsterScaleLookup(scaling, 4244)).toEqual({ scale: UNSCALED, missing: true }) + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) +}) + + +describe('planLevelMonsters reports missing MonLvl rows', () => { + const tables = { levels: LEVELS, monstats: MONSTATS, monlvl: MONLVL } + + it('reports nothing when every rolled level has a row', () => { + const plan = planLevelMonsters(tables, 2, 6400, 1234, 170) + expect(plan.packs.length).toBeGreaterThan(0) + expect(plan.missingScalingLevels).toEqual([]) + }) + + it('names the level whose row is missing instead of pretending it scaled', () => { + // Same table with the level-1 and level-2 rows taken out: the Blood Moor's + // monsters are all `Level` 1, so every one of them now rolls unscaled. + const thin = table( + ['Level', 'AC', 'AC(N)', 'TH', 'TH(N)', 'HP', 'HP(N)', 'DM', 'DM(N)', 'XP', 'XP(N)'], + [['30', '180', '190', '309', '320', '133', '300', '19', '52', '660', '1600']], + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const plan = planLevelMonsters({ ...tables, monlvl: thin }, 2, 6400, 1234, 170) + expect(plan.packs.length).toBeGreaterThan(0) + expect(plan.missingScalingLevels).toEqual([1]) + expect(warn).toHaveBeenCalled() + } finally { + warn.mockRestore() + } }) }) From d890f558b1d7b958fb7956c70375dc7869f7876b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:40:05 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(scene):=20=E8=AE=A9=E6=80=AA=E7=89=A9?= =?UTF-8?q?=E7=BE=8E=E6=9C=AF=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=EF=BC=8C=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=8F=98=E7=BA=A2=E6=96=B9=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三层 catch 把「整个美术系统崩溃」变成「场景就绪 + 全场红方块」,只留一句 console.warn: - `monster-art.ts` 逐 COF 层的 `catch` 直接丢层,怪物缺头缺臂而无任何日志。 现在每一层失败都产出 `MonsterLayerFailure`(unknown-component / no-member / decode),`console.error` 输出,并随 `MonsterAnimationSheet.layerFailures` 与 `LoadedMonsterArt.layerFailures` 返回。 - `loadMonsterArtMap` 改为返回 `MonsterArtLoad{ map, missing, errors, layerFailures }`:整函数 catch 与逐 spec catch 都计数并降级为 console.error, 预烘焙资源 fetch 失败也不再完全无声。 - `ActSceneState` 新增 `missingMonsterArt` / `monsterArtErrors` / `monsterArtLayerFailures`,由 `recordMonsterArtLoad` 在两处加载点写入; HUD 追加 `monsterArtStatusText` 片段。 - 红方块绘制点自己上报 id(流式刷怪可能刷出加载时未知的类型)。 新增 tests/monster-art-diagnostics.test.ts(5 例):逐层 no-member / decode 失败的计数与日志,以及 `missingMonsterArtIds` / `monsterArtStatusText`。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/monster-art.ts | 81 +++++++++++++- src/scene/act-scene.ts | 154 +++++++++++++++++++++++--- tests/monster-art-diagnostics.test.ts | 138 +++++++++++++++++++++++ 3 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 tests/monster-art-diagnostics.test.ts diff --git a/src/game/monster-art.ts b/src/game/monster-art.ts index 74f4fe5..ca4fa6e 100644 --- a/src/game/monster-art.ts +++ b/src/game/monster-art.ts @@ -84,6 +84,33 @@ function blit( } } +/** + * One COF layer that did not make it into the composite. + * + * A dropped layer is not a missing monster — it is a monster rendered without + * its head, or its weapon arm. That used to happen behind a bare `catch`, so + * the only evidence was the picture. Every drop is now described here and + * logged at error level. + */ +export interface MonsterLayerFailure { + /** The monster token, e.g. `fa`. */ + readonly token: string + /** The animation the layer belonged to, e.g. `wl`. */ + readonly animation: string + /** The composite component folder, e.g. `hd`; `null` for a layer type outside {@link MONSTER_COMPONENTS}. */ + readonly component: string | null + /** The DCC member that failed, when one was found. */ + readonly member: string | null + /** + * - `unknown-component` — the COF names a layer type this port does not map. + * - `no-member` — no DCC in the archives matches the layer's naming convention. + * - `decode` — the DCC was found but could not be read. + */ + readonly reason: 'unknown-component' | 'no-member' | 'decode' + /** The decoder's message, for `decode`. */ + readonly message?: string +} + /** * Result of compositing a monster animation. */ @@ -93,6 +120,8 @@ export interface MonsterAnimationSheet { readonly framesPerDirection: number readonly layers: number readonly decodedMembers: readonly string[] + /** Layers that were dropped; empty when the monster composited whole. */ + readonly layerFailures: readonly MonsterLayerFailure[] } /** @@ -141,7 +170,13 @@ export async function compositeMonsterAnimation( const layerResults = await Promise.all(cof.layers.map(async (layer) => { const component = MONSTER_COMPONENTS[layer.type] - if (component === undefined) return { sprite: null, member: undefined } + if (component === undefined) { + return { + sprite: null, + member: undefined, + failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure, + } + } const member = findMonsterLayerSprite( names, root, @@ -151,17 +186,44 @@ export async function compositeMonsterAnimation( component, layer.weaponClass, ) - if (member === undefined) return { sprite: null, member: undefined } + if (member === undefined) { + return { + sprite: null, + member: undefined, + failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure, + } + } try { const bytes = await archives.read(member) - return { sprite: decodeDcc(bytes), member } - } catch { - return { sprite: null, member: undefined } + return { sprite: decodeDcc(bytes), member, failure: undefined } + } catch (err) { + return { + sprite: null, + member: undefined, + failure: { + token: lower, + animation: usedAnim, + component, + member, + reason: 'decode', + message: (err as Error).message, + } as MonsterLayerFailure, + } } })) const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite) const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : []) + const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : []) + // A monster missing a limb is a data failure, not a cosmetic one, so it is + // said out loud even though the composite still returns a usable sheet. + for (const failure of layerFailures) { + console.error( + `monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) ` + + `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}` + + `${failure.message !== undefined ? `: ${failure.message}` : ''}`, + ) + } // Determine maximum bounding box across all layer directions let minX = 0 @@ -222,6 +284,7 @@ export async function compositeMonsterAnimation( framesPerDirection: cof.framesPerDirection, layers: cof.layers.length, decodedMembers, + layerFailures, } } @@ -242,6 +305,13 @@ export interface LoadedMonsterArt { readonly standOffset: number readonly walkFrames: number readonly standFrames: number + /** + * COF layers that were dropped while compositing this monster. + * + * Non-empty means the sprite on screen is missing a body part. Pre-baked pack + * art has no layers of its own, so its loader reports an empty list. + */ + readonly layerFailures: readonly MonsterLayerFailure[] } /** @@ -276,5 +346,6 @@ export async function loadMonsterAtlas( standOffset: walk.sheet.groups.length, walkFrames: walk.framesPerDirection, standFrames: stand.framesPerDirection, + layerFailures: [...walk.layerFailures, ...stand.layerFailures], } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 390b340..30b8922 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -238,6 +238,17 @@ export interface ActSceneState { npcsNear: NpcEntity[] /** Count of animated tiles in the active map. */ animatedTiles: number + /** + * Monster ids the art loader could not dress, lower case. + * + * Each one renders as a red placeholder box. The scene stays `ready` — it can + * still be walked — but it is not showing the level it claims to. + */ + missingMonsterArt: string[] + /** Monster-art loads that threw: a whole level, or one monster type. */ + monsterArtErrors: number + /** COF layers dropped inside art that did load: monsters missing body parts. */ + monsterArtLayerFailures: number } declare global { @@ -257,6 +268,7 @@ const state: ActSceneState = { drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, + missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, } if (typeof window !== 'undefined') window.__d2webAct = state @@ -1141,6 +1153,87 @@ interface PackedCharJson { readonly groups: readonly (readonly [number, number, number, number])[][] } +/** + * What a monster-art load produced, including what it failed to produce. + * + * The three failure channels used to be a `console.warn` and an empty `Map`, + * which the render loop turns into a red box per monster while the scene still + * reports `ready`. They are counted here so {@link ActSceneState} — and through + * it the HUD — can say so. + */ +export interface MonsterArtLoad { + /** Monster id (lower case) to its atlas. */ + readonly map: Map + /** Monster ids left with no atlas: every one of these draws as a red box. */ + readonly missing: string[] + /** Loads that threw — a per-spec failure, or the whole function. */ + readonly errors: number + /** COF layers dropped inside the art that *did* load: monsters missing limbs. */ + readonly layerFailures: number +} + +/** + * The monster ids that got no atlas. + * + * @param monsterIds - the ids the level asked for. + * @param loaded - the ids that loaded, lower case. + * @returns the missing ids, lower case and deduplicated, in request order. + */ +export function missingMonsterArtIds( + monsterIds: readonly string[], + loaded: ReadonlySet, +): string[] { + const missing: string[] = [] + for (const id of monsterIds) { + const key = id.toLowerCase() + if (!loaded.has(key) && !missing.includes(key)) missing.push(key) + } + return missing +} + +/** + * The HUD fragment describing a monster-art load, empty when it was clean. + * + * @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}. + * @returns the fragment, ready to append to the HUD line. + */ +export function monsterArtStatusText(load: { + readonly missing: readonly string[] + readonly errors: number + readonly layerFailures: number +}): string { + const parts: string[] = [] + if (load.missing.length > 0) { + const shown = load.missing.slice(0, 3).join('、') + const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : '' + parts.push(`怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`) + } + if (load.errors > 0) parts.push(`加载错误 ${String(load.errors)}`) + if (load.layerFailures > 0) parts.push(`缺层 ${String(load.layerFailures)}`) + return parts.length === 0 ? '' : ` · ${parts.join(' · ')}` +} + +/** + * Publish a load's failures into {@link ActSceneState}. + * + * Both the boot path and the level-change path call the loader, and both used to + * drop everything but the `Map` on the floor. + * + * @param load - what the loader produced. + */ +function recordMonsterArtLoad(load: MonsterArtLoad): void { + state.missingMonsterArt = [...load.missing] + state.monsterArtErrors = load.errors + state.monsterArtLayerFailures = load.layerFailures + if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) { + console.error( + `monster art incomplete: ${String(load.missing.length)} type(s) without art ` + + `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), ` + + `${String(load.layerFailures)} dropped layer(s)`, + ) + } +} + /** * Load monster atlases on-demand for all monster types present in the scene. * Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms), @@ -1153,9 +1246,11 @@ async function loadMonsterArtMap( renderer: SpriteRenderer, packBase?: string, act?: number, -): Promise> { +): Promise { const map = new Map() - if (monsterIds.length === 0) return map + let errors = 0 + let layerFailures = 0 + if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures } try { // Resolve unique (token, weapon) specs needed const tokenSpecs = new Map() @@ -1200,11 +1295,15 @@ async function loadMonsterArtMap( standOffset: meta.standOffset, walkFrames: meta.walkFrames, standFrames: meta.standFrames, + // Pre-baked art is already composited: there are no COF layers + // left to drop at load time. + layerFailures: [], }) return } - } catch { - // fall through to per-act PNG fallback + } catch (err) { + // Fall through to the per-act PNG, but do not pretend it did not happen. + console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`) } // 2. Fallback to per-act PNG if available @@ -1235,11 +1334,13 @@ async function loadMonsterArtMap( standOffset: meta.standOffset, walkFrames: meta.walkFrames, standFrames: meta.standFrames, + layerFailures: [], }) return } - } catch { - // fall back to MPQ loading below + } catch (err) { + // Fall back to MPQ loading below, with the reason on the record. + console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`) } } fallbackSpecs.push([key, spec]) @@ -1259,13 +1360,18 @@ async function loadMonsterArtMap( const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer) loadedBySpecKey.set(key, loaded) } catch (err) { - console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`) + // Every monster of this type will be a red box; say which type and + // count it so the scene cannot claim to be fully dressed. + errors += 1 + console.error(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`) } }) ) } } + for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length + // Map monster id to loaded art for (const id of monsterIds) { const spec = resolveMonsterArtSpec(id) @@ -1278,9 +1384,12 @@ async function loadMonsterArtMap( } } } catch (err) { - console.warn(`monster art loading error: ${(err as Error).message}`) + // This `catch` wraps the whole function, so landing here means *no* monster + // in the level has art. That is a broken scene, not a warning. + errors += 1 + console.error(`monster art loading error: ${(err as Error).message}`) } - return map + return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures } } async function loadCharacterArt( @@ -1791,8 +1900,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: renderer, packEntityBase, packEntityAct, - ).then((loadedMap) => { - monsterArtMap = loadedMap + ).then((loaded) => { + monsterArtMap = loaded.map + recordMonsterArtLoad(loaded) }) } } @@ -2169,7 +2279,15 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: return } } - // Fallback to placeholder solid box if art not loaded or frame missing + // Fallback to placeholder solid box if art not loaded or frame missing. + // Streaming can spawn a type the art load never saw (a super unique's + // minions, say), so the box itself reports — otherwise the only record + // of it is the screen. + const missingId = monster.stats.id.toLowerCase() + if (!state.missingMonsterArt.includes(missingId)) { + state.missingMonsterArt.push(missingId) + console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`) + } renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1]) }) } @@ -2276,6 +2394,13 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: + ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})` + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') + // A level whose monsters are all red boxes used to look exactly like a + // healthy one here. + + monsterArtStatusText({ + missing: state.missingMonsterArt, + errors: state.monsterArtErrors, + layerFailures: state.monsterArtLayerFailures, + }) } }, }) @@ -2317,8 +2442,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: renderer, packEntityBase, packEntityAct, - ).then((loadedMap) => { - monsterArtMap = loadedMap + ).then((loaded) => { + monsterArtMap = loaded.map + recordMonsterArtLoad(loaded) }) } diff --git a/tests/monster-art-diagnostics.test.ts b/tests/monster-art-diagnostics.test.ts new file mode 100644 index 0000000..70e9f1d --- /dev/null +++ b/tests/monster-art-diagnostics.test.ts @@ -0,0 +1,138 @@ +/** + * Monster art failures must be observable (issue #58 item 3). + * + * Three `catch` blocks used to turn "the monster art system is broken" into "the + * scene is ready and every monster is a red box", with at most one + * `console.warn`. These tests pin the two halves of the fix: + * + * - `compositeMonsterAnimation` reports every COF layer it drops, rather than + * rendering a monster without its head and saying nothing. + * - `act-scene` can tell which monster ids ended up with no art, and says so in + * the HUD. + */ +import { describe, expect, it, vi } from 'vitest' +import { compositeMonsterAnimation } from '../src/game/monster-art.ts' +import type { MountedArchives } from '../src/mpq/mount.ts' +import { missingMonsterArtIds, monsterArtStatusText } from '../src/scene/act-scene.ts' + +/** + * A COF with `layerCount` layers, one frame, one direction. + * + * Mirrors `decodeCof`'s reader: a 28-byte header followed by one 9-byte layer + * record each, then the frame-duration table and the per-direction layer order. + */ +function makeCof(layerCount: number): Uint8Array { + const directions = 1 + const frames = 1 + const size = 28 + layerCount * 9 + frames * 4 + directions * frames * layerCount + const bytes = new Uint8Array(size) + const view = new DataView(bytes.buffer) + bytes[0] = layerCount + bytes[1] = frames + bytes[2] = directions + let at = 28 + for (let layer = 0; layer < layerCount; layer += 1) { + bytes[at] = layer // composite type: 0 = hd, 1 = tr, ... + bytes[at + 1] = 0 // shadow + bytes[at + 2] = 0 // selectable + bytes[at + 3] = 0 // override transparency + // weapon class, 4 characters, 'hth\0' + bytes[at + 4] = 0x68 + bytes[at + 5] = 0x74 + bytes[at + 6] = 0x68 + bytes[at + 7] = 0 + at += 9 + } + for (let frame = 0; frame < frames; frame += 1) { + view.setInt32(at, 256, true) + at += 4 + } + for (let i = 0; i < directions * frames * layerCount; i += 1) { + bytes[at] = i % layerCount + at += 1 + } + return bytes +} + +/** An archive holding only the COF: every DCC layer will fail to resolve. */ +function archivesWithOnlyCof(cof: Uint8Array): MountedArchives { + const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof' + return { + listFiles: async () => [cofName], + read: async (name: string) => { + if (name === cofName) return cof + throw new Error(`not found: ${name}`) + }, + } as unknown as MountedArchives +} + +describe('compositeMonsterAnimation layer failures', () => { + it('reports and logs every dropped COF layer instead of swallowing it', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const sheet = await compositeMonsterAnimation(archivesWithOnlyCof(makeCof(3)), 'fa', 'wl', 'hth') + // Three layers, no DCC members in the archive: three honest failures. + expect(sheet.layers).toBe(3) + expect(sheet.layerFailures.length).toBe(3) + expect(sheet.layerFailures.map(f => f.component)).toEqual(['hd', 'tr', 'lg']) + expect(sheet.layerFailures.every(f => f.reason === 'no-member')).toBe(true) + expect(sheet.decodedMembers).toEqual([]) + // Silence was the bug; the log is part of the fix. + expect(error).toHaveBeenCalledTimes(3) + } finally { + error.mockRestore() + } + }) + + it('reports a layer whose DCC is present but undecodable', async () => { + const cofName = 'data\\global\\monsters\\fa\\cof\\fawlhth.cof' + const dccName = 'data\\global\\monsters\\fa\\hd\\fahdlitwlhth.dcc' + const cof = makeCof(1) + const archives = { + listFiles: async () => [cofName, dccName], + read: async (name: string) => { + if (name === cofName) return cof + // A truncated DCC: found, but `decodeDcc` cannot read it. + if (name === dccName) return new Uint8Array([0, 0, 0]) + throw new Error(`not found: ${name}`) + }, + } as unknown as MountedArchives + + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const sheet = await compositeMonsterAnimation(archives, 'fa', 'wl', 'hth') + expect(sheet.layerFailures.length).toBe(1) + expect(sheet.layerFailures[0]?.reason).toBe('decode') + expect(sheet.layerFailures[0]?.member).toBe(dccName) + expect(error).toHaveBeenCalledTimes(1) + } finally { + error.mockRestore() + } + }) +}) + +describe('act scene monster art diagnostics', () => { + it('names the monster ids that will draw as red boxes', () => { + expect(missingMonsterArtIds(['Fallen1', 'zombie1'], new Set(['fallen1']))) + .toEqual(['zombie1']) + expect(missingMonsterArtIds(['fallen1', 'fallen1'], new Set())) + .toEqual(['fallen1']) + expect(missingMonsterArtIds(['fallen1'], new Set(['fallen1']))).toEqual([]) + }) + + it('says nothing in the HUD when the load was clean', () => { + expect(monsterArtStatusText({ missing: [], errors: 0, layerFailures: 0 })).toBe('') + }) + + it('shows missing art, load errors and dropped layers in the HUD', () => { + const text = monsterArtStatusText({ + missing: ['fallen1', 'zombie1', 'skeleton1', 'quillrat1'], + errors: 2, + layerFailures: 5, + }) + expect(text).toContain('怪物美术缺失 4') + expect(text).toContain('fallen1、zombie1、skeleton1…+1') + expect(text).toContain('加载错误 2') + expect(text).toContain('缺层 5') + }) +}) From 5956b00e72ec760ffec83a82e03e95017d9ee7ac Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:45:46 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(scene):=20=E5=88=A0=E9=99=A4=20DEMO=5FM?= =?UTF-8?q?ONSTERS=20=E5=81=87=E5=88=B7=E6=80=AA=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关卡怪物计划为空时,act 场景会塞进 12 个手写的 DEMO_MONSTERS, 并且判断是否为城镇靠的是显示名里有没有英文子串 "town"。 于是:换个名字或换种语言的城镇会凭空冒出 12 只怪;真正的数据 失败(monsters 表读不出来、计划为空)也会伪装成"有内容"。 更糟的是 demo-* id 不在任何美术表里,画出来是红方块,和美术 加载失败的表现一模一样。 改为: - 删掉 demo-data.ts 里的 DEMO_MONSTERS 与 act-scene.ts 里的 PLACEHOLDER_MONSTER_COUNT、monsterStats/monsterCount 字段, 以及切关时 `next.monsterCount > 0` 的兜底 spawnMonsters 调用; 引擎选项显式传 stats: [] / monsterCount: 0。 - 新增 ActSceneState.monstersPlanned 与 monsterPlanStatusText(), 开机与切关后各调用一次 reportMonsterPlan(),HUD 上如实显示 "本关未规划到怪物",而不是拿假数据盖住。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/demo-data.ts | 11 ++-- src/scene/act-scene.ts | 96 +++++++++++++++------------- tests/act-scene-monster-plan.test.ts | 46 +++++++++++++ 3 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 tests/act-scene-monster-plan.test.ts diff --git a/src/game/demo-data.ts b/src/game/demo-data.ts index 3badba2..d5bac70 100644 --- a/src/game/demo-data.ts +++ b/src/game/demo-data.ts @@ -1,13 +1,12 @@ -import type { MonsterStats } from './combat' import type { ItemBase, Affix } from './items' import type { SkillDef } from './skills' import type { NpcDef, QuestDef } from './quests' -export const DEMO_MONSTERS: readonly MonsterStats[] = [ - { id: 'demo-fallen', name: 'Fallen', hp: 12, damage: 3, cooldownTicks: 24, reach: 36, aggroRadius: 220, speed: 80, xp: 8 }, - { id: 'demo-zombie', name: 'Zombie', hp: 30, damage: 6, cooldownTicks: 32, reach: 40, aggroRadius: 170, speed: 46, xp: 15 }, - { id: 'demo-skeleton', name: 'Skeleton', hp: 18, damage: 4, cooldownTicks: 28, reach: 38, aggroRadius: 260, speed: 70, xp: 12 }, -] +// `DEMO_MONSTERS` used to live here: three hand-written monsters the act scene +// dropped into any level whose plan came back empty. They stood in for missing +// data, carried invented stats, and — having no `MonStats.txt` ids — rendered as +// red boxes, so a data failure looked like content. A level that plans nothing +// is now empty and says so (issue #58 item 5). /** Fallback experience thresholds, index 1 = level 1. */ export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280] diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 30b8922..1768c82 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -35,7 +35,7 @@ import type { CollisionGrid, IsoDraw } from '../game/d2map.ts' import { updateAnimatableTiles } from '../game/animated-tiles.ts' import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts' import { createIsoTerrain } from '../game/iso-terrain.ts' -import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts' +import { spawnMonsterPacks } from '../game/combat.ts' import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts' import { MonsterStreamingManager } from '../game/monster-streaming.ts' import type { StreamingRoomDef } from '../game/monster-streaming.ts' @@ -44,7 +44,7 @@ import type { MapRoom } from '../game/monster-rooms.ts' import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" -import { DEMO_MONSTERS, DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" +import { DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import type { NpcDef } from '../game/quests.ts' import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts' @@ -103,15 +103,6 @@ const PAGE_CONCURRENCY = 3 /** Placeholder marker size, in scene pixels. */ const MARKER_WIDTH = 16 const MARKER_HEIGHT = 26 -/** - * Monsters placed per level, until `Levels.txt` supplies the real number. - * - * `MonDen` (density) and `NumMon` (how many kinds) are both real columns that - * #22 will read; 12 is only large enough that a walk across a level runs into - * something, which is what M6 needs to demonstrate that the level → spawn path - * works at all. - */ -const PLACEHOLDER_MONSTER_COUNT = 12 /** * Side of a streaming room, in cells. * @@ -249,6 +240,15 @@ export interface ActSceneState { monsterArtErrors: number /** COF layers dropped inside art that did load: monsters missing body parts. */ monsterArtLayerFailures: number + /** + * Monsters the level's own plan produced. + * + * Zero is a real answer — a town has none — and it is now the *only* answer + * for a level nothing planned for. The scene used to drop 12 hand-written + * `DEMO_MONSTERS` into that case, which also rendered as red boxes because + * their ids are in no art table. + */ + monstersPlanned: number } declare global { @@ -268,7 +268,7 @@ const state: ActSceneState = { drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, - missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, + missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0, } if (typeof window !== 'undefined') window.__d2webAct = state @@ -344,25 +344,14 @@ interface MapRuntime { * `MonStats.txt`. * * Empty when the level has none — a town — and empty for a source that has - * not planned one, which is what {@link ActRuntime.monsterStats} below is - * still there for. + * not planned one. There is no fallback: a level nothing planned for stays + * empty and says so, rather than being filled with invented monsters. */ readonly monsterPacks: readonly MonsterPack[] /** * The level's monster types, by `MonStats.txt` id, for the status line. */ readonly monsterTypes: readonly string[] - /** - * Fallback monster definitions, used only when the level produced no packs. - * - * A level with no `Levels.txt` row — a hand-built fixture, or a pack baked - * before the monster columns were read — would otherwise be empty. Spawning a - * few placeholders there keeps the page useful to poke at, and the status - * line says which of the two happened. - */ - readonly monsterStats: readonly MonsterStats[] - /** How many of {@link ActRuntime.monsterStats} to place in that fallback. */ - readonly monsterCount: number readonly objects: number readonly frames: number /** Whether the real character art is in use. */ @@ -772,8 +761,6 @@ async function buildPackRuntime( npcs: scene.npcs ?? [], monsterPacks: scene.monsters?.packs ?? [], monsterTypes: scene.monsters?.types ?? [], - monsterStats: DEMO_MONSTERS, - monsterCount: (entry.slug.includes('town') || scene.levelName.toLowerCase().includes('town') || (scene.monsters?.packs && scene.monsters.packs.length > 0) || scene.monsters !== undefined) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: scene.objects.length, frames: scene.frames.length, notes: [ @@ -991,8 +978,6 @@ async function loadLiveRuntime( npcs: [], monsterPacks: population.packs, monsterTypes: population.types, - monsterStats: DEMO_MONSTERS, - monsterCount: (town.levelName.toLowerCase().includes('town') || population.packs.length > 0 || population.budget === 0) ? 0 : PLACEHOLDER_MONSTER_COUNT, objects: level.objects.length, frames: scene.frames.length, notes, @@ -1213,6 +1198,29 @@ export function monsterArtStatusText(load: { return parts.length === 0 ? '' : ` · ${parts.join(' · ')}` } +/** + * The HUD fragment describing a level's planned population. + * + * @param planned - how many monsters the plan produced. + * @returns the fragment, ready to append to the HUD line. + */ +export function monsterPlanStatusText(planned: number): string { + return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}` +} + +/** + * Publish a level's planned population into {@link ActSceneState}. + * + * @param runtime - the level being entered. + */ +function reportMonsterPlan(runtime: MapRuntime): void { + const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0) + state.monstersPlanned = planned + if (planned === 0) { + console.info(`level ${runtime.level}: no monsters planned; the level stays empty`) + } +} + /** * Publish a load's failures into {@link ActSceneState}. * @@ -1685,7 +1693,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: }), { spawn: runtime.spawn, - stats: runtime.monsterStats, + // The scene plans its own population; there is no synthetic roster to fall + // back on, so the engine's frame-0 spawner has nothing to draw from. + stats: [], xpTable: DEMO_EXPERIENCE, itemBases: DEMO_BASES, prefixAffixes: DEMO_AFFIXES, @@ -1711,10 +1721,10 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: pickupRadius: 64, inventoryCols: 10, inventoryRows: 4, - // `monsterCount` / `monsterPacks` are the fallback for levels the baker - // gave no pack plan: with rooms present the engine skips frame-0 spawning - // entirely and lets the streaming manager do it room by room. - monsterCount: runtime.monsterCount, + // With rooms present the engine skips frame-0 spawning entirely and lets + // the streaming manager do it room by room; with none it places the + // planned packs at once. A level that planned nothing gets nothing. + monsterCount: 0, monsterPacks: resolvedPacks, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, roomPacks: streamingRooms, @@ -1874,24 +1884,22 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: }, ) } else { - // Levels the baker gave no pack plan keep the old behaviour: there is - // nothing to stream, so the handful of placeholders go down at once. + // Nothing to stream: the planned packs go down at once. A level the baker + // gave no plan for stays empty — and the status line says so, rather than + // 12 invented monsters standing in for the data that is missing. engine.streamingManager = undefined if (nextResolvedPacks.length > 0) { spawnMonsterPacks(engine.world, nextResolvedPacks, landing, spread, engine.terrain, nextSafeZones) - } else if (next.monsterCount > 0) { - spawnMonsters(engine.world, next.monsterStats, next.monsterCount, landing, spread, engine.terrain) } } + reportMonsterPlan(next) if (next.palette !== null) { renderer.setPalette(next.palette.rgb) const paletteForEntities = next.palette const packEntityBase = next.source === 'pack' ? next.base : undefined const packEntityAct = next.source === 'pack' ? next.act : undefined - const activeMonsterIds = next.monsterTypes.length > 0 - ? next.monsterTypes - : (next.monsterCount > 0 ? next.monsterStats.map(s => s.id) : []) + const activeMonsterIds = next.monsterTypes if (activeMonsterIds.length > 0) { void loadMonsterArtMap( DEFAULT_BASES, @@ -2396,6 +2404,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') // A level whose monsters are all red boxes used to look exactly like a // healthy one here. + + monsterPlanStatusText(state.monstersPlanned) + monsterArtStatusText({ missing: state.missingMonsterArt, errors: state.monsterArtErrors, @@ -2405,6 +2414,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: }, }) loop.start() + reportMonsterPlan(runtime) state.ready = true state.loadMs = Math.round(performance.now() - started) // Only the live path fills the selectors here: the pack path already built them from @@ -2430,9 +2440,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined // Collect monster types to load - const activeMonsterIds = runtime.monsterTypes.length > 0 - ? runtime.monsterTypes - : (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : []) + const activeMonsterIds = runtime.monsterTypes if (activeMonsterIds.length > 0) { void loadMonsterArtMap( diff --git a/tests/act-scene-monster-plan.test.ts b/tests/act-scene-monster-plan.test.ts new file mode 100644 index 0000000..f0a9010 --- /dev/null +++ b/tests/act-scene-monster-plan.test.ts @@ -0,0 +1,46 @@ +/** + * The act scene must not invent monsters (issue #58 item 5). + * + * `DEMO_MONSTERS` was three hand-written monsters the scene spawned 12 of + * whenever a level's plan came back empty — including in a town whose name did + * not happen to contain the English substring "town". They also rendered as red + * boxes, because `demo-*` ids are in no art table, so a data failure looked like + * content twice over. + */ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { monsterPlanStatusText } from '../src/scene/act-scene.ts' +import * as demoData from '../src/game/demo-data.ts' + +const ACT_SCENE_SOURCE = readFileSync(new URL('../src/scene/act-scene.ts', import.meta.url), 'utf8') + +/** + * Comments are stripped before scanning: the docstrings deliberately still name + * the deleted fallback to explain why it is gone, and that is not a code path. + */ +const ACT_SCENE_CODE = ACT_SCENE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') + +describe('no synthetic monster fallback', () => { + it('demo-data no longer exports a monster roster', () => { + expect(Object.keys(demoData)).not.toContain('DEMO_MONSTERS') + // The other demo tables are still used by the scene's engine options. + expect(Object.keys(demoData)).toContain('DEMO_SKILLS') + }) + + it('the act scene has no placeholder spawn path left', () => { + expect(ACT_SCENE_CODE).not.toContain('DEMO_MONSTERS') + expect(ACT_SCENE_CODE).not.toContain('PLACEHOLDER_MONSTER_COUNT') + // The spawn gate was an English substring match on the *display* name, so a + // renamed or translated town spawned 12 fake monsters in the square. (The + // pack index's `slug.includes('town')` level lookup is a different thing + // and is still there.) + expect(ACT_SCENE_CODE).not.toContain("toLowerCase().includes('town')") + expect(ACT_SCENE_CODE).not.toContain('spawnMonsters(') + }) + + it('reports an empty plan rather than hiding it', () => { + expect(monsterPlanStatusText(0)).toContain('本关未规划到怪物') + expect(monsterPlanStatusText(0)).toContain('怪物 0') + expect(monsterPlanStatusText(37)).toBe(' · 怪物 37') + }) +}) From ae2672dd9e40c0aca2847db9a287b8b7cc1d0dd8 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 11:55:55 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(wilderness):=20=E8=AE=A9=E9=81=A5?= =?UTF-8?q?=E6=B5=8B=E6=95=B0=E6=8D=AE=E5=8F=8D=E6=98=A0=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #58 第 6 条列出的 4 处伪造遥测(第 5 处 unimplementedPasses 已在 5b3a9db 修复)。这些数字是 verify:generators 与 HUD 判断 "这一关是否生成成功"的唯一依据,而它们是字面常量或恒等式: - wilderness-jungles.ts: junglePathCells 恒为 100、riverBridges 恒 至少为 2。改为扫描画布实测:新增 measureJungleTopography(),按 宏格网把已绘制的地板分成"丛林地面"与"河道水面"(桥面板属于桥, 不计入两者);riverBridges = 实际摆放的桥 + 真正画出来的定制 跨河桥(画布太短时第二条桥画不出来,就不再算数)。并补一条 notes 说明实测值。 - wilderness-acts.ts: 第四幕 104/106 的"玄武岩高原"分支只递增 plateauCells 却一格都不画。改为不再把踏勘当成果,计为候选点并 在 notes 里明说该 pass 未实现。 - wilderness.ts:5049: 特殊预置返回路径恒报 subTheme: 0,改为与主 路径一致的 clamp(floor(request.subTheme), 0, 4)。 - wilderness.ts applySubstitutions: BordType 0 的"整关仅一个"预算 从 Infinity 起算,min(1, Infinity) 恒为 1、减法也永远减不动, 实际退化成"每行一个"。预算改为从 1 起算,上限真正生效。 新增 tests/wilderness-telemetry.test.ts(8 个用例)。在改动前 其中 6 个失败,改动后全部通过。 TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/wilderness-acts.ts | 15 +- src/game/wilderness-jungles.ts | 63 ++++++- src/game/wilderness.ts | 18 +- tests/wilderness-telemetry.test.ts | 278 +++++++++++++++++++++++++++++ 4 files changed, 365 insertions(+), 9 deletions(-) create mode 100644 tests/wilderness-telemetry.test.ts diff --git a/src/game/wilderness-acts.ts b/src/game/wilderness-acts.ts index d610a7e..54b053b 100644 --- a/src/game/wilderness-acts.ts +++ b/src/game/wilderness-acts.ts @@ -766,14 +766,25 @@ export function initAct4OutdoorLevel( } } } else { - // Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus + // Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus. + // + // Not written yet. This loop walks a 4-cell stride grid looking for room, + // and it used to add every free sample to `plateauCells` without painting + // anything — so the stats claimed hundreds of plateau cells on a map that + // had none. Count the sites as candidates and say so, rather than counting + // the survey as work done. + let candidateSites = 0 for (let y = 12; y < height - 12; y += 4) { for (let x = 12; x < width - 12; x += 4) { if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) { - plateauCells += 1 + candidateSites += 1 } } } + stats.notes.push( + `Act 4 basalt plateaus not implemented for level ${String(request.levelId)}: ` + + `${String(candidateSites)} candidate sites left unpainted`, + ) } stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells } diff --git a/src/game/wilderness-jungles.ts b/src/game/wilderness-jungles.ts index 71ce061..571984f 100644 --- a/src/game/wilderness-jungles.ts +++ b/src/game/wilderness-jungles.ts @@ -282,6 +282,50 @@ function synthesizeBridgePlanks( } } +/** + * Measure what the jungle pass actually painted, cell by cell. + * + * The counters used to be literals (`junglePathCells: 100`), so a pass that + * stamped nothing — no clearing pieces in the MPQ, a canvas too small for the + * macro-grid — still reported a healthy-looking jungle. Counting the finished + * canvas means an empty level reports zeros and the gap is visible. + * + * A cell counts as painted when it carries a visible, non-blank floor. Bridge + * planks (style 4, prop1 2) are skipped here: they are decks laid *over* the + * water and are reported as bridges, not as water or as ground. + * + * @param canvas - the level being stamped. + * @param grid - the macro-grid that decided which 32x32 block is river. + * @returns the painted ground and water cell counts. + */ +function measureJungleTopography( + canvas: Canvas, + grid: readonly (readonly MacroGridCell[])[], +): { canalCells: number; junglePathCells: number } { + let canalCells = 0 + let junglePathCells = 0 + for (let y = 0; y < canvas.height; y += 1) { + const row = canvas.cells[y] + if (row === undefined) continue + const gridRow = grid[Math.floor(y / 32)] + if (gridRow === undefined) continue + for (let x = 0; x < canvas.width; x += 1) { + const cell = row[x] + if (cell === undefined) continue + const floor = cell.floors.find( + f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0), + ) + if (floor === undefined) continue + if (floor.style === 4 && floor.prop1 === 2) continue + const gridCell = gridRow[Math.floor(x / 32)] + if (gridCell === undefined) continue + if (gridCell.type === 'river') canalCells += 1 + else junglePathCells += 1 + } + } + return { canalCells, junglePathCells } +} + /** * `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm. * @@ -588,7 +632,9 @@ export function DRLG_GenerateJungles( topology, } // Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography + let canonicalCrossings = 0 for (const by of [78, 142]) { + let plankCells = 0 for (let y = by; y < by + 4; y += 1) { for (let x = 32; x < 60; x += 1) { if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) { @@ -602,18 +648,29 @@ export function DRLG_GenerateJungles( unknown2: 0, hidden: false, } + plankCells += 1 } } } } + // A crossing off the bottom of a short canvas paints nothing, so it is not + // a crossing. Counting the attempt is how `riverBridges` used to claim 2. + if (plankCells > 0) canonicalCrossings += 1 } + const measured = measureJungleTopography(canvas, grid) stats.act3Topography = { + // Causeways are a Kurast feature; the jungle pass carves none, and 0 here + // is a measurement, not a placeholder. causewayCells: 0, - riverBridges: Math.max(2, bridges.length), - canalCells: 0, - junglePathCells: 100, + riverBridges: bridges.length + canonicalCrossings, + canalCells: measured.canalCells, + junglePathCells: measured.junglePathCells, } + stats.notes.push( + `Act 3 Jungle Topography: ${String(bridges.length + canonicalCrossings)} river bridges, ` + + `${String(measured.junglePathCells)} jungle ground cells, ${String(measured.canalCells)} river water cells`, + ) stats.bridgesPlaced = bridges.length stats.riverIslands = islands.length diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 3bf3c08..05e7df1 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -2434,7 +2434,14 @@ function applySubstitutions( dirtPathGrid?: Uint8Array, ): number { let total = 0 - let unlimitedBudget = Number.POSITIVE_INFINITY + /** + * `BordType` 0 rows share one cluster across the whole level. + * + * This started at `Infinity`, so `Math.min(1, budget)` was always 1 and the + * subtraction below never bit: every `BordType` 0 row got its own cluster, + * which is the `BordType` 1 rule. Starting at 1 makes the cap real. + */ + let sharedBudget = 1 for (const row of rows) { const role = classifySubstitutionRole(row.name, row.type) const chance = row.prob[themeIndex] ?? 0 @@ -2442,14 +2449,14 @@ function applySubstitutions( let clusters = 0 if (enabled) { // `BordType` 0 = one cluster for the whole level, 1 = one per row. - const allowance = row.bordType === 0 ? Math.min(1, unlimitedBudget) : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY + const allowance = row.bordType === 0 ? sharedBudget : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY const rowMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) const wanted = Math.min(rowMax, allowance) if (wanted > 0) { const single = applySubstitution(canvas, row, themeIndex, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid) clusters = Math.min(wanted, single) if (row.bordType !== 0 && row.bordType !== 1) clusters = single - if (row.bordType === 0) unlimitedBudget = Math.max(0, unlimitedBudget - clusters) + if (row.bordType === 0) sharedBudget = Math.max(0, sharedBudget - clusters) total += clusters } } @@ -5046,7 +5053,10 @@ export function generateWilderness(request: WildernessRequest): WildernessResult blockGrid: { width: gridWidth, height: gridHeight }, dt1Mask: wildernessDt1Mask(request.levelTypeName), subType: request.subType, - subTheme: 0, + // The special-preset path clamps `SubTheme` exactly like the main path + // does; it used to report a flat 0, so every special level looked like + // theme 0 in the verification reports. + subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))), groundTile: primaryGroundTile, groundTiles: groundTiles ?? undefined, groundCells: stats.groundCells, diff --git a/tests/wilderness-telemetry.test.ts b/tests/wilderness-telemetry.test.ts new file mode 100644 index 0000000..1e55c54 --- /dev/null +++ b/tests/wilderness-telemetry.test.ts @@ -0,0 +1,278 @@ +/** + * Wilderness telemetry must be measured, not invented (issue #58 item 6). + * + * Every number in `stats` is read by `npm run verify:generators` and by the + * in-game HUD to decide whether a level generated correctly. Four of them were + * literals or no-op arithmetic, so a level that produced nothing still reported + * a plausible-looking map: + * + * - `wilderness-jungles.ts` reported `junglePathCells: 100` and at least 2 + * `riverBridges`, whatever was painted. + * - `wilderness-acts.ts` counted Act 4 plateau cells in a loop that painted + * nothing for levels 104 and 106. + * - `wilderness.ts` reported `subTheme: 0` on the special-preset return path. + * - `applySubstitutions` capped `BordType` 0 rows with `Math.min(1, Infinity)`, + * a budget that never constrained anything. + */ +import { describe, expect, test } from 'vitest' +import { createCanvas, generateWilderness, stampDs1 } from '../src/game/wilderness.ts' +import type { + SubstitutionReport, + WildernessPiece, + WildernessStats, + WildernessSubstitution, +} from '../src/game/wilderness.ts' +import type { Act3TopographyStats, Act4TopographyStats } from '../src/game/wilderness-acts.ts' +import { DRLG_GenerateJungles } from '../src/game/wilderness-jungles.ts' +import { Rng } from '../src/game/rng.ts' +import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' + +/** A fresh, empty report collector. */ +function makeStats(): WildernessStats { + return { + substitutions: [], + borderPieces: {}, + unresolved: [], + notes: [], + borderStamped: 0, + groundCells: 0, + groundTile: null, + sizeSource: 'test', + roadCells: 0, + roadSegments: 0, + anchors: 0, + specialPresets: [], + entrances: [], + } +} + +/** A solid block of floor, so stamped pieces are measurable. */ +function makeMockDs1(width: number, height: number): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + row.push({ + walls: [], + floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 3, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } +} + +function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { + const borderDs1 = makeMockDs1(8, 8) + return Array.from({ length: 8 }, (_unused, i) => ({ + name: `${actPrefix} - Border ${String(i + 1)}`, + border: true, + levels: [borderDs1], + })) +} + +describe('Act 3 jungle topography is counted, not assumed', () => { + test('a jungle with no presets reports zero ground and zero water', () => { + const canvas = createCanvas(64, 192, 1, 1, 1) + const stats = makeStats() + DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1) + + const topo = stats.act3Topography as Act3TopographyStats + // Nothing was stamped, so there is no jungle ground and no river water. + // This used to report 100 path cells regardless. + expect(topo.junglePathCells).toBe(0) + expect(topo.canalCells).toBe(0) + // The bridges that exist are the plank crossings the pass paints itself: + // the island/main-channel bridges plus both canonical crossings. + expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 2) + // Causeways belong to Kurast, and the jungle pass paints none. + expect(topo.causewayCells).toBe(0) + }) + + test('a canvas too short for the second crossing does not count it', () => { + const canvas = createCanvas(64, 96, 1, 1, 1) + const stats = makeStats() + DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1) + + const topo = stats.act3Topography as Act3TopographyStats + // The crossing at y = 142 falls off a 96-cell canvas and paints nothing. + // `Math.max(2, bridges.length)` used to report it anyway. + expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 1) + }) + + test('ground and water counts add up to what is on the canvas', () => { + const canvas = createCanvas(64, 192, 1, 1, 1) + const stats = makeStats() + const pieces: WildernessPiece[] = [ + { name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] }, + { name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] }, + { name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] }, + { name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] }, + ] + DRLG_GenerateJungles(canvas, 76, pieces, new Rng(76001), stats, stampDs1) + + let painted = 0 + for (let y = 0; y < canvas.height; y += 1) { + for (let x = 0; x < canvas.width; x += 1) { + const cell = canvas.cells[y]![x]! + const floor = cell.floors.find( + f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0), + ) + if (floor === undefined) continue + // Bridge planks are decks over the water, reported as bridges. + if (floor.style === 4 && floor.prop1 === 2) continue + painted += 1 + } + } + + const topo = stats.act3Topography as Act3TopographyStats + expect(painted).toBeGreaterThan(0) + expect(topo.junglePathCells + topo.canalCells).toBe(painted) + expect(topo.canalCells).toBeGreaterThan(0) + }) +}) + +describe('Act 4 plateaus are only counted where they are painted', () => { + test('Outer Steppes (104) reports no plateau cells and names the gap', () => { + const result = generateWilderness({ + levelId: 104, + levelName: 'Outer Steppes', + levelTypeName: 'Act 4 - Mesa', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 104001, + pieces: makeMockBorderPieces('Act 4'), + substitutions: [], + }) + + const topo = result.stats.act4Topography as Act4TopographyStats + // The 104/106 branch surveys a stride grid and paints nothing; it used to + // count every sample as a plateau cell. + expect(topo.plateauCells).toBe(0) + const notes = result.stats.notes as readonly string[] + expect(notes.some(note => note.includes('Act 4 basalt plateaus not implemented'))).toBe(true) + }) + + test('Plains of Despair (105) still counts the prison plateau it paints', () => { + const result = generateWilderness({ + levelId: 105, + levelName: 'Plains of Despair', + levelTypeName: 'Act 4 - Mesa', + sizeX: 80, + sizeY: 80, + subType: 0, + subTheme: 0, + seed: 105001, + pieces: makeMockBorderPieces('Act 4'), + substitutions: [], + }) + + const topo = result.stats.act4Topography as Act4TopographyStats + expect(topo.plateauCells).toBeGreaterThan(20) + }) +}) + +describe('the special-preset path reports the real SubTheme', () => { + test('a jungle level keeps the SubTheme it was asked for', () => { + const pieces: WildernessPiece[] = [ + { name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] }, + { name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] }, + { name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] }, + { name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] }, + ] + const result = generateWilderness({ + levelId: 76, + levelName: 'Spider Forest', + levelTypeName: 'Act 3 - Jungle', + sizeX: 64, + sizeY: 192, + subType: 0, + subTheme: 3, + seed: 76001, + pieces, + substitutions: [], + }) + + // This return path hardcoded 0, so every special level looked like theme 0. + expect(result.stats.subTheme).toBe(3) + }) + + test('an out-of-range SubTheme is clamped, like the main path clamps it', () => { + const pieces: WildernessPiece[] = [ + { name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] }, + { name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] }, + ] + const result = generateWilderness({ + levelId: 76, + levelName: 'Spider Forest', + levelTypeName: 'Act 3 - Jungle', + sizeX: 64, + sizeY: 192, + subType: 0, + subTheme: 9, + seed: 76002, + pieces, + substitutions: [], + }) + + expect(result.stats.subTheme).toBe(4) + }) +}) + +describe('BordType 0 really is one cluster for the whole level', () => { + /** Two clutter rows that both want a whole-level cluster. */ + function makeBordType0Rows(): WildernessSubstitution[] { + return ['Trees', 'Rocks'].map(name => ({ + name, + type: 1, + gridSize: 1, + bordType: 0, + dt1Mask: 0, + prob: [100, 100, 100, 100, 100], + trials: [-1, -1, -1, -1, -1], + max: [5, 5, 5, 5, 5], + levels: [makeMockDs1(4, 4)], + })) + } + + test('the second BordType 0 row gets nothing left to spend', () => { + const result = generateWilderness({ + levelId: 2, + levelName: 'Cold Plains', + levelTypeName: 'Act 1 - Wilderness', + sizeX: 64, + sizeY: 64, + subType: 1, + subTheme: 0, + seed: 2001, + pieces: makeMockBorderPieces('Act 1'), + substitutions: makeBordType0Rows(), + }) + + const reported = result.stats.substitutions as readonly SubstitutionReport[] + const rows = reported.filter(row => row.name === 'Trees' || row.name === 'Rocks') + expect(rows).toHaveLength(2) + const spent = rows.reduce((sum, row) => sum + row.clusters, 0) + // `Math.min(1, Infinity)` handed each row its own cluster, so this used to + // be 2: a per-row cap wearing a whole-level cap's name. + expect(spent).toBeLessThanOrEqual(1) + expect(rows[0]!.clusters).toBe(1) + expect(rows[1]!.clusters).toBe(0) + }) +}) +