fix(drlg,pack): pack XX minion art, fix L123 var3 waypoint, and pin exact DRLG tile variants (#500, #501, #502)
This commit is contained in:
parent
298c6f6b39
commit
909c50eb48
|
|
@ -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` 这类简单线性同余公式,并通过空间方差统计单测确保视觉离散度。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -694,10 +694,20 @@ function parseIds(spec: string): Set<number> {
|
|||
function parseArgs(argv: readonly string[]): Map<string, string> {
|
||||
const known = new Set(['pack', 'out', 'jobs', 'act', 'levels', 'shots', 'timeout', 'chrome'])
|
||||
const args = new Map<string, string>()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
|||
const tokens = new Set<string>()
|
||||
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<string> {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
const expectedByCellKind = new Map<string, string[]>()
|
||||
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<string, string[]>()
|
||||
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<void> {
|
|||
}
|
||||
// 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<void> {
|
|||
}
|
||||
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<string, unknown>[] = []
|
||||
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<void> {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, { library: number; tile: number }[]>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -398,6 +398,17 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
|
|||
"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<number, readonly MonsterArtSpec[]> = {
|
|||
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<number, readonly MonsterArtSpec[]> = {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
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}`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue