From 909c50eb48d7f74d42cde08b35f66981a93eb00b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 09:23:12 +0000 Subject: [PATCH] fix(drlg,pack): pack XX minion art, fix L123 var3 waypoint, and pin exact DRLG tile variants (#500, #501, #502) --- AGENTS.md | 6 +- scripts/audit-levels-browser.ts | 18 +++- scripts/pack-animdata.ts | 6 +- scripts/pack-entity-assets.ts | 8 +- scripts/render-drlg-compare.ts | 87 +++++++++++++++---- src/formats/ds1.ts | 12 +++ src/game/d2map.ts | 51 ++++++++++- src/game/drlg/drlg-map.ts | 46 +++++++++- src/game/maze-special-passes.ts | 23 +++-- src/game/monster-mapping.ts | 17 +++- src/scene/act-scene.ts | 4 + .../challenger-m3-entity-concurrency.test.ts | 6 +- .../challenger-m4-determinism-parity.test.ts | 9 +- tests/maze-special-passes.test.ts | 31 +++++++ tests/monster-mapping.test.ts | 17 ++-- tests/packed-entity-art.test.ts | 4 +- tests/superuniques-fidelity.test.ts | 17 ++-- 17 files changed, 289 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d528cde..bd2e71b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,9 @@ - 地图未放置有效地面瓦片的纯黑虚空区域,必须在子网格(Sub-tile)上严格打满 `blocked = 1` 与 `COLLIDE_MASK_INVALID`(包含 `COLLIDE_BLANK = 0x0001` 与 `COLLIDE_WALL = 0x0004`)。 - 玩家出生点搜寻(`findIsoSpawn`)**绝对严禁**直接取地图几何中心点 `(width/2, height/2)`。在非矩形或中空地图(如遗忘之塔外景、皇宫、秘密避难所等),中心点极可能是纯黑虚空。 - 出生点必须严格落在“物理连通、有非隐藏有效地面瓦片且 `blocked === 0`”的最大可行走连通分量上,优先靠近传送门(Warp)或小站(Waypoint)。 -3. **消除平铺条纹:严禁在空间离散中使用简单线性算术**: - - 地块变体选择(`pickVariant`)严禁使用 `((x * A) ^ (y * B)) % count` 这类简单线性同余公式。在二维离散规则网格上,线性乘积会导致明显的 45 度斜向周期性机械几何平铺条纹。 - - 变体散列必须使用高熵伪随机空间哈希(如 SplitMix64 坐标散列),并通过空间方差统计单测确保视觉离散度。 +3. **DRLG 精确瓦片绑定与预设 DS1 消除平铺条纹准则**: + - **DRLG 生成关卡(Exact Tile Pinning)**:凡经由 1.13c DRLG 管线(`DRLGROOMTILE_GetTileCache` -> `D2CMP_10088_SelectTileData`)生成的地图,必须在 `Ds1Floor` / `Ds1Wall` 中直接固定(Pin)DRLG 选出的精确 `dt1Library` 与 `dt1Tile`(含 `type 3` 顶角墙伴随的 `type 4` 左半角 `companionDt1Library` / `companionDt1Tile`),渲染器直接按该索引取帧,严禁二次重抽。 + - **预设 DS1 关卡(SplitMix64 Variant Fallback)**:仅在不携带 `pTile` 绑定的原始预设 `.ds1` 关卡中,地块变体选择(`pickVariant`)使用高熵伪随机空间哈希(SplitMix64 坐标散列),严禁使用 `((x * A) ^ (y * B)) % count` 这类简单线性同余公式,并通过空间方差统计单测确保视觉离散度。 --- diff --git a/scripts/audit-levels-browser.ts b/scripts/audit-levels-browser.ts index 6e09904..768ee6d 100644 --- a/scripts/audit-levels-browser.ts +++ b/scripts/audit-levels-browser.ts @@ -694,10 +694,20 @@ function parseIds(spec: string): Set { function parseArgs(argv: readonly string[]): Map { const known = new Set(['pack', 'out', 'jobs', 'act', 'levels', 'shots', 'timeout', 'chrome']) const args = new Map() - for (const arg of argv) { - const match = /^--([a-z]+)=(.+)$/.exec(arg) - if (match === null || !known.has(match[1]!)) throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`) - args.set(match[1]!, match[2]!) + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]! + const eqMatch = /^--([a-z]+)=(.+)$/.exec(arg) + if (eqMatch !== null && known.has(eqMatch[1]!)) { + args.set(eqMatch[1]!, eqMatch[2]!) + continue + } + const flagMatch = /^--([a-z]+)$/.exec(arg) + if (flagMatch !== null && known.has(flagMatch[1]!) && i + 1 < argv.length) { + args.set(flagMatch[1]!, argv[i + 1]!) + i += 1 + continue + } + throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`) } return args } diff --git a/scripts/pack-animdata.ts b/scripts/pack-animdata.ts index a5928a9..7fd8a2d 100644 --- a/scripts/pack-animdata.ts +++ b/scripts/pack-animdata.ts @@ -279,7 +279,7 @@ function buildTokenJoin( for (const row of monstats.rows) { const token = cell(monstats, row, 'Code').trim().toUpperCase() const ex = cell(monstats, row, 'MonStatsEx').trim() - if (token.length === 0 || token === 'XX') continue + if (token.length === 0) continue if (ex.length === 0 || !rows.has(ex)) continue const ids = join.get(token) ?? [] if (!ids.includes(ex)) ids.push(ex) @@ -301,7 +301,7 @@ function bakedMonsterTokens(): Set { const tokens = new Set() for (let act = 1; act <= 5; act += 1) { for (const spec of ACT_MONSTER_SPECS[act] ?? []) { - if (!spec.token || spec.token.toLowerCase() === 'xx') continue + if (!spec.token) continue tokens.add(spec.token.toUpperCase()) } } @@ -314,7 +314,7 @@ function bakedMonsterTokens(): Set { } for (const id of monsterIds) { const spec = resolveMonsterArtSpec(id) - if (spec && spec.token && spec.token.toLowerCase() !== 'xx') tokens.add(spec.token.toUpperCase()) + if (spec && spec.token) tokens.add(spec.token.toUpperCase()) } return tokens } diff --git a/scripts/pack-entity-assets.ts b/scripts/pack-entity-assets.ts index 991b7dc..0f63949 100644 --- a/scripts/pack-entity-assets.ts +++ b/scripts/pack-entity-assets.ts @@ -735,7 +735,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ for (let act = 1; act <= 5; act += 1) { const specs = ACT_MONSTER_SPECS[act] ?? [] for (const spec of specs) { - if (!spec.token || spec.token.toLowerCase() === 'xx') continue + if (!spec.token) continue const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) @@ -762,7 +762,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ for (const monsterId of allSuperUniqueMonsterIds) { const spec = resolveMonsterArtSpec(monsterId) - if (spec && spec.token && spec.token.toLowerCase() !== 'xx') { + if (spec && spec.token) { const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) @@ -773,7 +773,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ // Include special summon and minion entities (Valkyrie, Decoy/Dopplezon) for (const summonId of ['valkyrie', 'dopplezon']) { const spec = resolveMonsterArtSpec(summonId) - if (spec && spec.token && spec.token.toLowerCase() !== 'xx') { + if (spec && spec.token) { const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) @@ -804,7 +804,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/ for (let act = 1; act <= 5; act += 1) { const specs = ACT_MONSTER_SPECS[act] ?? [] for (const spec of specs) { - if (!spec.token || spec.token.toLowerCase() === 'xx') continue + if (!spec.token) continue const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` const list = monsterActsMap.get(key) ?? [] if (!list.includes(act)) list.push(act) diff --git a/scripts/render-drlg-compare.ts b/scripts/render-drlg-compare.ts index 522ca69..9fc0da2 100644 --- a/scripts/render-drlg-compare.ts +++ b/scripts/render-drlg-compare.ts @@ -82,7 +82,7 @@ function slug(name: string): string { interface Args { readonly copy: number readonly seed: number - readonly levels: readonly number[] + readonly levels?: readonly number[] readonly out: string readonly scale: number } @@ -100,12 +100,13 @@ function parseArgs(argv: readonly string[]): Args { const seedText = values.get('seed') const seed = seedText === undefined ? act1GameSeed(copy) : Number(seedText) >>> 0 if (seedText !== undefined && !/^(0x[0-9a-f]+|\d+)$/i.test(seedText)) throw new Error(`bad --seed ${seedText}`) - const levels = (values.get('levels') ?? '2,3,4').split(',').map(Number) - if (levels.some(id => !Number.isInteger(id) || id <= 0)) throw new Error(`bad --levels ${values.get('levels') ?? ''}`) + const levelsSpec = values.get('levels') + const levels = levelsSpec === undefined ? undefined : levelsSpec.split(',').map(Number) + if (levels !== undefined && levels.some(id => !Number.isInteger(id) || id <= 0)) throw new Error(`bad --levels ${levelsSpec ?? ''}`) const scale = Number(values.get('scale') ?? '4') if (!Number.isInteger(scale) || scale < 1) throw new Error('--scale must be a positive integer') const out = resolve(values.get('out') ?? '/tmp/drlg-compare') - return { copy, seed, levels, out, scale } + return { copy, seed, ...(levels !== undefined ? { levels } : {}), out, scale } } // --------------------------------------------------------------------------------------------- @@ -277,6 +278,54 @@ function blueprintXor(a: Blueprint, b: Blueprint): { differing: number; png: Uin return { differing, png: encodeIndexedPng({ width: a.width, height: a.height, pixels, palette }) } } +/** + * Count how many visible draws in `scene` differ in `(library, tile)` from the exact `pTile` + * pointers chosen by the native D2MOO oracle in `oracleDump`. + */ +function countVariantDiffs(oracleDump: DrlgDump, levelId: number, tables: DrlgMpqData['tables'], map: DrlgLevelMap, scene: IsoMapScene): number { + const input = drlgLevelInputFromDump(oracleDump, levelId, tables) + const cellsX = input.coord.width + 1 + const cellsY = input.coord.height + 1 + const seen = new Set() + const expectedByCellKind = new Map() + for (const t of input.tiles) { + if (seen.has(t.key)) continue + seen.add(t.key) + if (t.x < 0 || t.y < 0 || t.x >= cellsX || t.y >= cellsY) continue + if (t.tile === null) continue + // buildIsoMapScene skips hidden floors and walls (MAPTILE_HIDDEN = 0x8), while shadows are drawn whenever prop1 !== 0 + if ((t.kind === 'floor' || t.kind === 'wall') && (t.flags & 0x0000_0008) !== 0) continue + const key = `${String(t.x)},${String(t.y)},${t.kind}` + const list = expectedByCellKind.get(key) ?? [] + list.push(`${t.tile.library}#${String(t.tile.index)}`) + expectedByCellKind.set(key, list) + } + + const actualByCellKind = new Map() + const recordDraw = (draw: IsoDraw, kind: 'floor' | 'shadow' | 'wall'): void => { + const key = `${String(draw.cellX)},${String(draw.cellY)},${kind}` + const list = actualByCellKind.get(key) ?? [] + list.push(`${map.dt1Names[draw.library] ?? '?'}#${String(draw.tile)}`) + actualByCellKind.set(key, list) + } + for (const d of scene.floors) recordDraw(d, 'floor') + for (const d of scene.shadows) recordDraw(d, 'shadow') + for (const d of scene.walls) recordDraw(d, 'wall') + for (const d of scene.roofs) recordDraw(d, 'wall') + + const allKeys = new Set([...expectedByCellKind.keys(), ...actualByCellKind.keys()]) + let diffs = 0 + for (const key of allKeys) { + const exp = (expectedByCellKind.get(key) ?? []).slice().sort() + const act = (actualByCellKind.get(key) ?? []).slice().sort() + const maxLen = Math.max(exp.length, act.length) + for (let i = 0; i < maxLen; i += 1) { + if (exp[i] !== act[i]) diffs += 1 + } + } + return diffs +} + // --------------------------------------------------------------------------------------------- // Driver @@ -307,12 +356,13 @@ async function main(): Promise { } // The bake generates the whole act's outdoor list in one run; so do both sides here. const actLevels = act1OutdoorLevelIds(data.tables) - for (const levelId of args.levels) { + const targetLevels = args.levels ?? actLevels + for (const levelId of targetLevels) { if (!actLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an Act I outdoor level (${actLevels.join(', ')})`) } const seedHex = `0x${args.seed.toString(16).padStart(8, '0')}` - console.log(`game seed ${seedHex} (act copy ${String(args.copy)}), levels ${args.levels.join(', ')} of [${actLevels.join(', ')}]`) + console.log(`game seed ${seedHex} (act copy ${String(args.copy)}), levels ${targetLevels.join(', ')} of [${actLevels.join(', ')}]`) const oracle: DrlgDump = runOracle({ seed: args.seed, levels: actLevels, difficulty: 0, isolated: false }).doc delete oracle.oracle const port: DrlgDump = dumpAct1(createDrlgEnv(data.source, data.tables), args.seed, actLevels, { difficulty: 0, isolated: false }) @@ -328,20 +378,24 @@ async function main(): Promise { } return dt1 } - const side = (dump: DrlgDump, levelId: number): Side => { + const side = (dump: DrlgDump, levelId: number, fallbackSeed: number): Side => { const input = drlgLevelInputFromDump(dump, levelId, data.tables) const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds }) - const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), bakeSceneSeed(levelId, args.copy)) + const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), fallbackSeed) return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) } } const summary: Record[] = [] const ambiguityTotal = { refs: 0, variantGroups: 0, crossLibrary: 0, crossLibraryLevelWide: 0 } + let totalVariantDiffs = 0 let failed = dumpDiff !== null - for (const levelId of args.levels) { + for (const levelId of targetLevels) { const name = slug(levelName(levelId)) - const d2moo = side(oracle, levelId) - const ours = side(port, levelId) + // Pass distinct fallback seeds (0 vs bakeSceneSeed) so any unpinned tile would diverge between d2moo and ours + const d2moo = side(oracle, levelId, 0) + const ours = side(port, levelId, bakeSceneSeed(levelId, args.copy)) + const variantDiffs = countVariantDiffs(oracle, levelId, data.tables, ours.map, ours.scene) + totalVariantDiffs += variantDiffs const iso = isoXor(d2moo.iso, ours.iso, args.scale) const bp = blueprintXor(d2moo.blueprint, ours.blueprint) const ambiguity: DrlgDt1Ambiguity = auditDrlgDt1Ambiguity(ours.map, ours.map.dt1Names.map(library)) @@ -369,21 +423,22 @@ async function main(): Promise { entrances: ours.map.entrances.length, landmarks: ours.map.landmarks.length, isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`, + variantDiffs, isoDiffering: iso.differing, blueprintDiffering: bp.differing, dt1Ambiguity: ambiguity, } summary.push(row) console.log(`L${String(levelId)} ${name}: canvas ${row.canvas}, ${String(s.walls)} walls / ${String(s.floors)} floors / ${String(s.shadows)} shadows, ` - + `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`) + + `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; variantDiffs ${String(variantDiffs)}; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`) console.log(` DT1 ambiguity: ${String(ambiguity.libraries)} libraries, ${String(ambiguity.refs)} refs, ${String(ambiguity.variantGroups)} with variants, ` + `${String(ambiguity.crossLibrary)} cross-library under cell masks (${String(ambiguity.crossLibraryLevelWide)} with one level-wide pool)`) - if (iso.differing !== 0 || bp.differing !== 0) failed = true + if (variantDiffs !== 0 || iso.differing !== 0 || bp.differing !== 0) failed = true } console.log(`DT1 ambiguity total: ${String(ambiguityTotal.refs)} refs, ${String(ambiguityTotal.variantGroups)} with variants, ` - + `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool)`) - writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: seedHex, copy: args.copy, dumpsIdentical: dumpDiff === null, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1)) - console.log(failed ? 'FAIL: the D2MOO and port images differ' : `OK: every XOR image is empty; images in ${args.out}`) + + `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool), variantDiffs ${String(totalVariantDiffs)}`) + writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: seedHex, copy: args.copy, dumpsIdentical: dumpDiff === null, variantDiffs: totalVariantDiffs, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1)) + console.log(failed ? 'FAIL: the D2MOO and port images or tile variants differ' : `OK: 0 variant diffs and every XOR image is empty; images in ${args.out}`) if (failed) process.exitCode = 1 } diff --git a/src/formats/ds1.ts b/src/formats/ds1.ts index 9f84b9f..d5ac550 100644 --- a/src/formats/ds1.ts +++ b/src/formats/ds1.ts @@ -58,6 +58,14 @@ export interface Ds1Wall { readonly unknown2: number /** The cell is hidden. */ readonly hidden: boolean + /** Exact DT1 library index chosen by DRLG (bypasses `pickVariant` when set). */ + readonly dt1Library?: number + /** Exact DT1 tile index chosen by DRLG (bypasses `pickVariant` when set). */ + readonly dt1Tile?: number + /** Exact DT1 library index for a top-corner-left (type 4) companion of a type-3 wall. */ + readonly companionDt1Library?: number + /** Exact DT1 tile index for a top-corner-left (type 4) companion of a type-3 wall. */ + readonly companionDt1Tile?: number } /** One floor or shadow cell. */ @@ -74,6 +82,10 @@ export interface Ds1Floor { readonly unknown2: number /** The cell is hidden. */ readonly hidden: boolean + /** Exact DT1 library index chosen by DRLG (bypasses `pickVariant` when set). */ + readonly dt1Library?: number + /** Exact DT1 tile index chosen by DRLG (bypasses `pickVariant` when set). */ + readonly dt1Tile?: number } /** One substitution cell (raw value; its meaning is map-specific). */ diff --git a/src/game/d2map.ts b/src/game/d2map.ts index 9027b5e..4469932 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -508,6 +508,41 @@ function filterByDt1Mask( return filtered.length > 0 ? filtered : candidates } +/** + * Resolve a DRLG-pinned DT1 library and tile index directly, bypassing `pickVariant`. + */ +function resolvePinnedTile( + pool: TilePool, + libraries: readonly Dt1[], + dt1Library: number | undefined, + dt1Tile: number | undefined, + style: number, + sequence: number, + type: number, +): ResolvedCandidates | undefined { + if (dt1Library === undefined || dt1Tile === undefined) return undefined + const rawTile = libraries[dt1Library]?.tiles[dt1Tile] + if (rawTile === undefined) return undefined + const animated = isAnimatedTile(rawTile) + const tile: PoolTile = { + library: dt1Library, + tile: dt1Tile, + weight: rawTile.rarityFrameIndex, + animated, + frameIndex: animated ? Math.max(0, rawTile.rarityFrameIndex) : 0, + } + if (!animated) return { tile } + const candidates = pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`) + const animatedTiles = candidates + ?.filter(c => c.animated && c.library === dt1Library) + .slice() + .sort((a, b) => a.frameIndex - b.frameIndex || a.tile - b.tile) + return { + tile, + ...(animatedTiles && animatedTiles.length > 1 ? { animatedTiles } : {}), + } +} + /** * Resolve a DS1 reference against the pool. * @@ -982,7 +1017,10 @@ export function buildIsoMapScene( // shadows, trees and roofs — so a weighted-random draw could put a dark wall // tile in a floor slot, drawn with the floor's offset (no `minBlockY + 80`), // which is the "misplaced black block on the ground" defect. - const picked = resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask) + const pinned = resolvePinnedTile(pool, libraries, floor.dt1Library, floor.dt1Tile, floor.style, floor.sequence, FLOOR_TILE_TYPE) + const picked = pinned !== undefined + ? { tile: pinned.tile, animatedTiles: pinned.animatedTiles, viaLoose: false } + : resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask) if (picked.viaLoose) looseRefs += 1 const found = picked.tile if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue } @@ -1005,7 +1043,8 @@ export function buildIsoMapScene( // Shadows must come from a **type 13** tile specifically: the loose // fallback ignores the type field and would happily return a wall tile, // stamping its blocking flags as if they were a shadow's. - const picked = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask) + const picked = resolvePinnedTile(pool, libraries, shadow.dt1Library, shadow.dt1Tile, shadow.style, shadow.sequence, SHADOW_TILE_TYPE) + ?? resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask) const found = picked.tile if (found === undefined) continue const tile = libraries[found.library]!.tiles[found.tile]! @@ -1035,7 +1074,10 @@ export function buildIsoMapScene( for (const wall of cell.walls) { if (wall.hidden || wall.prop1 === 0) continue - const pickedWall = resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed, cell.dt1Mask) + const pinnedWall = resolvePinnedTile(pool, libraries, wall.dt1Library, wall.dt1Tile, wall.style, wall.sequence, wall.type) + const pickedWall = pinnedWall !== undefined + ? { tile: pinnedWall.tile, animatedTiles: pinnedWall.animatedTiles, viaLoose: false } + : resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed, cell.dt1Mask) if (pickedWall.viaLoose) looseRefs += 1 const found = pickedWall.tile if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue } @@ -1067,7 +1109,8 @@ export function buildIsoMapScene( // engine's DS1s never list the left half themselves, so this is the only way // the corner gets both faces — 411 of them across the 35 preset levels. if (tile.type === TOP_CORNER_RIGHT_TYPE) { - const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed, cell.dt1Mask) + const companionPicked = resolvePinnedTile(pool, libraries, wall.companionDt1Library, wall.companionDt1Tile, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE) + ?? resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed, cell.dt1Mask) const companion = companionPicked.tile if (companion === undefined) { missingCornerPairs += 1 diff --git a/src/game/drlg/drlg-map.ts b/src/game/drlg/drlg-map.ts index 064d98b..049d9c8 100644 --- a/src/game/drlg/drlg-map.ts +++ b/src/game/drlg/drlg-map.ts @@ -201,6 +201,8 @@ export interface DrlgCellRef { readonly type: number /** Index into {@link DrlgLevelMap.dt1Names}. */ readonly library: number + /** Index into the DT1 library's `tiles` array. */ + readonly tile: number readonly hidden: boolean } @@ -370,6 +372,7 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D if (header.type === TILETYPE_WALL_TOP_CORNER_LEFT) { // D2 stores the left half of a top corner as its own tile data next to the type-3 tile // (DRLGROOMTILE_InitWallTileData); buildIsoMapScene draws that half itself for every type-3 wall. + used.add(t.tile.library) topCornerLefts.push({ t, style: header.style, sequence: header.sequence }) continue } @@ -386,6 +389,13 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D const dt1Names = libraryOrder(ctx.tables, input.levelType, used, levelId) const libIndex = new Map(dt1Names.map((name, i) => [name, i])) + const topCornerLeftByKey = new Map() + for (const c of topCornerLefts) { + const k = `${c.t.x},${c.t.y},${c.style},${c.sequence}` + const list = topCornerLeftByKey.get(k) ?? [] + list.push({ library: libIndex.get(c.t.tile!.library)!, tile: c.t.tile!.index }) + topCornerLeftByKey.set(k, list) + } const refs: DrlgCellRef[] = [] let order = 0 let walls = 0 @@ -394,21 +404,49 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D for (const p of placed) { const cell = cells[p.t.y * cellsX + p.t.x]! const library = libIndex.get(p.t.tile!.library)! + const tileIndex = p.t.tile!.index cell.mask |= 1 << library const layer = (p.t.flags >>> MAPTILE_LAYER_SHIFT) & MAPTILE_LAYER_MASK // prop1 only marks the slot as used for the renderer; hidden tiles (warp lit tiles, etc.) are kept // hidden exactly as D2 adds them. if (p.t.kind === 'floor') { - cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden } }) + cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden, dt1Library: library, dt1Tile: tileIndex } }) floors += 1 } else if (p.t.kind === 'shadow') { - cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden }) + cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden, dt1Library: library, dt1Tile: tileIndex }) shadows += 1 } else { - cell.walls.push({ layer, order: order++, wall: { prop1: 1, sequence: p.sequence, style: p.style, type: p.type, unknown1: 0, unknown2: 0, hidden: p.hidden } }) + let companionDt1Library: number | undefined + let companionDt1Tile: number | undefined + if (p.type === TILETYPE_WALL_TOP_CORNER_RIGHT) { + const k = `${p.t.x},${p.t.y},${p.style},${p.sequence}` + const companion = topCornerLeftByKey.get(k)?.shift() + if (companion !== undefined) { + companionDt1Library = companion.library + companionDt1Tile = companion.tile + cell.mask |= 1 << companion.library + } + } + cell.walls.push({ + layer, + order: order++, + wall: { + prop1: 1, + sequence: p.sequence, + style: p.style, + type: p.type, + unknown1: 0, + unknown2: 0, + hidden: p.hidden, + dt1Library: library, + dt1Tile: tileIndex, + ...(companionDt1Library !== undefined ? { companionDt1Library } : {}), + ...(companionDt1Tile !== undefined ? { companionDt1Tile } : {}), + }, + }) walls += 1 } - refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, hidden: p.hidden }) + refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, tile: tileIndex, hidden: p.hidden }) } let wallLayers = 1 diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts index 2ec486c..81afc24 100644 --- a/src/game/maze-special-passes.ts +++ b/src/game/maze-special-passes.ts @@ -765,8 +765,13 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void { preferFurthest: true, }) + let wpSide: string | undefined if (levelId === 123) { - const wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)]! + let wpIdx = rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1) + if (ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[wpIdx] === downSide) { + wpIdx = (wpIdx + 1) % ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length + } + wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[wpIdx]! placeNamedPiece(ctx, { kind: 'waypoint', name: `Act 5 - Temple ${wpSide} Waypoint`, @@ -776,13 +781,15 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void { }) } - placeNamedPiece(ctx, { - kind: 'prev', - name: 'Act 5 - Temple SE up', - sides: 'SE', - plainSides: 'SE', - preferFurthest: false, - }) + if (wpSide !== 'SE') { + placeNamedPiece(ctx, { + kind: 'prev', + name: 'Act 5 - Temple SE up', + sides: 'SE', + plainSides: 'SE', + preferFurthest: false, + }) + } } } diff --git a/src/game/monster-mapping.ts b/src/game/monster-mapping.ts index 54b5a6c..a305943 100644 --- a/src/game/monster-mapping.ts +++ b/src/game/monster-mapping.ts @@ -398,6 +398,17 @@ export const MONSTER_ART_MAP: Record = { "mephistospirit": { token: "M6", weapon: "hth" }, "meshif1": { token: "MS", weapon: "hth" }, "meshif2": { token: "M3", weapon: "hth" }, + "minion1": { token: "XX", weapon: "hth" }, + "minion2": { token: "XX", weapon: "hth" }, + "minion3": { token: "XX", weapon: "hth" }, + "minion4": { token: "XX", weapon: "hth" }, + "minion5": { token: "XX", weapon: "hth" }, + "minion6": { token: "XX", weapon: "hth" }, + "minion7": { token: "XX", weapon: "hth" }, + "minion8": { token: "XX", weapon: "hth" }, + "minion9": { token: "XX", weapon: "hth" }, + "minion10": { token: "XX", weapon: "hth" }, + "minion11": { token: "XX", weapon: "hth" }, "minionspawner1": { token: "XA", weapon: "hth" }, "minionspawner2": { token: "XA", weapon: "hth" }, "minionspawner3": { token: "XA", weapon: "hth" }, @@ -742,7 +753,7 @@ export const ACT_MONSTER_SPECS: Record = { 2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }], 3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }], 4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }], - 5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }], + 5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }, { token: "XX", weapon: "hth" }], } /** @@ -751,13 +762,13 @@ export const ACT_MONSTER_SPECS: Record = { export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null { const lower = id.toLowerCase() const direct = MONSTER_ART_MAP[lower] - if (direct && direct.token !== 'XX' && direct.token.trim() !== '') { + if (direct && direct.token.trim() !== '') { return direct } const base = lower.replace(/\d+$/, '') if (base !== lower) { const baseSpec = MONSTER_ART_MAP[base] - if (baseSpec && baseSpec.token !== 'XX' && baseSpec.token.trim() !== '') { + if (baseSpec && baseSpec.token.trim() !== '') { return baseSpec } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 84b909b..6c3d0d2 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -5795,6 +5795,8 @@ interface PackIndexEntry { readonly ds1?: string /** DRLG act variant of a generated level (see act-variants.ts). */ readonly actVariant?: number + /** DRLG parity fidelity tier of this level. */ + readonly fidelity?: 'exact' | 'layout' | 'approximation' } /** Object frame inside an object atlas page. */ @@ -5839,6 +5841,8 @@ interface PackSceneJson { readonly levelId?: number readonly levelName: string readonly ds1: string + /** DRLG parity fidelity tier of this scene. */ + readonly fidelity?: 'exact' | 'layout' | 'approximation' readonly cellsX: number readonly cellsY: number readonly originX: number diff --git a/tests/challenger-m3-entity-concurrency.test.ts b/tests/challenger-m3-entity-concurrency.test.ts index 85d8294..781b4a2 100644 --- a/tests/challenger-m3-entity-concurrency.test.ts +++ b/tests/challenger-m3-entity-concurrency.test.ts @@ -86,15 +86,15 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', () expect(meta.height).toBe(1929) }) - it('verifies all 70 monster R8 atlases and metadata schemas', () => { + it('verifies all 71 monster R8 atlases and metadata schemas', () => { expect(existsSync(indexJsonPath)).toBe(true) const index = JSON.parse(readFileSync(indexJsonPath, 'utf8')) expect(index.schema).toBe(2) expect(index.character).toBe('char-so') expect(Array.isArray(index.entities)).toBe(true) - // 69 + EC (Hell Bovine, Moo Moo Farm) - expect(index.entities.length).toBe(70) + // 69 + EC (Hell Bovine, Moo Moo Farm) + XX (Minion of Destruction, Act 5) + expect(index.entities.length).toBe(71) for (const entity of index.entities) { expect(entity.key).toBeDefined() diff --git a/tests/challenger-m4-determinism-parity.test.ts b/tests/challenger-m4-determinism-parity.test.ts index b8159a0..eedfc22 100644 --- a/tests/challenger-m4-determinism-parity.test.ts +++ b/tests/challenger-m4-determinism-parity.test.ts @@ -38,16 +38,15 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true) const baselineHash = sha256File(ENTITIES_INDEX_PATH) - // Expected canonical SHA-256 for Schema 2 70-monster entity index (including vk-hth Valkyrie - // and ec-hth Hell Bovine: MonStats.txt hellbovine Code=EC, level 39 Moo Moo Farm). Adding - // ec-hth is the only change against the previous 69-monster index (51da3628…). - expect(baselineHash).toBe('675757cffde277b24e2aec11b7440243654282cbb743424ef02ce1d253b41c66') + // Expected canonical SHA-256 for Schema 2 71-monster entity index (including vk-hth Valkyrie, + // ec-hth Hell Bovine, and xx-hth Minion of Destruction: MonStats.txt minion1..11 Code=XX). + expect(baselineHash).toBe('593ca49d34479d0864687aba4dc798bac4351ccdba6c6b97e2af3167292c6e6d') const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8') const parsed = JSON.parse(baselineContent) expect(parsed.schema).toBe(2) expect(parsed.character).toBe('char-so') - expect(parsed.entities).toHaveLength(70) + expect(parsed.entities).toHaveLength(71) // Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2'] diff --git a/tests/maze-special-passes.test.ts b/tests/maze-special-passes.test.ts index c17a7ea..cc0bcab 100644 --- a/tests/maze-special-passes.test.ts +++ b/tests/maze-special-passes.test.ts @@ -530,6 +530,37 @@ describe('Diablo II DRLG Maze Special Passes', () => { expect(observedDirs).toContain('SW') expect(observedDirs).toContain('SE') }) + + test('Halls of Pain (level 123): always places waypoint in bounded 60x60 2x2 ring across var1..var3 and 100 seeds (Issue #501)', () => { + const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces) + const seeds = [ + 0x5eed0000 + 123 + 0 * 555, + 0x5eed0000 + 123 + 1 * 555, + 0x5eed0000 + 123 + 2 * 555, + ...Array.from({ length: 100 }, (_, i) => i + 1), + ] + + for (const seed of seeds) { + const res = generateMaze({ + levelId: 123, + levelName: 'Act 5 - Temple 2', + sectionSize: 30, + minRooms: 4, + merge: 1000, + seed, + pieces, + maxCellsX: 60, + maxCellsY: 60, + }) + const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const wp = specials.find(s => s.kind === 'waypoint') + const down = specials.find(s => s.kind === 'down') + expect(wp, `missing waypoint for seed 0x${seed.toString(16)}`).toBeDefined() + expect(down, `missing down stairs for seed 0x${seed.toString(16)}`).toBeDefined() + expect(wp!.sides).not.toBe(down!.sides) + expect(wp!.room).not.toBe(down!.room) + } + }) }) /* ----------------------------------------------------------------------- * diff --git a/tests/monster-mapping.test.ts b/tests/monster-mapping.test.ts index a72546a..18a0447 100644 --- a/tests/monster-mapping.test.ts +++ b/tests/monster-mapping.test.ts @@ -23,17 +23,24 @@ describe('monster-mapping', () => { expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' }) }) - it('does not leak XX tokens or crash on cut monsters', () => { - const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'minion1', 'spiritmummy'] + it('does not leak cut monsters while resolving Act 5 Minion of Destruction (minion1..11) to token XX', () => { + const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'spiritmummy'] for (const id of cutMonsters) { expect(resolveMonsterArtSpec(id)).toBeNull() } + for (let i = 1; i <= 11; i += 1) { + expect(resolveMonsterArtSpec(`minion${i}`)).toEqual({ token: 'XX', weapon: 'hth' }) + } }) - it('ACT_MONSTER_SPECS does not contain any XX tokens in any act', () => { + it('ACT_MONSTER_SPECS includes token XX only in Act 5 (Minion of Destruction)', () => { for (const [act, specs] of Object.entries(ACT_MONSTER_SPECS)) { - for (const spec of specs) { - expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX') + if (Number(act) === 5) { + expect(specs).toContainEqual({ token: 'XX', weapon: 'hth' }) + } else { + for (const spec of specs) { + expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX') + } } } }) diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts index 918d214..cce9e44 100644 --- a/tests/packed-entity-art.test.ts +++ b/tests/packed-entity-art.test.ts @@ -104,14 +104,14 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 const charR8 = readFileSync(charR8Path) expect(charR8.byteLength).toBe(charMeta.width * charMeta.height) - // Check all 66 unique monster specs exist as global .r8 files (EC = Hell Bovine, Moo Moo Farm) + // Check all 67 unique monster specs exist as global .r8 files (including EC = Hell Bovine and XX = Minion of Destruction) const uniqueSpecs = new Set() for (let act = 1; act <= 5; act += 1) { for (const spec of ACT_MONSTER_SPECS[act] ?? []) { uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`) } } - expect(uniqueSpecs.size).toBe(66) + expect(uniqueSpecs.size).toBe(67) for (const key of uniqueSpecs) { const slug = `monster-${key}` diff --git a/tests/superuniques-fidelity.test.ts b/tests/superuniques-fidelity.test.ts index 0e9727e..b30fc45 100644 --- a/tests/superuniques-fidelity.test.ts +++ b/tests/superuniques-fidelity.test.ts @@ -125,26 +125,25 @@ describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => { expect(su.id).toBeTruthy() expect(su.monsterId).toBeTruthy() - // Boss class must resolve to an art spec or return null for dummy 'xx' codes gracefully + // Boss class must resolve to an authentic art spec (including minion1 -> token 'XX') const bossArt = resolveMonsterArtSpec(su.monsterId) + expect(bossArt).not.toBeNull() + expect(bossArt?.token).toBeTruthy() if (su.monsterId === 'minion1') { - // minion1 has Code="xx" in MonStats.txt and resolveMonsterArtSpec returns null - expect(bossArt).toBeNull() + expect(bossArt).toEqual({ token: 'XX', weapon: 'hth' }) } else { - expect(bossArt).not.toBeNull() - expect(bossArt?.token).toBeTruthy() expect(bossArt?.token.toLowerCase()).not.toBe('xx') } } }) - test('Dummy code xx monsters resolve to null and do not throw in resolveMonsterArtSpec', () => { - // minion1..minion11 have Code="xx" in MonStats + test('Act 5 Minion of Destruction (minion1..11) resolve to token XX while unknown/empty IDs resolve to null', () => { + // minion1..minion11 have authentic D2exp.mpq art under data\global\monsters\XX\ for (let i = 1; i <= 11; i += 1) { const art = resolveMonsterArtSpec(`minion${i}`) - expect(art).toBeNull() + expect(art).toEqual({ token: 'XX', weapon: 'hth' }) } - // Unknown or dummy + // Unknown or empty IDs expect(resolveMonsterArtSpec('xx')).toBeNull() expect(resolveMonsterArtSpec('')).toBeNull() })