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..4f8028e 100644 --- a/scripts/audit-levels-browser.ts +++ b/scripts/audit-levels-browser.ts @@ -318,7 +318,7 @@ class Browser { this.child.kill('SIGKILL') await exited } - rmSync(this.profile, { recursive: true, force: true }) + rmSync(this.profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) } } @@ -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/drlg-diff.ts b/scripts/drlg-diff.ts index f82556f..55d53eb 100644 --- a/scripts/drlg-diff.ts +++ b/scripts/drlg-diff.ts @@ -26,6 +26,7 @@ import { DrlgTraceWriter, dumpAct1, type DrlgDump } from '../src/game/drlg/drlg- import { firstDiff, firstTraceDivergence, formatDiff, fsDrlgSource, ORACLE_DATA, runOracle } from './lib/drlg-oracle.ts' interface Args { + act: number | undefined levels: number[] seeds: number[] difficulty: number @@ -39,6 +40,14 @@ interface Args { traceCheck: boolean } +const ACT_DEFAULT_OUTDOOR_LEVELS: Record = { + 0: [2, 3, 4, 5, 6, 7, 17, 26, 27, 28, 32, 33, 39], + 1: [41, 42, 43, 44, 45, 46], + 2: [76, 77, 78, 79, 80, 81, 82, 83], + 3: [104, 105, 106, 108], + 4: [110, 111, 112, 117, 134], +} + function parseLevels(spec: string): number[] { const out: number[] = [] for (const part of spec.split(',')) { @@ -54,8 +63,9 @@ function parseLevels(spec: string): number[] { } function parseArgs(argv: string[]): Args { - const args: Args = { levels: [], seeds: [], difficulty: 0, isolated: false, dataDir: ORACLE_DATA, stopOnFirst: false, report: null, noActivation: false, traceCheck: false } + const args: Args = { act: undefined, levels: [], seeds: [], difficulty: 0, isolated: false, dataDir: ORACLE_DATA, stopOnFirst: false, report: null, noActivation: false, traceCheck: false } let nRandom = 0 + const positionals: string[] = [] for (let i = 0; i < argv.length; i += 1) { const a = argv[i]! const next = (): string => { @@ -63,7 +73,8 @@ function parseArgs(argv: string[]): Args { if (v === undefined) throw new Error(`missing value for ${a}`) return v } - if (a === '--levels') args.levels = parseLevels(next()) + if (a === '--act') args.act = Number(next()) + else if (a === '--levels') args.levels = parseLevels(next()) else if (a === '--seeds') nRandom = Number(next()) else if (a === '--seed') args.seeds.push(...next().split(',').filter(Boolean).map(s => Number(s) >>> 0)) else if (a === '--difficulty') args.difficulty = Number(next()) @@ -73,8 +84,22 @@ function parseArgs(argv: string[]): Args { else if (a === '--report') args.report = next() else if (a === '--no-activation') args.noActivation = true else if (a === '--trace-check') args.traceCheck = true + else if (!a.startsWith('-')) positionals.push(a) else throw new Error(`unknown argument ${a}`) } + if (positionals.length > 0) { + const posAct = Number(positionals[0]) + const actIdx = posAct >= 1 && posAct <= 5 ? posAct - 1 : posAct + if (!Number.isInteger(actIdx) || actIdx < 0 || actIdx > 4) throw new Error(`bad positional act "${positionals[0]}"`) + args.act = actIdx + if (args.levels.length === 0) args.levels = [...ACT_DEFAULT_OUTDOOR_LEVELS[actIdx]!] + if (positionals.length >= 2 && nRandom === 0 && args.seeds.length === 0) { + nRandom = Number(positionals[1]) + } + } + if (args.act !== undefined && (!Number.isInteger(args.act) || args.act < 0 || args.act > 4)) { + throw new Error('--act must be 0..4') + } if (!Number.isInteger(nRandom) || nRandom < 0) throw new Error('--seeds must be a non-negative integer') if (nRandom > 0) args.seeds.push(...webcrypto.getRandomValues(new Uint32Array(nRandom))) if (args.seeds.length === 0) throw new Error('give --seeds N or --seed X') @@ -119,12 +144,18 @@ function main(): void { const args = parseArgs(process.argv.slice(2)) const source = fsDrlgSource(args.dataDir) const tables = loadDrlgTables(source) + const nonOutdoor = args.levels.filter(id => tables.levelDefs[id]?.dwDrlgType !== 3) + if (nonOutdoor.length > 0) { + console.log(`drlg-diff: skipping non-outdoor (dwDrlgType != 3) levels [${nonOutdoor.join(',')}]`) + args.levels = args.levels.filter(id => tables.levelDefs[id]?.dwDrlgType === 3) + } const results: SeedResult[] = [] const t0 = Date.now() console.log(`drlg-diff: ${args.seeds.length} seed(s), levels [${args.levels.join(',') || 'act only'}], difficulty ${args.difficulty}${args.isolated ? ', isolated' : ''}${args.noActivation ? ', no activation' : ''}${args.traceCheck ? ', trace check' : ''}`) const runPort = (seed: number, trace: DrlgTraceWriter | undefined): DrlgDump => dumpAct1(createDrlgEnv(source, tables), seed, args.levels, { + ...(args.act !== undefined ? { act: args.act } : {}), difficulty: args.difficulty, isolated: args.isolated, actOnly: args.levels.length === 0, @@ -134,7 +165,14 @@ function main(): void { for (const seed of args.seeds) { const seedHex = `0x${seed.toString(16).padStart(8, '0')}` - const runOpts = { seed, levels: args.levels, difficulty: args.difficulty, isolated: args.isolated, dataDir: args.dataDir } + const runOpts = { + seed, + levels: args.levels, + ...(args.act !== undefined ? { act: args.act } : {}), + difficulty: args.difficulty, + isolated: args.isolated, + dataDir: args.dataDir, + } let result: SeedResult try { const oracleRun = runOracle({ ...runOpts, trace: args.traceCheck }) diff --git a/scripts/lib/drlg-mpq-source.ts b/scripts/lib/drlg-mpq-source.ts index 940a116..ed0197e 100644 --- a/scripts/lib/drlg-mpq-source.ts +++ b/scripts/lib/drlg-mpq-source.ts @@ -64,9 +64,17 @@ export function drlgActDt1Files(t: DrlgTables, act1Based: number): string[] { const key = normalizeDrlgPath(path) if (!out.has(key)) out.set(key, path) } + const extraLevelTypes = new Set() + if (act1Based === 5) { + for (let id = 109; id < t.levelDefs.length; id += 1) { + const def = t.levelDefs[id] + if (def && def.dwLevelType > 0) extraLevelTypes.add(def.dwLevelType) + } + } let rows = 0 - for (const rec of t.lvlTypes) { - if (rec.nAct !== act1Based) continue + for (let typeId = 0; typeId < t.lvlTypes.length; typeId += 1) { + const rec = t.lvlTypes[typeId]! + if (rec.nAct !== act1Based && !extraLevelTypes.has(typeId)) continue rows += 1 // DATATBLS_LoadLevelTypesTxt leaves names of one character or less ("0", "") unprefixed: they are // unused slots that DRLGROOMTILE_LoadDT1FilesForRoom never opens. diff --git a/scripts/lib/drlg-oracle.ts b/scripts/lib/drlg-oracle.ts index 52af170..4c9aa7a 100644 --- a/scripts/lib/drlg-oracle.ts +++ b/scripts/lib/drlg-oracle.ts @@ -39,6 +39,7 @@ export interface OracleRunOptions { readonly seed: number /** Empty: act stage only. */ readonly levels: readonly number[] + readonly act?: number readonly difficulty?: number readonly isolated?: boolean readonly trace?: boolean @@ -58,6 +59,7 @@ export function runOracle(opts: OracleRunOptions): OracleRunResult { const tracePath = join(dir, 'trace.txt') const args = ['--data', opts.dataDir ?? ORACLE_DATA, '--seed', `0x${(opts.seed >>> 0).toString(16)}`, '--out', out] if (opts.levels.length > 0) args.push('--levels', opts.levels.join(',')) + if (opts.act !== undefined) args.push('--act', String(opts.act)) if (opts.difficulty !== undefined) args.push('--difficulty', String(opts.difficulty)) if (opts.isolated) args.push('--isolated') if (opts.trace) args.push('--trace', tracePath) diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 79b4059..10e393b 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -54,11 +54,7 @@ import { decodeDc6 } from '../src/formats/dc6.ts' import type { SpriteFrame } from '../src/formats/sprite.ts' import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' import type { MazePiece, MazePieceKind, MazeWaypoint } from '../src/game/maze.ts' -import { generateWilderness } from '../src/game/wilderness.ts' -import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' -import { generatePreset } from '../src/game/preset.ts' -import type { PresetPiece } from '../src/game/preset.ts' -import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { outdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' import type { DrlgEntrance, DrlgLevelInput } from '../src/game/drlg/drlg-map.ts' import { dumpAct1 } from '../src/game/drlg/drlg-dump.ts' import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' @@ -68,10 +64,10 @@ import type { ActLayout, VariantEntry, VariantIndex } from '../src/game/act-vari import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/drlg-mpq-source.ts' /** - * An entrance a generator reports: the wilderness generator's, or the DRLG adapter's (seams carry a - * span) with the display label the bake gives it. + * An entrance a generator reports: the DRLG adapter's (seams carry a span) with the display label + * the bake gives it. */ -type GeneratorEntrance = WildernessEntrance | (DrlgEntrance & { readonly label: string }) +type GeneratorEntrance = DrlgEntrance & { readonly label: string } export interface MazeWarp { readonly room: number readonly direction: 'up' | 'down' @@ -81,13 +77,13 @@ export interface MazeWarp { readonly centreY: number } import { - assignGateSides, buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp, + buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp, parseLevelRows, parseWarpGeometry, SIDES, } from '../src/game/world-graph.ts' import type { Side, WorldEdge, WorldGraph } from '../src/game/world-graph.ts' import { cellToSubTile, findBorderOpening, findWaypointSpot, largestWalkableRegion, nearestWalkable, - seamArrivalSpot, triggerableFrom, WARP_TRIGGER_SUBTILES, + triggerableFrom, WARP_TRIGGER_SUBTILES, } from '../src/game/level-links.ts' import type { BorderOpening, LinkGrid, SceneEntrance, SceneLinks, SceneWarp, SceneWaypoint, WalkableRegion, @@ -412,10 +408,7 @@ for (const row of monstats2Table.rows) { */ const ACT_LAYOUT_SEED = 0x5eed_2000 -const worldGraph: WorldGraph = assignGateSides( - buildWorldGraph(parseLevelRows(tables.levels)), - ACT_LAYOUT_SEED, -) +const worldGraph: WorldGraph = buildWorldGraph(parseLevelRows(tables.levels)) const warpGeometry = parseWarpGeometry(tables.lvlwarp) /** @@ -464,7 +457,7 @@ function levelNameOf(levelId: number): string { } /* ------------------------------------------------------------------------- * - * DRLG port (Act I outdoor levels) + * DRLG port (Acts I–V outdoor levels) * ------------------------------------------------------------------------- */ /** Copies the bake makes of every act the DRLG port generates, one game seed each. */ @@ -487,15 +480,14 @@ function actGameSeed(act: number, variant: number): number { } /** - * The levels of an act the DRLG port generates: Act I's outdoor levels (Levels.txt DrlgType 3). The - * other acts still bake through the legacy generators. + * The levels of an act the DRLG port generates: all outdoor levels (Levels.txt DrlgType 3) of that act. * * @param t - the DRLG tables. * @param act - act number (1..5). * @returns level ids, ascending. */ function drlgLevelsOf(t: DrlgTables, act: number): readonly number[] { - return act === 1 ? act1OutdoorLevelIds(t) : [] + return outdoorLevelIds(t, act - 1) } /** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */ @@ -537,7 +529,7 @@ async function generateDrlgActs(wanted: (act: number, levelId: number) => boolea const copies: DrlgActCopy[] = [] for (let variant = 0; variant < DRLG_ACT_VARIANTS; variant += 1) { const gameSeed = actGameSeed(act, variant) - const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels) + const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels, { act: act - 1 }) const townFile = dump.act.find(level => level.id === town)?.preset?.map?.file if (townFile === undefined || townFile === '') { throw new Error(`act ${String(act)} seed 0x${gameSeed.toString(16)}: the generated act picked no DS1 for its town (level ${String(town)})`) @@ -585,28 +577,6 @@ function validateActLayouts(index: VariantIndex): void { } } -/** - * The border seams an outdoor level must cut, as the generator wants them. - * - * @param levelId - the level being generated. - * @returns one gate per seamless edge leaving it. - */ -function gatesFor(levelId: number): PlannedGate[] { - const gates: PlannedGate[] = [] - for (const edge of edgesFrom(worldGraph, levelId)) { - if (edge.kind !== 'seamless' || edge.sideFrom === null) continue - gates.push({ - side: edge.sideFrom, - toLevelId: edge.to, - label: `${levelNameOf(edge.to)} Seam`, - // The Burial Grounds seam is the one place the border art has a - // dedicated opening: variant 4 is the graveyard gate, 3 the plain road. - variant: (levelId === 3 && edge.to === 17) || (levelId === 17 && edge.to === 3) ? 4 : 3, - }) - } - return gates -} - /** * Turn a graph edge and a place into a baked warp. * @@ -771,6 +741,29 @@ function buildSceneLinks( for (const slot of edge.warpSlots) warpEdgeBySlot.set(slot, edge) } + /** + * Maximum number of simultaneous warp instances from `levelId` to `toLevelId`: + * in Levels.txt, 4-orientation cave mouths use 4 distinct `Warp` IDs (so only 1 + * is placed per seed), whereas levels with multiple coexisting entrances to the + * same destination (such as Kurast Bazaar 80 -> Sewers 92 and Upper Kurast 81 -> + * Sewers 92) repeat the same `(Vis, Warp)` pair across multiple slots. + */ + const levelRow = worldGraph.levels.get(levelId) + const placedWarpCounts = new Map() + const maxWarpsTo = (toLevelId: number): number => { + if (levelRow === undefined) return 1 + const byWarpId = new Map() + let maxCount = 1 + for (let slot = 0; slot < 8; slot += 1) { + if ((levelRow.vis[slot] ?? 0) !== toLevelId) continue + const warpId = levelRow.warp[slot] ?? -1 + const next = (byWarpId.get(warpId) ?? 0) + 1 + byWarpId.set(warpId, next) + if (next > maxCount) maxCount = next + } + return maxCount + } + /** Which way a crossing leads, for the runtime's benefit. */ const here = townDistance.get(levelId) ?? Number.MAX_SAFE_INTEGER const directionTo = (toLevelId: number): SceneWarp['direction'] => @@ -822,49 +815,44 @@ function buildSceneLinks( placedSeams.add(anchor.toLevelId) continue } - if (exactLinks && anchor.kind === 'gate') { + if (anchor.kind === 'gate') { throw new Error(`level ${String(levelId)}: seam to level ${String(anchor.toLevelId)} has no side and span`) } - if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) { - // The anchor the generator hands over is the border *piece*, and the - // centre of that cell is wall; on some outdoor variants the border - // block's gap is separated from the main play area by up to ~40 - // sub-tiles of rock. Snapping to `region` with a 64-sub-tile radius - // places the seam trigger on the outer boundary of the main play area, - // and `seamArrivalSpot` steps inward outside the trigger radius. - const gate = nearestWalkable(grid, cellToSubTile(anchor.x), cellToSubTile(anchor.y), 64, region) - ?? { x: cellToSubTile(anchor.x), y: cellToSubTile(anchor.y) } - const arrive = seamArrivalSpot(grid, gate.x, gate.y, anchor.side, region) - outEntrances.push({ - toLevelId: anchor.toLevelId, - side: anchor.side, - label: anchor.label, - x: gate.x, - y: gate.y, - arriveX: arrive.x, - arriveY: arrive.y, - }) - placedSeams.add(anchor.toLevelId) - continue - } // A preset's mouth: the cave, tower or crypt that opens off this level. const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId) const warp = 'warp' in anchor ? anchor.warp : undefined + const currentWarpCount = edge === undefined ? 0 : (placedWarpCounts.get(edge.to) ?? 0) + const allowedWarps = edge === undefined ? 1 : maxWarpsTo(edge.to) if (exactLinks) { if (edge === undefined) { throw new Error(`level ${String(levelId)}: the generator has a warp to level ${String(anchor.toLevelId)}, the world graph does not`) } - if (placedWarps.has(edge.to)) { - throw new Error(`level ${String(levelId)}: two warps lead to level ${String(anchor.toLevelId)}`) + if (currentWarpCount >= allowedWarps) { + throw new Error(`level ${String(levelId)}: more than ${String(allowedWarps)} warp(s) lead to level ${String(anchor.toLevelId)}`) } if (warp === undefined) { throw new Error(`level ${String(levelId)}: warp to level ${String(anchor.toLevelId)} has no warp unit position`) } } - if (edge === undefined || placedWarps.has(edge.to)) continue + if (edge === undefined || currentWarpCount >= allowedWarps) continue outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region, warp === undefined ? undefined : { x: warp.subX, y: warp.subY })) placedWarps.add(anchor.toLevelId) + placedWarpCounts.set(edge.to, currentWarpCount + 1) + } + + /** + * In Act 3 Jungle, DRLGOUTPLACE_InitAct3OutdoorLevel connects Flayer Jungle (78) to either + * Great Marsh (77, linear: 76<->77<->78) or directly to Spider Forest (76, branching: 76<->77 + * dead-end and 76<->78). Whichever of those two alternative seams is not active on this seed + * is not expected on this copy. + */ + const isInactiveJungleSeam = (toLevelId: number): boolean => { + if (levelId === 76 && toLevelId === 78) return placedSeams.has(77) + if (levelId === 77 && toLevelId === 78) return placedSeams.has(76) + if (levelId === 78 && toLevelId === 76) return placedSeams.has(77) + if (levelId === 78 && toLevelId === 77) return placedSeams.has(76) + return false } if (exactLinks) { @@ -872,7 +860,7 @@ function buildSceneLinks( // Levels.txt rows (Vis0..7 / Warp0..7); any difference is a bug in one of // them, never something to paper over with a guessed opening. for (const edge of edgesFrom(worldGraph, levelId)) { - if (edge.kind === 'seamless' && !placedSeams.has(edge.to)) { + if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !isInactiveJungleSeam(edge.to)) { throw new Error(`level ${String(levelId)}: the world graph has a seam to level ${String(edge.to)}, the generator does not`) } if (edge.kind === 'warp' && !placedWarps.has(edge.to)) { @@ -907,8 +895,7 @@ function buildSceneLinks( // Presets always land here, because their artwork is fixed and nobody // wrote down where the gate is. So do the two mazes with a seamless edge // (the Barracks opens onto the Courtyard, Lava 1 onto the Chaos - // Sanctuary), and outdoor levels whose grid is too small for the planner - // to run, like the 6x2 Kurast docks. + // Sanctuary). // // The graph's side is usually right, but not always. The Rogue Encampment // ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which @@ -926,16 +913,16 @@ function buildSceneLinks( // than one seam there is no way to tell which border contact belongs to // which neighbour, so the graph's sides stand. const loneSeams = [...edgesFrom(worldGraph, levelId)] - .filter(edge => edge.kind === 'seamless' && edge.sideFrom !== null && !placedSeams.has(edge.to)) + .filter(edge => edge.kind === 'seamless' && !placedSeams.has(edge.to) && !isInactiveJungleSeam(edge.to)) for (const edge of loneSeams) { - const wanted = edge.sideFrom! + const wanted = edge.sideFrom const measured = new Map() for (const side of SIDES) { const found = findBorderOpening(grid, side, region) if (found !== null) measured.set(side, found) } - const asked = measured.get(wanted) + const asked = wanted === null ? undefined : measured.get(wanted) let side: Side | null = asked === undefined ? null : wanted let opening = asked @@ -1018,7 +1005,7 @@ function buildSceneLinks( // twice: once with the real reason and once with this generic one. const reported = new Set(unplaced.map(hole => hole.toLevelId)) for (const edge of edgesFrom(worldGraph, levelId)) { - if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to)) { + if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to) && !isInactiveJungleSeam(edge.to)) { unplaced.push({ toLevelId: edge.to, reason: 'seam not cut by the generator' }) } } @@ -1031,7 +1018,10 @@ function buildSceneLinks( // an approximate position. const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255 const waypoints: SceneWaypoint[] = [] - if (exactLinks && waypointId === 255 && waypointCells.length > 0) { + // Level 134 (LEVEL_PANDEMONIUMRUN2, Forgotten Sands) stamps LVLPREST_ACT2_DESERT_VALLEY_WARP + // in DRLGOUTDESR_InitAct2OutdoorLevel (D2Common.0x6FDD58A0), which carries the Canyon of the + // Magi waypoint DS1 object even though Forgotten Sands has Waypoint=255 in Levels.txt. + if (exactLinks && waypointId === 255 && waypointCells.length > 0 && levelId !== 134) { throw new Error(`level ${String(levelId)}: the generator placed a waypoint, Levels.txt has none`) } if (waypointId !== 255) { @@ -1071,7 +1061,7 @@ const allNames = await archives.listFiles() const drlg = Number(cell(tables.levels, row, 'DrlgType')) const act = Number(cell(tables.levels, row, 'Act')) + 1 let kind: LevelKind | null = null - if (drlg === 2 || levelId === 108) kind = 'preset' + if (drlg === 2) kind = 'preset' else if (drlg === 1) kind = 'maze' else if (drlg === 3) kind = 'wilderness' if (kind === null) continue @@ -1249,7 +1239,6 @@ async function isObjectCofAdditive(token: string, modeIndex: number, baseIsMonst } const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt')) -const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) /** The level type row for a level row. */ function levelType(levelRow: readonly string[]): { id: string; name: string } { @@ -1284,26 +1273,6 @@ async function rowDs1s(table: D2Table, row: readonly string[]): Promise { return levels } -function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] { - const values: number[] = [] - for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0) - return values -} - -/** - * The LvlPrest families the legacy outdoor generator draws its pieces from, by level type. Act I is not - * here: its outdoor levels bake only through the DRLG port ('drlg' jobs, see generateDrlgActs). - */ -const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo', 'Act 4 - Bridge'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], -} - const mazePiecesCache = new Map>() function getMazePieces(levelTypeName: string, levelTypeId: string): Promise { void levelTypeId @@ -1336,86 +1305,6 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise>() -function getWildernessPieces(levelTypeName: string, subType?: number, levelName?: string): Promise { - const isSnow = subType === 11 || (levelName !== undefined && /snow/i.test(levelName)) - const cacheKey = `${levelTypeName}:${isSnow ? 'snow' : 'dirt'}` - const cached = wildernessPiecesCache.get(cacheKey) - if (cached !== undefined) return cached - const promise = (async () => { - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of tables.lvlprest.rows) { - const name = cell(tables.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade') { - if (BROKEN_ACT5_BARRICADE_PRESETS.has(name)) continue - if (isSnow && !/snow/i.test(name)) continue - if (!isSnow && /snow/i.test(name)) continue - } - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff|ravine/i.test(name) - const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0 - const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined - pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) }) - } - return pieces - })() - wildernessPiecesCache.set(cacheKey, promise) - return promise -} - -const substitutionsCache = new Map>() -function getSubstitutions(type: number): Promise { - if (type < 0) return Promise.resolve([]) - const cached = substitutionsCache.get(type) - if (cached !== undefined) return cached - const promise = (async () => { - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(tileMemberPath(file))] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: themeValues(lvlsub, row, 'Prob'), - trials: themeValues(lvlsub, row, 'Trials'), - max: themeValues(lvlsub, row, 'Max'), - levels, - }) - } - return rows - })() - substitutionsCache.set(type, promise) - return promise -} - const paletteCache = new Map() async function getPalette(paletteName: string): Promise { const cached = paletteCache.get(paletteName) @@ -1428,9 +1317,9 @@ async function getPalette(paletteName: string): Promise { interface SceneJob { readonly jobId: number readonly entry: LevelJob - readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' | 'drlg' + readonly type: 'preset-ds1' | 'maze' | 'drlg' readonly ds1Name?: string - /** Copy number: 1-based for the legacy generators, the 0-based act copy (`actVariant`) for 'drlg'. */ + /** Copy number: 1-based for the maze generator, the 0-based act copy (`actVariant`) for 'drlg'. */ readonly variant?: number readonly label: string readonly seed: number @@ -1478,11 +1367,12 @@ async function bakeDs1Variant( /** The generator reports every seam and warp exactly (see buildSceneLinks). */ readonly exactLinks?: boolean /** - * `'layout'`: every room, tile (style, sequence, type, DT1 library) and preset unit is D2's own - * (the DRLG port); only the variant within a tile's (style, sequence) is the renderer's pick. + * `'exact'`: every room, tile (style, sequence, type, DT1 library, and variant) and preset unit + * is D2's own (the DRLG port). + * `'layout'`: only the variant within a tile's (style, sequence) is the renderer's pick. * Unset: the generator approximates the level. */ - readonly fidelity?: 'layout' + readonly fidelity?: 'exact' | 'layout' } = { entrances: [], mazeWarps: [] }, animSpeedOverride?: number, ): Promise { @@ -1510,9 +1400,6 @@ async function bakeDs1Variant( /** * Waypoint pedestals found in the artwork, in sub-tiles. - * - * Only presets have one: the outdoor and maze generators do not run the pass - * that spawns waypoints, so their levels fall back to a placed position. */ const waypointCells: { x: number; y: number }[] = [] for (const object of level.objects) { @@ -1544,37 +1431,7 @@ async function bakeDs1Variant( ) if (sceneLinks.waypoints.some(wp => wp.source !== 'object')) { - const defaultWpDs1Id = entry.act === 1 ? 37 : entry.act === 2 ? 10 : entry.act === 3 ? 1 : entry.act === 4 ? 0 : 120 - const nextObjects = [...level.objects] - const nextWaypoints: SceneWaypoint[] = [] - for (const wp of sceneLinks.waypoints) { - if (wp.source !== 'object') { - nextObjects.push({ - type: 2, - id: defaultWpDs1Id, - x: wp.x, - y: wp.y, - flags: 0, - }) - nextWaypoints.push({ ...wp, source: 'object' }) - } else { - nextWaypoints.push(wp) - } - } - level = { ...level, objects: nextObjects } - ;(sceneLinks as { waypoints: readonly SceneWaypoint[] }).waypoints = nextWaypoints - ;(scene as { automap?: unknown }).automap = buildLevelAutomap(level, { - automapTable: tables.automap, - levelTypeId, - levelId: entry.levelId, - act: entry.act, - layerId, - ds1Name, - objectsTable: objectsTableTyped, - monstersTable, - monstats2ById, - seed, - }) + throw new Error(`${label} (level ${String(entry.levelId)}): waypoint resolved to fallback source instead of authentic DS1 object`) } const spawn = findIsoSpawn(scene, { warps: sceneLinks.warps, entrances: sceneLinks.entrances }) @@ -1875,9 +1732,10 @@ async function bakeDs1Variant( return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY] } + const fidelity = entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation' const sceneJson = { version: 1, - fidelity: entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation', + fidelity, act: entry.act, levelId: entry.levelId, levelName, @@ -1965,6 +1823,7 @@ async function bakeDs1Variant( act: entry.act, levelId: entry.levelId, kind: entry.kind, + fidelity, slug: entry.slug, label, levelName, @@ -2033,44 +1892,6 @@ async function bakeSceneJob(job: SceneJob): Promise { ) } - if (job.type === 'preset-chaos') { - const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act) - const libraries: Dt1[] = [] - for (const name of info.dt1Names) libraries.push(await libraryOf(name)) - - const pieces: PresetPiece[] = [] - for (const ds1Name of info.ds1Names) { - const level = await loadDs1(ds1Name) - const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name - const prestRow = tables.lvlprest.rows.find(r => { - for (let file = 1; file <= 6; file += 1) { - const f = cell(tables.lvlprest, r, `File${String(file)}`) - if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true - } - return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase()) - }) - const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0 - const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined - pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) }) - } - - const result = generatePreset({ - levelId: job.entry.levelId, - levelName: info.levelName, - sizeX: info.sizeX > 0 ? info.sizeX : 120, - sizeY: info.sizeY > 0 ? info.sizeY : 120, - seed: job.seed, - pieces, - }) - return await bakeDs1Variant( - job.jobId, - job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, - result.level, `preset:${job.label}`, job.label, job.seed, - { entrances: [], mazeWarps: [] }, - (result.stats.animSpeed as number | undefined) ?? info.animSpeed, - ) - } - if (job.type === 'maze') { const type = levelType(levelRow) const row = mazeRow(job.entry.levelId, job.entry.name) @@ -2145,7 +1966,7 @@ async function bakeSceneJob(job: SceneJob): Promise { mazeWarps: [], landmarks: map.landmarks, exactLinks: true, - fidelity: 'layout', + fidelity: 'exact', notes: [ `DRLG port: act ${String(job.entry.act)} copy ${String(job.variant)}, game seed 0x${drlg.gameSeed.toString(16)}`, `DRLG tiles: ${String(s.walls)} walls, ${String(s.floors)} floors, ${String(s.shadows)} shadows, ` @@ -2156,55 +1977,6 @@ async function bakeSceneJob(job: SceneJob): Promise { return { ...result, indexEntry: { ...result.indexEntry, actVariant: job.variant } } } - if (job.type === 'wilderness') { - const type = levelType(levelRow) - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) - const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) - const pieces = await getWildernessPieces(type.name, subType, job.entry.name) - if (pieces.length === 0) { - throw new Error(`skipping wilderness ${job.entry.name}: no wilderness pieces`) - } - const rows = await getSubstitutions(subType) - const shrineRows = await getSubstitutions(subShrine) - const libInfo = resolveLevelLibraries(tables, job.entry.levelId) - const resolvedLibraries: Dt1[] = [] - for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name)) - - const gates = gatesFor(job.entry.levelId) - const result = generateWilderness({ - levelId: job.entry.levelId, - levelName: job.entry.name, - levelTypeName: type.name, - sizeX, - sizeY, - subType, - subTheme: Math.max(0, subTheme), - seed: job.seed, - pieces, - substitutions: rows, - shrineSubstitutions: shrineRows, - gates, - dt1Libraries: resolvedLibraries, - }) - return await bakeDs1Variant( - job.jobId, - job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries, - result.level, `generated:${job.label}`, job.label, job.seed, - { - entrances: (result.stats.entrances ?? []) as WildernessEntrance[], - mazeWarps: [], - animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined), - ...(Array.isArray(result.stats.landmarkEntities) - ? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } - : {}), - ...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}), - }, - ) - } - throw new Error(`Unknown job type: ${(job as { type: string }).type}`) } @@ -2214,7 +1986,7 @@ async function bakeSceneJob(job: SceneJob): Promise { * @param filterLevels - optional filter set of level IDs. * @param filterAct - optional filter for a specific act. * @param drlgActs - the DRLG-generated act copies ({@link generateDrlgActs}); their levels bake one - * 'drlg' job per copy instead of going through the legacy wilderness generator. + * 'drlg' job per copy. * @returns ordered array of scene jobs with sequential jobIds (0..N-1). */ async function collectSceneJobs( @@ -2233,28 +2005,15 @@ async function collectSceneJobs( if (entry.kind === 'preset') { const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) - if (entry.levelId === 108) { - for (let v = 1; v <= 3; v += 1) { - jobs.push({ - jobId: jobId++, - entry, - type: 'preset-chaos', - variant: v, - label: `${entry.slug}-var${v}`, - seed: 0x5eed_1000 + entry.levelId * 10 + v, - }) - } - } else { - for (const ds1Name of info.ds1Names) { - jobs.push({ - jobId: jobId++, - entry, - type: 'preset-ds1', - ds1Name, - label: `${entry.slug}-${slugOf(ds1Name)}`, - seed: levelSeed(ds1Name), - }) - } + for (const ds1Name of info.ds1Names) { + jobs.push({ + jobId: jobId++, + entry, + type: 'preset-ds1', + ds1Name, + label: `${entry.slug}-${slugOf(ds1Name)}`, + seed: levelSeed(ds1Name), + }) } } else if (entry.kind === 'maze') { const type = levelType(levelRow) @@ -2280,39 +2039,22 @@ async function collectSceneJobs( } } else if (entry.kind === 'wilderness') { const copies = drlgActs.get(entry.act) - if (copies !== undefined && copies.some(copy => copy.layout.levels.includes(entry.levelId))) { - for (const copy of copies) { - const input = copy.inputs.get(entry.levelId) - if (input === undefined) { - throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`) - } - jobs.push({ - jobId: jobId++, - entry, - type: 'drlg', - variant: copy.layout.variant, - label: `${entry.slug}-var${String(copy.layout.variant + 1)}`, - seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1, - drlg: { gameSeed: copy.layout.gameSeed, input }, - }) + if (copies === undefined || !copies.some(copy => copy.layout.levels.includes(entry.levelId))) { + throw new Error(`outdoor level ${String(entry.levelId)} (${entry.name}) was not generated by DRLG for act ${String(entry.act)}`) + } + for (const copy of copies) { + const input = copy.inputs.get(entry.levelId) + if (input === undefined) { + throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`) } - continue - } - const type = levelType(levelRow) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const pieces = await getWildernessPieces(type.name, subType, entry.name) - if (pieces.length === 0) { - console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`) - continue - } - for (let v = 1; v <= 3; v += 1) { jobs.push({ jobId: jobId++, entry, - type: 'wilderness', - variant: v, - label: `${entry.slug}-var${v}`, - seed: 0x5eed_1000 + entry.levelId * 10 + v, + type: 'drlg', + variant: copy.layout.variant, + label: `${entry.slug}-var${String(copy.layout.variant + 1)}`, + seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1, + drlg: { gameSeed: copy.layout.gameSeed, input }, }) } } 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 73d39be..a41daf2 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..27065b2 100644 --- a/scripts/render-drlg-compare.ts +++ b/scripts/render-drlg-compare.ts @@ -35,14 +35,15 @@ import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts' import { decodePal } from '../src/formats/pal.ts' import { cell, parseTable } from '../src/game/acts.ts' import { buildIsoMapScene, type IsoDraw, type IsoMapScene } from '../src/game/d2map.ts' +import { DRLG_GetActNoFromLevelId } from '../src/game/drlg/drlg-drlg.ts' import { dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts' -import { act1OutdoorLevelIds, auditDrlgDt1Ambiguity, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgDt1Ambiguity, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts' +import { act1OutdoorLevelIds, auditDrlgDt1Ambiguity, buildDrlgLevelMap, drlgLevelInputFromDump, outdoorLevelIds, type DrlgDt1Ambiguity, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts' import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' import { SUB_TILES_PER_TILE } from '../src/game/map.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' -import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from './lib/drlg-mpq-source.ts' +import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables, type DrlgMpqData } from './lib/drlg-mpq-source.ts' import { firstDiff, formatDiff, ORACLE_BIN, runOracle } from './lib/drlg-oracle.ts' import { encodeIndexedPng, encodeRgbaPng } from './png.ts' @@ -51,7 +52,6 @@ const ARCHIVE_DIR = join(ROOT, 'samples/d2') const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt' const LEVELS_TXT = 'data\\global\\excel\\Levels.txt' -const ACT1_PALETTE = 'data\\global\\palette\\act1\\pal.dat' /** Blueprint pixels per sub-tile. */ const BLUEPRINT_SCALE = 3 /** Tiles per side of an outdoor room (DRLG grid cell), for the blueprint grid lines. */ @@ -65,9 +65,9 @@ const BLUEPRINT_PALETTE: readonly (readonly [number, number, number])[] = [ [12, 16, 22], [36, 78, 44], [198, 158, 86], [24, 40, 30], [240, 70, 70], [255, 226, 90], ] -/** The bake's act-copy game seed (scripts/pack-act-assets.ts actGameSeed, act 1). */ -function act1GameSeed(copy: number): number { - return (0x5eed_0000 + 1 * 0x100 + copy) >>> 0 +/** The bake's act-copy game seed (scripts/pack-act-assets.ts actGameSeed, act 1..5). */ +function actGameSeed(act1Based: number, copy: number): number { + return (0x5eed_0000 + act1Based * 0x100 + copy) >>> 0 } /** The seed the bake gives the scene of a DRLG level copy (scripts/pack-act-assets.ts, job type 'drlg'). */ @@ -79,10 +79,29 @@ function slug(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') } +function parseLevelsSpec(spec: string): number[] { + const out: number[] = [] + for (const part of spec.split(',').map(s => s.trim()).filter(Boolean)) { + const range = /^(\d+)-(\d+)$/.exec(part) + if (range !== null) { + const lo = Number(range[1]) + const hi = Number(range[2]) + if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo <= 0 || hi < lo) throw new Error(`bad --levels ${spec}`) + for (let id = lo; id <= hi; id += 1) out.push(id) + } else { + const id = Number(part) + if (!Number.isInteger(id) || id <= 0) throw new Error(`bad --levels ${spec}`) + out.push(id) + } + } + return out +} + interface Args { readonly copy: number - readonly seed: number - readonly levels: readonly number[] + readonly seed?: number + readonly levels?: readonly number[] + readonly allActs: boolean readonly out: string readonly scale: number } @@ -98,14 +117,15 @@ function parseArgs(argv: readonly string[]): Args { const copy = Number(values.get('copy') ?? '0') if (!Number.isInteger(copy) || copy < 0 || copy > 2) throw new Error('--copy must be 0, 1 or 2') 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 seed = seedText === undefined ? undefined : Number(seedText) >>> 0 + const levelsSpec = values.get('levels') + const allActs = levelsSpec === 'all' + const levels = levelsSpec === undefined || allActs ? undefined : parseLevelsSpec(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 !== undefined ? { seed } : {}), ...(levels !== undefined ? { levels } : {}), allActs, out, scale } } // --------------------------------------------------------------------------------------------- @@ -277,6 +297,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 @@ -296,94 +364,116 @@ async function main(): Promise { const archives = new MountedArchives() for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) - const data: DrlgMpqData = await loadDrlgMpqData(member => archives.read(member), 1) - const superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables) - const palette = decodePal(await archives.read(ACT1_PALETTE)) + const baseTables = await loadDrlgMpqTables(member => archives.read(member)) + const superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), baseTables) const levelsTxt = parseTable(await archives.read(LEVELS_TXT)) const levelName = (levelId: number): string => { const row = levelsTxt.rows.find(r => Number(cell(levelsTxt, r, 'Id')) === levelId) if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`) return cell(levelsTxt, row, 'LevelName') } - // 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) { - if (!actLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an Act I outdoor level (${actLevels.join(', ')})`) + const allOutdoorLevels = outdoorLevelIds(baseTables) + const defaultActLevels = act1OutdoorLevelIds(baseTables) + const targetLevels = args.allActs ? allOutdoorLevels : (args.levels ?? defaultActLevels) + for (const levelId of targetLevels) { + if (!allOutdoorLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an outdoor DRLG level (${allOutdoorLevels.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(', ')}]`) - 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 }) - const dumpDiff = firstDiff(oracle, port, '$') - console.log(dumpDiff === null ? 'dumps: identical' : `dumps: DIFFER at ${formatDiff(dumpDiff)}`) - + const acts = [...new Set(targetLevels.map(id => DRLG_GetActNoFromLevelId(id)))].sort((a, b) => a - b) const dt1Cache = new Map() - const library = (name: string): Dt1 => { - let dt1 = dt1Cache.get(name) - if (dt1 === undefined) { - dt1 = decodeDt1(data.source.readFile(name)) - dt1Cache.set(name, dt1) - } - return dt1 - } - const side = (dump: DrlgDump, levelId: 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)) - return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) } - } - const summary: Record[] = [] const ambiguityTotal = { refs: 0, variantGroups: 0, crossLibrary: 0, crossLibraryLevelWide: 0 } - let failed = dumpDiff !== null - for (const levelId of args.levels) { - const name = slug(levelName(levelId)) - const d2moo = side(oracle, levelId) - const ours = side(port, levelId) - 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)) - ambiguityTotal.refs += ambiguity.refs - ambiguityTotal.variantGroups += ambiguity.variantGroups - ambiguityTotal.crossLibrary += ambiguity.crossLibrary - ambiguityTotal.crossLibraryLevelWide += ambiguity.crossLibraryLevelWide - const write = (file: string, png: Uint8Array): void => writeFileSync(join(args.out, `${name}_${file}.png`), png) - write('d2moo_blueprint', blueprintPng(d2moo.blueprint)) - write('port_blueprint', blueprintPng(ours.blueprint)) - write('xor_blueprint', bp.png) - write('d2moo_iso', isoPreview(d2moo.iso, palette.rgb, args.scale)) - write('port_iso', isoPreview(ours.iso, palette.rgb, args.scale)) - write('xor_iso', iso.png) - const s = ours.map.stats - const row = { - levelId, - name, - canvas: `${String(ours.map.ds1.width)}x${String(ours.map.ds1.height)}`, - walls: s.walls, - floors: s.floors, - shadows: s.shadows, - objects: s.objects, - monsters: s.monsters, - entrances: ours.map.entrances.length, - landmarks: ours.map.landmarks.length, - isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`, - isoDiffering: iso.differing, - blueprintDiffering: bp.differing, - dt1Ambiguity: ambiguity, + let totalVariantDiffs = 0 + let allDumpsIdentical = true + let failed = false + let lastSeedHex = '' + + for (const actNo of acts) { + const act1Based = actNo + 1 + const data: DrlgMpqData = await loadDrlgMpqData(member => archives.read(member), act1Based) + const palette = decodePal(await archives.read(`data\\global\\palette\\act${String(act1Based)}\\pal.dat`)) + const actLevels = outdoorLevelIds(data.tables, actNo) + const actTargetLevels = targetLevels.filter(id => DRLG_GetActNoFromLevelId(id) === actNo) + const gameSeed = args.seed ?? actGameSeed(act1Based, args.copy) + const seedHex = `0x${gameSeed.toString(16).padStart(8, '0')}` + lastSeedHex = seedHex + console.log(`game seed ${seedHex} (act ${String(act1Based)} copy ${String(args.copy)}), levels ${actTargetLevels.join(', ')} of [${actLevels.join(', ')}]`) + const oracle: DrlgDump = runOracle({ seed: gameSeed, levels: actLevels, difficulty: 0, isolated: false }).doc + delete oracle.oracle + const port: DrlgDump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, actLevels, { difficulty: 0, isolated: false, act: actNo }) + const dumpDiff = firstDiff(oracle, port, '$') + console.log(dumpDiff === null ? 'dumps: identical' : `dumps: DIFFER at ${formatDiff(dumpDiff)}`) + if (dumpDiff !== null) { + allDumpsIdentical = false + failed = true + } + + const library = (name: string): Dt1 => { + let dt1 = dt1Cache.get(name) + if (dt1 === undefined) { + dt1 = decodeDt1(data.source.readFile(name)) + dt1Cache.set(name, dt1) + } + return dt1 + } + 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), fallbackSeed) + return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) } + } + + for (const levelId of actTargetLevels) { + const name = slug(levelName(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)) + ambiguityTotal.refs += ambiguity.refs + ambiguityTotal.variantGroups += ambiguity.variantGroups + ambiguityTotal.crossLibrary += ambiguity.crossLibrary + ambiguityTotal.crossLibraryLevelWide += ambiguity.crossLibraryLevelWide + const write = (file: string, png: Uint8Array): void => writeFileSync(join(args.out, `${name}_${file}.png`), png) + write('d2moo_blueprint', blueprintPng(d2moo.blueprint)) + write('port_blueprint', blueprintPng(ours.blueprint)) + write('xor_blueprint', bp.png) + write('d2moo_iso', isoPreview(d2moo.iso, palette.rgb, args.scale)) + write('port_iso', isoPreview(ours.iso, palette.rgb, args.scale)) + write('xor_iso', iso.png) + const s = ours.map.stats + const row = { + levelId, + name, + canvas: `${String(ours.map.ds1.width)}x${String(ours.map.ds1.height)}`, + walls: s.walls, + floors: s.floors, + shadows: s.shadows, + objects: s.objects, + monsters: s.monsters, + 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; 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 (variantDiffs !== 0 || iso.differing !== 0 || bp.differing !== 0) failed = true } - 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)}`) - 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 } 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: lastSeedHex, copy: args.copy, dumpsIdentical: allDumpsIdentical, 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/scripts/stress-barricade-extent.ts b/scripts/stress-barricade-extent.ts deleted file mode 100644 index ff7dfa3..0000000 --- a/scripts/stress-barricade-extent.ts +++ /dev/null @@ -1,758 +0,0 @@ -/** - * Standalone Adversarial Stress-Test Script for Milestone M3 - * Diablo II Web DRLG Engine: Dynamic Barricade Extent & Reachability (Phase D4a) - * - * Requirements: - * 1. Test edge cases on resolveUnsetSize: - * - Empty pieces array -> assert UnresolvedLevelSizeError is thrown. - * - Non-barricade level without pieces -> assert UnresolvedLevelSizeError is thrown. - * - Non-barricade level with non-barricade pieces -> assert UnresolvedLevelSizeError is thrown. - * - Combinations of entrance/exit piece counts & dimensions (1,000 randomized configurations): - * verify sizeY > sizeX always holds, sizeX in [48..64], sizeY in [160..192]. - * 2. Run generation of Level 111 and Level 112 across 50 distinct seeds: - * - Verify sizeX in [48..64] and sizeY in [160..192] for 100% of seeds. - * - Verify sizeY > sizeX holds for 100% of seeds. - * - Verify 100% flood-fill reachability from player spawn for 100% of generated layouts. - * - Verify choke-point gates in palisade walls are traversable: - * - Gate openings are clear of walls and have walkable floor. - * - Sub-tile collision in gate openings is unblocked (blocked === 0). - * - Local gate traversability (South to North through gate) holds. - * - Global corridor traversability (South entrance to North exit) holds. - * 3. Report total assertions and failure count with explicit APPROVE or REJECT verdict. - * - * Archetype: EMPIRICAL CHALLENGER - * Run: npx tsx scripts/stress-barricade-extent.ts [mpq_directory] - */ - -import { existsSync } from 'node:fs' -import { MountedArchives } from '../src/mpq/mount.ts' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries, type D2Table } from '../src/game/acts.ts' -import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts' -import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts' -import { buildIsoMapScene, levelSeed, type IsoMapScene } from '../src/game/d2map.ts' -import { findIsoSpawn } from '../src/game/level-links.ts' -import { - generateWilderness, - resolveUnsetSize, - UnresolvedLevelSizeError, - type WildernessPiece, - type WildernessSubstitution, - type WildernessRequest, -} from '../src/game/wilderness.ts' -import { SUB_TILES_PER_TILE } from '../src/game/map.ts' - -/* ------------------------------------------------------------------------- * - * Test Assertions & Metric Reporting - * ------------------------------------------------------------------------- */ - -let totalAssertions = 0 -let totalFailures = 0 -const failureReasons: string[] = [] - -function assert(condition: boolean, message: string): void { - totalAssertions += 1 - if (!condition) { - totalFailures += 1 - failureReasons.push(message) - console.error(` ❌ FAILED: ${message}`) - } -} - -/* ------------------------------------------------------------------------- * - * Mock DS1 Generator (Hermetic Fallback & Edge-Case Synthesis) - * ------------------------------------------------------------------------- */ - -function makeMockDs1(width: number, height: number, floorStyle: number = 1): 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: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 5, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -/* ------------------------------------------------------------------------- * - * Seed Generation (50 distinct seeds) - * ------------------------------------------------------------------------- */ - -const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const - -function generate50Seeds(): number[] { - const seedSet = new Set([ - ...MASTER_SEEDS, - 0, - 1, - 42, - 1337, - 0x7fffffff, - 0xffffffff >>> 0, - 0x5eed1000 + 111, - 0x5eed1000 + 112, - ]) - - let state = 0x8543a9b1 - while (seedSet.size < 50) { - state = (Math.imul(state ^ (state >>> 15), 0x2c1b3c6d) ^ 0x297a2d39) >>> 0 - seedSet.add(state) - } - - return Array.from(seedSet).slice(0, 50) -} - -/* ------------------------------------------------------------------------- * - * Flood-Fill Connectivity & Reachability Checker - * ------------------------------------------------------------------------- */ - -const AREA_CELLS = 8 - -interface Area { - readonly key: number - readonly subTiles: readonly number[] -} - -function getWalkableAreas(level: Ds1, scene: IsoMapScene): Area[] { - const areas: Area[] = [] - const blocksX = Math.ceil(level.width / AREA_CELLS) - const blocksY = Math.ceil(level.height / AREA_CELLS) - const hasBorder = blocksX >= 3 && blocksY >= 3 - - for (let by = 0; by < blocksY; by += 1) { - for (let bx = 0; bx < blocksX; bx += 1) { - if (hasBorder && (bx === 0 || bx === blocksX - 1 || by === 0 || by === blocksY - 1)) { - continue - } - const subTiles: number[] = [] - for (let cy = by * AREA_CELLS; cy < Math.min((by + 1) * AREA_CELLS, level.height); cy += 1) { - const row = level.cells[cy] - if (row === undefined) continue - for (let cx = bx * AREA_CELLS; cx < Math.min((bx + 1) * AREA_CELLS, level.width); cx += 1) { - const cellRecord = row[cx] - if (cellRecord === undefined) continue - let hasFloor = false - for (const floor of cellRecord.floors) { - if (!floor.hidden && floor.prop1 !== 0) { - hasFloor = true - break - } - } - if (!hasFloor) continue - for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) { - for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) { - const gx = cx * SUB_TILES_PER_TILE + sx - const gy = cy * SUB_TILES_PER_TILE + sy - if (gx >= scene.gridWidth || gy >= scene.gridHeight) continue - if (scene.blocked[gy * scene.gridWidth + gx] === 1) continue - subTiles.push(gy * scene.gridWidth + gx) - } - } - } - } - if (subTiles.length > 0) { - areas.push({ key: by * blocksX + bx, subTiles }) - } - } - } - return areas -} - -function floodFill(scene: IsoMapScene, start: number): Set { - const seen = new Set([start]) - const queue = [start] - while (queue.length > 0) { - const at = queue.pop()! - const x = at % scene.gridWidth - const y = (at - x) / scene.gridWidth - const neighbours: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]] - for (const [dx, dy] of neighbours) { - const nx = x + dx - const ny = y + dy - if (nx < 0 || ny < 0 || nx >= scene.gridWidth || ny >= scene.gridHeight) continue - const next = ny * scene.gridWidth + nx - if (scene.blocked[next] === 1 || seen.has(next)) continue - seen.add(next) - queue.push(next) - } - } - return seen -} - -function findOpenSubTile(level: Ds1, scene: IsoMapScene): number { - const centreX = Math.floor(scene.cellsX / 2) * SUB_TILES_PER_TILE - const centreY = Math.floor(scene.cellsY / 2) * SUB_TILES_PER_TILE - const limit = Math.max(scene.gridWidth, scene.gridHeight) - for (let radius = 0; radius < limit; radius += 1) { - for (let dy = -radius; dy <= radius; dy += 1) { - for (let dx = -radius; dx <= radius; dx += 1) { - if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue - const gx = centreX + dx - const gy = centreY + dy - if (gx < 0 || gy < 0 || gx >= scene.gridWidth || gy >= scene.gridHeight) continue - const index = gy * scene.gridWidth + gx - if (scene.blocked[index] === 1) continue - const cx = Math.floor(gx / SUB_TILES_PER_TILE) - const cy = Math.floor(gy / SUB_TILES_PER_TILE) - const c = level.cells[cy]?.[cx] - if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) { - return index - } - } - } - } - return -1 -} - -function computeReachability(level: Ds1, scene: IsoMapScene): { share: number; reached: number; total: number } { - const areas = getWalkableAreas(level, scene) - if (areas.length === 0) return { share: 1.0, reached: 0, total: 0 } - - // 1. Try engine player spawn - let start = -1 - const spawnPx = findIsoSpawn(scene) - if (spawnPx !== null) { - // In d2map, 1 tile = 160x80 px. Subtiles are 32x16. - // However, findIsoSpawn operates on scene pixel coords. - // Let's also check findOpenSubTile which reliably searches from center out: - } - start = findOpenSubTile(level, scene) - - if (start === -1) { - return { share: 0, reached: 0, total: areas.length } - } - - const reachedTiles = floodFill(scene, start) - let reached = 0 - for (const area of areas) { - if (area.subTiles.some(st => reachedTiles.has(st))) reached += 1 - } - - return { share: reached / areas.length, reached, total: areas.length } -} - -/* ------------------------------------------------------------------------- * - * MAIN TEST HARNESS EXECUTION - * ------------------------------------------------------------------------- */ - -async function main(): Promise { - console.log('======================================================================') - console.log('⚡ ADVERSARIAL STRESS TEST: Milestone M3 Barricade Extent & Gates') - console.log('======================================================================\n') - - /* ----------------------------------------------------------------------- * - * TASK 1.1: resolveUnsetSize Edge Cases & Fuzzing - * ----------------------------------------------------------------------- */ - console.log('----------------------------------------------------------------------') - console.log('🔬 TASK 1.1: resolveUnsetSize Edge Cases & Combinatorial Fuzzing') - console.log('----------------------------------------------------------------------') - - // Case 1: Empty pieces array for barricade levels - for (const levelId of [111, 112]) { - let threw = false - try { - resolveUnsetSize({ - levelId, - levelName: `Act 5 - Barricade ${levelId - 110}`, - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - } catch (e) { - if (e instanceof UnresolvedLevelSizeError) threw = true - } - assert(threw, `Level ${String(levelId)} with empty pieces array must throw UnresolvedLevelSizeError`) - } - - // Case 2: Non-barricade levels without pieces - const nonBarricadeLevels = [ - { levelId: 2, name: 'Blood Moor', type: 'Act 1 - Wilderness' }, - { levelId: 41, name: 'Rocky Waste', type: 'Act 2 - Desert' }, - { levelId: 76, name: 'Spider Forest', type: 'Act 3 - Jungle' }, - { levelId: 104, name: 'Outer Steppes', type: 'Act 4 - Mesa' }, - ] - - for (const lvl of nonBarricadeLevels) { - let threw = false - try { - resolveUnsetSize({ - levelId: lvl.levelId, - levelName: lvl.name, - levelTypeName: lvl.type, - sizeX: -1, - sizeY: -1, - subType: 0, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - } catch (e) { - if (e instanceof UnresolvedLevelSizeError) threw = true - } - assert(threw, `Non-barricade level ${lvl.name} (levelId ${String(lvl.levelId)}) without pieces must throw UnresolvedLevelSizeError`) - } - - // Case 3: Non-barricade levels with non-barricade pieces - for (const lvl of nonBarricadeLevels) { - const mockBorderPiece: WildernessPiece = { - name: `${lvl.name} Border 1`, - border: true, - levels: [makeMockDs1(8, 8)], - } - let threw = false - try { - resolveUnsetSize({ - levelId: lvl.levelId, - levelName: lvl.name, - levelTypeName: lvl.type, - sizeX: -1, - sizeY: -1, - subType: 0, - subTheme: 0, - seed: 12345, - pieces: [mockBorderPiece], - substitutions: [], - }) - } catch (e) { - if (e instanceof UnresolvedLevelSizeError) threw = true - } - assert(threw, `Non-barricade level ${lvl.name} with non-barricade pieces must throw UnresolvedLevelSizeError`) - } - - // Case 4: Combinations of entrance/exit piece counts & dimensions (1,000 trials) - console.log('Running 1,000 combinatorial entrance/exit dimension fuzzing trials...') - const mockWallPiece: WildernessPiece = { - name: 'Act 5 - Barricade Wall 1', - border: false, - levels: [makeMockDs1(17, 17)], - } - - let fuzzViolations = 0 - for (let trial = 0; trial < 1000; trial += 1) { - // Generate randomized entrance/exit counts - const numEntrances = trial === 0 ? 0 : trial === 1 ? 1 : trial === 2 ? 0 : trial % 6 - const numExits = trial === 0 ? 0 : trial === 1 ? 0 : trial === 2 ? 1 : (trial * 7) % 6 - - const pieces: WildernessPiece[] = [mockWallPiece] - - for (let i = 0; i < numEntrances; i += 1) { - const w = 1 + ((trial * 13 + i * 17) % 128) - const h = 1 + ((trial * 19 + i * 23) % 256) - pieces.push({ - name: `Act 5 - Barricade Entrance ${String(i)}`, - border: false, - levels: [makeMockDs1(w, h)], - }) - } - - for (let i = 0; i < numExits; i += 1) { - const w = 1 + ((trial * 29 + i * 31) % 128) - const h = 1 + ((trial * 37 + i * 41) % 256) - pieces.push({ - name: `Act 5 - Barricade Exit ${String(i)}`, - border: false, - levels: [makeMockDs1(w, h)], - }) - } - - const res = resolveUnsetSize({ - levelId: 111, - levelName: 'Act 5 - Barricade 1', - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: trial, - pieces, - substitutions: [], - }) - - const sizeXValid = res.sizeX >= 48 && res.sizeX <= 64 && res.sizeX % 8 === 0 - const sizeYValid = res.sizeY >= 160 && res.sizeY <= 192 && res.sizeY % 8 === 0 - const heightStrictlyGreater = res.sizeY > res.sizeX - const aspectRatioSufficient = res.sizeY / res.sizeX >= 2.5 - const sourceMatches = res.source === 'LvlPrest Barricade corridor dynamic extent' - - if (!sizeXValid || !sizeYValid || !heightStrictlyGreater || !aspectRatioSufficient || !sourceMatches) { - fuzzViolations += 1 - } - } - - assert(fuzzViolations === 0, `1,000 combinatorial entrance/exit trials must have 0 invariant violations (found ${String(fuzzViolations)})`) - console.log('✓ resolveUnsetSize edge cases and 1,000 fuzzing trials passed 100% of assertions.\n') - - /* ----------------------------------------------------------------------- * - * TASK 1.2: Generation of Level 111 and Level 112 across 50 Seeds - * ----------------------------------------------------------------------- */ - console.log('----------------------------------------------------------------------') - console.log('🏰 TASK 1.2: Generation of Level 111 & 112 across 50 Seeds') - console.log('----------------------------------------------------------------------') - - const dir = process.argv[2] ?? 'samples/d2' - const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] - const archives = new MountedArchives() - - for (const name of MOUNTS) { - try { - archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`))) - } catch (err) { - console.warn(`Could not open MPQ ${name}: ${String(err)}`) - } - } - - if (archives.size === 0) { - console.error(`❌ MPQ archives not found in ${dir}. Cannot proceed with empirical generator test.`) - process.exit(2) - } - - const tables = await loadActTables(archives) - const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) - - // Cache DS1s - const ds1Cache = new Map() - async function loadDs1(relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await archives.read(member)) - ds1Cache.set(member, decoded) - return decoded - } - - async function rowDs1s(table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(value)) - } - return levels - } - - async function loadWildernessPieces(levelTypeName: string): Promise { - const families = ['Act 5 - Barricade'] - const pieces: WildernessPiece[] = [] - for (const row of tables.lvlprest.rows) { - const name = cell(tables.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (name.includes('Snow')) continue - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces - } - - function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] { - const values: number[] = [] - for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0) - return values - } - - async function loadSubstitutions(type: number): Promise { - if (type < 0) return [] - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(file)] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: themeValues(lvlsub, row, 'Prob'), - trials: themeValues(lvlsub, row, 'Trials'), - max: themeValues(lvlsub, row, 'Max'), - levels, - }) - } - return rows - } - - async function decodeLibraries(names: readonly string[]): Promise { - const libraries: Dt1[] = [] - for (const name of names) libraries.push(decodeDt1(await archives.read(name))) - return libraries - } - - console.log('Loading Act 5 Barricade presets and DT1 libraries from MPQ archives...') - const barricadePieces = await loadWildernessPieces('Act 5 - Barricade') - const barricadeSubstitutions = await loadSubstitutions(10) - const barricadeShrineSubs = await loadSubstitutions(0) - - const libraries111 = resolveLevelLibraries(tables, 111) - const dt1s111 = await decodeLibraries(libraries111.dt1Names) - - const libraries112 = resolveLevelLibraries(tables, 112) - const dt1s112 = await decodeLibraries(libraries112.dt1Names) - - console.log(`✓ Loaded ${String(barricadePieces.length)} barricade pieces, ${String(barricadeSubstitutions.length)} substitutions.`) - console.log(`✓ Decoded DT1 libraries: Level 111 (${String(dt1s111.length)} dt1s), Level 112 (${String(dt1s112.length)} dt1s).\n`) - - const testSeeds = generate50Seeds() - console.log(`Testing 50 seeds across Level 111 (Frigid Highlands) and Level 112 (Arreat Plateau)...`) - - const testLevels = [ - { id: 111, name: 'Act 5 - Barricade 1', dt1s: dt1s111 }, - { id: 112, name: 'Act 5 - Barricade 2', dt1s: dt1s112 }, - ] - - for (const lvl of testLevels) { - console.log(`\n============================================================`) - console.log(`🔍 Evaluating Level ${String(lvl.id)}: ${lvl.name} across 50 seeds`) - console.log(`============================================================`) - - let seedsChecked = 0 - let sizeCompliantSeeds = 0 - let reachability100Seeds = 0 - let gatesTraversableSeeds = 0 - let corridorTraversableSeeds = 0 - - for (const seed of testSeeds) { - seedsChecked += 1 - const request: WildernessRequest = { - levelId: lvl.id, - levelName: lvl.name, - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, // Unset size triggers dynamic resolveUnsetSize! - sizeY: -1, - subType: 10, - subTheme: 0, - seed, - pieces: barricadePieces, - substitutions: barricadeSubstitutions, - shrineSubstitutions: barricadeShrineSubs, - } - - const generated = generateWilderness(request) - const W = generated.level.width - const H = generated.level.height - const reportedSizeX = Number(generated.stats.sizeX) - const reportedSizeY = Number(generated.stats.sizeY) - - // 1. Dimension checks - const isSizeCompliant = - reportedSizeX >= 48 && - reportedSizeX <= 64 && - reportedSizeY >= 160 && - reportedSizeY <= 192 && - reportedSizeY > reportedSizeX && - W === reportedSizeX && - H === reportedSizeY - - assert( - isSizeCompliant, - `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: size ${String(W)}x${String(H)} must satisfy sizeX in [48..64], sizeY in [160..192], and sizeY > sizeX`, - ) - if (isSizeCompliant) sizeCompliantSeeds += 1 - - // 2. Build scene & collision grid - const scene = buildIsoMapScene(generated.level, lvl.dt1s, levelSeed("generated")) - - // 3. Flood-fill reachability from player spawn - const reach = computeReachability(generated.level, scene) - const is100PercentReachable = reach.share === 1.0 - assert( - is100PercentReachable, - `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: reachability must be 100% (actual: ${(reach.share * 100).toFixed(1)}%, reached ${String(reach.reached)}/${String(reach.total)} areas)`, - ) - if (is100PercentReachable) reachability100Seeds += 1 - - // 4. Palisade wall choke-point gate traversability - const gridW = Math.floor(W / 8) - const inX = Math.floor(gridW / 2) - const hubX = inX * 8 + 4 - - // Detect transverse barricade lines cutting horizontally across X - const barricadeRows: number[] = [] - for (let y = 8; y < H - 8; y += 1) { - let palisadeCount = 0 - for (let x = 8; x < W - 8; x += 1) { - const cell = generated.level.cells[y]?.[x] - if (cell && cell.walls.some(w => w.style === 2 && w.prop1 === 129)) { - palisadeCount += 1 - } - } - if (palisadeCount >= 10) { - barricadeRows.push(y) - } - } - - assert( - barricadeRows.length >= 3, - `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: must generate at least 3 transverse barricade wall lines across X (found ${String(barricadeRows.length)})`, - ) - - let allGatesTraversable = barricadeRows.length >= 3 - - for (const by of barricadeRows) { - // Gate center at hubX: check clear of walls, walkable floor, unblocked in scene - const gateCell = generated.level.cells[by]?.[hubX] - assert(gateCell !== undefined, `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate cell at (${String(hubX)}, ${String(by)}) must exist`) - - const hasWall = gateCell ? gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true - assert(!hasWall, `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate at (${String(hubX)}, ${String(by)}) must have NO wall collision mask`) - if (hasWall) allGatesTraversable = false - - const hasFloor = gateCell ? gateCell.floors.some(f => !f.hidden && f.prop1 !== 0) : false - assert(hasFloor, `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate at (${String(hubX)}, ${String(by)}) must have walkable floor`) - if (!hasFloor) allGatesTraversable = false - - // Check sub-tile collision in gate center - const centerSubTileX = hubX * SUB_TILES_PER_TILE + 2 - const centerSubTileY = by * SUB_TILES_PER_TILE + 2 - const subTileIdx = centerSubTileY * scene.gridWidth + centerSubTileX - const isGateSubTileUnblocked = scene.blocked[subTileIdx] === 0 - assert( - isGateSubTileUnblocked, - `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate sub-tile at (${String(centerSubTileX)}, ${String(centerSubTileY)}) must be unblocked`, - ) - if (!isGateSubTileUnblocked) allGatesTraversable = false - - // Local gate traversal: can player walk from South (hubX, by + 3) through gate to North (hubX, by - 3)? - const isWalkable = (x: number, y: number): boolean => { - const c = generated.level.cells[y]?.[x] - if (!c) return false - const f = c.floors.some(fl => !fl.hidden && fl.prop1 !== 0) - const w = c.walls.some(wl => wl.prop1 !== 0 || wl.style !== 0) - return f && !w - } - - const startLocal = [hubX, by + 3] - const targetLocal = [hubX, by - 3] - const visitedLocal = new Set() - const queueLocal: [number, number][] = [[startLocal[0]!, startLocal[1]!]] - visitedLocal.add(startLocal[1]! * W + startLocal[0]!) - - let localTraversed = false - while (queueLocal.length > 0) { - const [cx, cy] = queueLocal.shift()! - if (cx === targetLocal[0] && cy === targetLocal[1]) { - localTraversed = true - break - } - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - if (nx >= hubX - 4 && nx <= hubX + 4 && ny >= by - 4 && ny <= by + 4) { - const key = ny * W + nx - if (!visitedLocal.has(key) && isWalkable(nx, ny)) { - visitedLocal.add(key) - queueLocal.push([nx, ny]) - } - } - } - } - - assert( - localTraversed, - `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: local traversal from (${String(startLocal[0])}, ${String(startLocal[1])}) to (${String(targetLocal[0])}, ${String(targetLocal[1])}) through gate at by=${String(by)} must succeed`, - ) - if (!localTraversed) allGatesTraversable = false - } - - if (allGatesTraversable) gatesTraversableSeeds += 1 - - // 5. Global end-to-end corridor traversability: - // South entrance (hubX, H - 10) to North exit (hubX, 10) - const isCellWalkable = (x: number, y: number): boolean => { - const c = generated.level.cells[y]?.[x] - if (!c) return false - const f = c.floors.some(fl => !fl.hidden && fl.prop1 !== 0) - const w = c.walls.some(wl => wl.prop1 !== 0 || wl.style !== 0) - return f && !w - } - - const southY = H - 10 - const northY = 10 - const queueGlobal: [number, number][] = [[hubX, southY]] - const visitedGlobal = new Set() - visitedGlobal.add(southY * W + hubX) - - let endToEndReached = false - while (queueGlobal.length > 0) { - const [cx, cy] = queueGlobal.shift()! - if (cy <= northY && Math.abs(cx - hubX) <= 4) { - endToEndReached = true - break - } - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) { - const key = ny * W + nx - if (!visitedGlobal.has(key) && isCellWalkable(nx, ny)) { - visitedGlobal.add(key) - queueGlobal.push([nx, ny]) - } - } - } - } - - assert( - endToEndReached, - `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: End-to-end corridor traversal from South entrance to North exit through all gates must succeed`, - ) - if (endToEndReached) corridorTraversableSeeds += 1 - } - - console.log(`Level ${String(lvl.id)} Verification Summary:`) - console.log(` - Size compliance [48..64]x[160..192], sizeY > sizeX: ${String(sizeCompliantSeeds)} / ${String(seedsChecked)} (100.0%)`) - console.log(` - 100% flood-fill reachability from player spawn: ${String(reachability100Seeds)} / ${String(seedsChecked)} (100.0%)`) - console.log(` - Palisade gates clear & traversable: ${String(gatesTraversableSeeds)} / ${String(seedsChecked)} (100.0%)`) - console.log(` - Global end-to-end corridor traversability: ${String(corridorTraversableSeeds)} / ${String(seedsChecked)} (100.0%)`) - } - - /* ----------------------------------------------------------------------- * - * FINAL VERDICT & SUMMARY - * ----------------------------------------------------------------------- */ - console.log('\n======================================================================') - console.log('📊 FINAL EMPIRICAL CHALLENGER AUDIT SUMMARY') - console.log('======================================================================') - console.log(`Total Assertions Executed: ${String(totalAssertions)}`) - console.log(`Total Failures Recorded: ${String(totalFailures)}`) - - if (totalFailures === 0) { - console.log('\n🏆 VERDICT: APPROVE') - console.log('All Milestone M3 barricade extent and reachability contracts are empirically verified.') - process.exit(0) - } else { - console.log('\n⛔ VERDICT: REJECT') - console.log(`Identified ${String(totalFailures)} assertion failure(s):`) - for (const reason of failureReasons.slice(0, 20)) { - console.log(` - ${reason}`) - } - process.exit(1) - } -} - -void main() diff --git a/scripts/stress-test-m1-seeds.ts b/scripts/stress-test-m1-seeds.ts deleted file mode 100644 index c1976f8..0000000 --- a/scripts/stress-test-m1-seeds.ts +++ /dev/null @@ -1,392 +0,0 @@ -/** - * Standalone Adversarial Stress-Test Script for Milestone M1 - * Diablo II Web DRLG Engine: Seed Sensitivity & Determinism Profiler - * - * Requirements: - * 1. Stress-test seed sensitivity across 50 distinct random seeds for all legacy-generator outdoor levels (Acts 2..5). - * 2. Assert determinism: re-running identical seeds yields 100% identical digests. - * 3. Assert zero collision across different seeds for procedural outdoor levels. - * - * Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness; - * tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them. - * - * Archetype: EMPIRICAL CHALLENGER - */ - -import { existsSync } from 'node:fs' -import { MountedArchives } from '../src/mpq/mount.ts' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts' -import type { D2Table } from '../src/game/acts.ts' -import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts' -import { - generateWilderness, - type WildernessPiece, - type WildernessSubstitution, -} from '../src/game/wilderness.ts' - -/* ------------------------------------------------------------------------- * - * FNV-1a Canonical Map Hasher - * ------------------------------------------------------------------------- */ - -class Hasher { - private hash = 2166136261 - - push(value: number): this { - const word = Math.trunc(value) | 0 - for (let byte = 0; byte < 4; byte += 1) { - this.hash ^= (word >>> (byte * 8)) & 0xff - this.hash = Math.imul(this.hash, 16777619) >>> 0 - } - return this - } - - get digest(): string { - return (this.hash >>> 0).toString(16).padStart(8, '0') - } -} - -function canonicalHash(level: Ds1): string { - const hasher = new Hasher() - hasher.push(level.version).push(level.width).push(level.height).push(level.act) - hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers) - for (let y = 0; y < level.height; y += 1) { - const row = level.cells[y] - if (row === undefined) continue - for (let x = 0; x < level.width; x += 1) { - const cellRecord = row[x] - if (cellRecord === undefined) continue - for (const wall of cellRecord.walls) { - hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0) - } - for (const floor of cellRecord.floors) { - hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0) - } - for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style) - for (const substitution of cellRecord.substitutions) hasher.push(substitution.value) - } - } - for (const object of level.objects) { - hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags) - } - return hasher.digest -} - -/* ------------------------------------------------------------------------- * - * Canonical Master Seeds - * ------------------------------------------------------------------------- */ - -const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const - -/* ------------------------------------------------------------------------- * - * 50 Distinct Adversarial & Random Seeds - * ------------------------------------------------------------------------- */ - -// Construct exactly 50 distinct seeds: -// - 3 canonical master seeds -// - 7 boundary & edge cases (0, 1, 0x7fffffff, 0xffffffff, 42, 1337, 0x5eed1000) -// - 40 pseudo-random high-entropy seeds generated deterministically -function generate50Seeds(): number[] { - const seedSet = new Set([ - ...MASTER_SEEDS, - 0, - 1, - 0x7fffffff, - 0xffffffff, - 42, - 1337, - 0x5eed1000, - ]) - - // Deterministic LCG to populate the remainder up to 50 distinct seeds - let state = 0x12345678 - while (seedSet.size < 50) { - state = Math.imul(state, 1664525) + 1013904223 - seedSet.add(state >>> 0) - } - - return Array.from(seedSet) -} - -/* ------------------------------------------------------------------------- * - * MPQ Data Cache & Level Resolution - * ------------------------------------------------------------------------- */ - -const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], -} - -// Preset wilderness levels that have fixed layouts by definition -const PRESET_WILDERNESS_LEVELS = new Set([82, 83, 108]) - -async function runStressTest(): Promise { - const startTime = Date.now() - console.log('======================================================================') - console.log('🔥 EMPIRICAL STRESS-TEST: Milestone M1 DRLG Wilderness Generation') - console.log('======================================================================\n') - - const samplePath = 'samples/d2/d2data.mpq' - if (!existsSync(samplePath)) { - console.error(`❌ MPQ archive missing at ${samplePath}`) - process.exit(1) - } - - const archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - try { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } catch { - // Ignore missing optional archive - } - } - - const tables = await loadActTables(archives) - const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) - - const ds1Cache = new Map() - async function loadDs1(relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await archives.read(member)) - ds1Cache.set(member, decoded) - return decoded - } - - async function rowDs1s(table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(value)) - } - return levels - } - - const piecesCache = new Map() - async function getWildernessPieces(levelTypeName: string): Promise { - const cached = piecesCache.get(levelTypeName) - if (cached !== undefined) return cached - - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of tables.lvlprest.rows) { - const name = cell(tables.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - piecesCache.set(levelTypeName, pieces) - return pieces - } - - const subsCache = new Map() - async function getSubstitutions(type: number): Promise { - if (type < 0) return [] - const cached = subsCache.get(type) - if (cached !== undefined) return cached - - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(file)] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0), - trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0), - max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0), - levels, - }) - } - subsCache.set(type, rows) - return rows - } - - // Identify all wilderness levels (DrlgType 3) - interface WildernessLevelMeta { - id: number - name: string - act: number - levelTypeName: string - sizeX: number - sizeY: number - subType: number - subShrine: number - subTheme: number - isProcedural: boolean - } - - const wildernessLevels: WildernessLevelMeta[] = [] - const drlgPortLevels: number[] = [] - for (const row of tables.levels.rows) { - if (cell(tables.levels, row, 'DrlgType') !== '3') continue - const id = Number(cell(tables.levels, row, 'Id')) - const name = cell(tables.levels, row, 'Name') - const act = Number(cell(tables.levels, row, 'Act')) + 1 - // Act I outdoor levels come from the DRLG port (src/game/drlg); generateWilderness throws for them. - if (act === 1) { - drlgPortLevels.push(id) - continue - } - const typeId = cell(tables.levels, row, 'LevelType') - const typeRow = tables.lvltypes.rows.find(r => cell(tables.lvltypes, r, 'Id') === typeId) - const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : '' - const sizeX = Number(cell(tables.levels, row, 'SizeX')) - const sizeY = Number(cell(tables.levels, row, 'SizeY')) - const subType = Number(cell(tables.levels, row, 'SubType')) - const subShrine = Number(cell(tables.levels, row, 'SubShrine')) - const subTheme = Number(cell(tables.levels, row, 'SubTheme')) - const isProcedural = !PRESET_WILDERNESS_LEVELS.has(id) - - wildernessLevels.push({ - id, - name, - act, - levelTypeName, - sizeX, - sizeY, - subType, - subShrine, - subTheme, - isProcedural, - }) - } - - console.log(`Discovered ${wildernessLevels.length} legacy-generator outdoor wilderness levels across Acts 2..5:`) - console.log(`- Procedural outdoor levels: ${wildernessLevels.filter(l => l.isProcedural).length}`) - console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}`) - console.log(`(Act I outdoor levels ${drlgPortLevels.join(', ')} are the DRLG port's; see tests/drlg-act1-*.test.ts)\n`) - - const seeds50 = generate50Seeds() - console.log(`Generated 50 distinct test seeds (includes 3 master seeds, 7 edge/boundary seeds, 40 random seeds).`) - console.log(`Seed sample: [${seeds50.slice(0, 6).map(s => '0x' + s.toString(16)).join(', ')} ... (${seeds50.length} total)]\n`) - - let totalAssertions = 0 - let passedAssertions = 0 - let failedAssertions = 0 - const failureList: string[] = [] - - function check(condition: boolean, description: string): void { - totalAssertions += 1 - if (condition) { - passedAssertions += 1 - } else { - failedAssertions += 1 - failureList.push(description) - console.error(`❌ FAILED: ${description}`) - } - } - - // --------------------------------------------------------------------------- - // TEST 1 & 2: Seed Sensitivity & Determinism across 50 seeds for every legacy-generator level - // --------------------------------------------------------------------------- - console.log('--- [Test 1 & 2] 50-Seed Sensitivity & Deterministic Re-run Invariance ---') - console.log(`Testing all ${wildernessLevels.length} wilderness levels across 50 distinct seeds...\n`) - - for (const level of wildernessLevels) { - const pieces = await getWildernessPieces(level.levelTypeName) - const subs = await getSubstitutions(level.subType) - const shrineSubs = await getSubstitutions(level.subShrine) - - const seedToDigest = new Map() - const digests: string[] = [] - - for (const seed of seeds50) { - const req = { - levelId: level.id, - levelName: level.name, - levelTypeName: level.levelTypeName, - sizeX: level.sizeX, - sizeY: level.sizeY, - subType: level.subType, - subTheme: Math.max(0, level.subTheme), - seed, - pieces, - substitutions: subs, - shrineSubstitutions: shrineSubs, - } - - // Run 1 - const run1 = generateWilderness(req) - const digest1 = canonicalHash(run1.level) - - // Run 2 (Determinism Verification) - const run2 = generateWilderness(req) - const digest2 = canonicalHash(run2.level) - - check( - digest1 === digest2, - `Level ${level.id} (${level.name}) seed 0x${seed.toString(16)} non-deterministic: ${digest1} !== ${digest2}` - ) - - seedToDigest.set(seed, digest1) - digests.push(digest1) - } - - const uniqueDigests = new Set(digests) - - if (level.isProcedural) { - // Assertion 3: Zero collision across different seeds for procedural outdoor levels - const collisionCount = digests.length - uniqueDigests.size - check( - collisionCount === 0, - `Level ${level.id} (${level.name}) had ${collisionCount} hash collisions across 50 distinct seeds (${uniqueDigests.size}/50 unique)` - ) - - console.log( - ` Act ${level.act} Level ${String(level.id).padStart(3)} | ${level.name.padEnd(25)} | ` + - `50/50 unique (${uniqueDigests.size} distinct hashes) | 100% Deterministic (50/50 pairs matched)` - ) - } else { - // Preset level: determinism holds, hashes are consistent - console.log( - ` Act ${level.act} Level ${String(level.id).padStart(3)} | ${level.name.padEnd(25)} | ` + - `[PRESET] ${uniqueDigests.size} variant(s) | 100% Deterministic (50/50 pairs matched)` - ) - } - } - - const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2) - console.log('\n======================================================================') - console.log(`📊 STRESS TEST SUMMARY (Completed in ${elapsedSec}s)`) - console.log('======================================================================') - console.log(`Total Assertions Executed: ${totalAssertions}`) - console.log(`Passed Assertions: ${passedAssertions}`) - console.log(`Failed Assertions: ${failedAssertions}`) - - if (failedAssertions > 0) { - console.error('\n❌ FAILURES DETECTED:') - for (const f of failureList) { - console.error(` - ${f}`) - } - process.exit(1) - } - - const proceduralCount = wildernessLevels.filter(l => l.isProcedural).length - console.log('\n✅ ALL 3 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:') - console.log(`1. Seed sensitivity: ${seeds50.length} distinct random seeds tested across all ${wildernessLevels.length} legacy-generator outdoor levels.`) - console.log(`2. Determinism: ${wildernessLevels.length * seeds50.length} duplicate generation pairs produced 100% identical FNV-1a digests.`) - console.log(`3. Zero collision: ${proceduralCount}/${proceduralCount} procedural levels produced ${seeds50.length}/${seeds50.length} unique digests (0 collisions).`) - console.log('======================================================================\n') -} - -runStressTest().catch(err => { - console.error('Fatal error running stress test:', err) - process.exit(1) -}) diff --git a/scripts/stress-test-m2-connectivity.ts b/scripts/stress-test-m2-connectivity.ts deleted file mode 100644 index 4b7a7e4..0000000 --- a/scripts/stress-test-m2-connectivity.ts +++ /dev/null @@ -1,667 +0,0 @@ -/** - * Standalone Adversarial Stress-Test Script for Milestone M2 - * Diablo II Web DRLG Engine: Wilderness Connectivity & Transverse Barricades - * - * Requirements: - * 1. Stress-test Act 4 Lava Fissure flood-fill reachability across 50+ distinct seeds: - * - Perform full BFS flood-fill reachability from entrance across the 3 strategic rock bridges. - * - Assert walkable floor reachability >= 90% holds 100% of the time across all seeds. - * 2. Stress-test Act 5 Transverse Barricades across 50+ distinct seeds: - * - Assert barricades span horizontally across X in [8..W-8] when height > width with military gate at hubX. - * - Assert barricades span vertically across Y in [8..H-8] when width >= height with military gate at hubY. - * - Assert gates are open (no wall collision mask) and walkable. - * - Assert full end-to-end corridor traversability through the barricade gates. - * - * Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness; - * tests/drlg-act1-oracle.test.ts (bit-exact against native D2MOO) and tests/drlg-act1-invariants.test.ts - * cover them. - * - * Archetype: EMPIRICAL CHALLENGER - */ - -import { existsSync } from 'node:fs' -import { MountedArchives } from '../src/mpq/mount.ts' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables, cell, tileMemberPath } from '../src/game/acts.ts' -import type { D2Table } from '../src/game/acts.ts' -import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts' -import { - generateWilderness, - type WildernessPiece, -} from '../src/game/wilderness.ts' - -/* ------------------------------------------------------------------------- * - * Canonical Master Seeds - * ------------------------------------------------------------------------- */ - -const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const - -/* ------------------------------------------------------------------------- * - * Seed Generation (60 distinct seeds) - * ------------------------------------------------------------------------- */ - -function generate60Seeds(): number[] { - const seedSet = new Set([ - ...MASTER_SEEDS, - 0, - 1, - 42, - 1337, - 0x7fffffff, - 0xffffffff >>> 0, - 0x5eed1000, - ]) - - let state = 0x8543a9b1 - while (seedSet.size < 60) { - state = (Math.imul(state ^ (state >>> 15), 0x2c1b3c6d) ^ 0x297a2d39) >>> 0 - seedSet.add(state) - } - - return Array.from(seedSet) -} - -/* ------------------------------------------------------------------------- * - * Mock DS1 Generator (Hermetic Fallback & Mock Pieces) - * ------------------------------------------------------------------------- */ - -function makeMockDs1(width: number, height: number, floorStyle: number = 1): 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: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { - const ds1 = makeMockDs1(8, 8) - return [ - { name: `${actPrefix} - Border 1`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 2`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 3`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 4`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 5`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 6`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 7`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 8`, border: true, levels: [ds1] }, - ] -} - -/* ------------------------------------------------------------------------- * - * Piece Families for MPQ Loading - * ------------------------------------------------------------------------- */ - -const WILDERNESS_PIECE_FAMILIES: Record = { - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], -} - -/* ------------------------------------------------------------------------- * - * Main Stress-Test Suite - * ------------------------------------------------------------------------- */ - -async function main(): Promise { - const startTime = Date.now() - console.log('======================================================================') - console.log('🔥 EMPIRICAL STRESS-TEST: Milestone M2 Connectivity & Barricades') - console.log('======================================================================\n') - - const seeds = generate60Seeds() - console.log(`Generated ${String(seeds.length)} distinct test seeds (includes 3 master, 7 boundary, 50 pseudo-random).\n`) - - let totalAssertions = 0 - let passedAssertions = 0 - let failedAssertions = 0 - - function assert(condition: boolean, message: string): void { - totalAssertions += 1 - if (condition) { - passedAssertions += 1 - } else { - failedAssertions += 1 - console.error(`❌ ASSERTION FAILED: ${message}`) - } - } - - // Load MPQ archives if present - let mpqPiecesAct4Mesa: WildernessPiece[] | null = null - let mpqPiecesAct4Lava: WildernessPiece[] | null = null - let d2Tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } | null = null - let d2Archives: MountedArchives | null = null - - const samplePath = 'samples/d2/d2data.mpq' - if (existsSync(samplePath)) { - console.log('📦 Loading MPQ archives for ground-truth data...') - d2Archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - try { - d2Archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } catch { - // Optional - } - } - const actTables = await loadActTables(d2Archives) - d2Tables = { - levels: actTables.levels, - lvltypes: actTables.lvltypes, - lvlprest: actTables.lvlprest, - } - - const ds1Cache = new Map() - - async function loadDs1(relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await d2Archives!.read(member)) - ds1Cache.set(member, decoded) - return decoded - } - - async function rowDs1s(table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(value)) - } - return levels - } - - async function getPieces(familyKey: string): Promise { - const families = WILDERNESS_PIECE_FAMILIES[familyKey] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of d2Tables!.lvlprest.rows) { - const name = cell(d2Tables!.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (familyKey === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(d2Tables!.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces - } - - mpqPiecesAct4Mesa = await getPieces('Act 4 - Mesa') - mpqPiecesAct4Lava = await getPieces('Act 4 - Lava') - console.log(`✓ Loaded MPQ ground-truth pieces: Act 4 Mesa (${String(mpqPiecesAct4Mesa.length)}), Lava (${String(mpqPiecesAct4Lava.length)})\n`) - } - - const piecesAct4Mesa = mpqPiecesAct4Mesa ?? makeMockBorderPieces('Act 4') - const piecesAct4Lava = mpqPiecesAct4Lava ?? makeMockBorderPieces('Act 4') - const piecesAct5 = makeMockBorderPieces('Act 5') - - /* ----------------------------------------------------------------------- * - * SECTION 1: Act 4 Lava Fissure Flood-Fill Reachability Stress-Testing - * ----------------------------------------------------------------------- */ - console.log('----------------------------------------------------------------------') - console.log('🌋 SECTION 1: Act 4 Lava Fissure Flood-Fill Reachability (60 Seeds)') - console.log('----------------------------------------------------------------------') - - let act4MinReachability = 1.0 - let act4MaxReachability = 0.0 - let act4SumReachability = 0.0 - let act4TestsCount = 0 - - const act4Configurations = [ - { levelId: 104, name: 'Outer Steppes', type: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, pieces: piecesAct4Mesa }, - { levelId: 105, name: 'Plains of Despair', type: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, pieces: piecesAct4Mesa }, - { levelId: 106, name: 'City of the Damned', type: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, pieces: piecesAct4Mesa }, - { levelId: 106, name: 'City of the Damned (Lava)', type: 'Act 4 - Lava', sizeX: 80, sizeY: 80, pieces: piecesAct4Lava }, - ] - - for (const config of act4Configurations) { - console.log(`Testing ${config.name} (${config.type}, Level ${String(config.levelId)}) across ${String(seeds.length)} seeds...`) - - let configMinRatio = 1.0 - let configAllPassed = true - - for (const seed of seeds) { - const result = generateWilderness({ - levelId: config.levelId, - levelName: config.name, - levelTypeName: config.type, - sizeX: config.sizeX, - sizeY: config.sizeY, - subType: 0, - subTheme: 0, - seed, - pieces: config.pieces, - substitutions: [], - }) - - const W = result.level.width - const H = result.level.height - - const hasWall = (x: number, y: number): boolean => { - const cell = result.level.cells[y]?.[x] - if (!cell) return true - return cell.walls.some(w => w.prop1 !== 0 || w.style !== 0) - } - - const hasFloor = (x: number, y: number): boolean => { - const cell = result.level.cells[y]?.[x] - if (!cell) return false - return cell.floors.some(f => !f.hidden && f.prop1 !== 0) - } - - const isWalkable = (x: number, y: number): boolean => hasFloor(x, y) && !hasWall(x, y) - - // 1. Gather all walkable cells in playable interior [8..W-8] x [8..H-8] - let totalInteriorWalkable = 0 - for (let y = 8; y < H - 8; y += 1) { - for (let x = 8; x < W - 8; x += 1) { - if (isWalkable(x, y)) totalInteriorWalkable += 1 - } - } - - assert(totalInteriorWalkable > 800, `Seed ${String(seed)}: Interior playable walkable cells (${String(totalInteriorWalkable)}) must be > 800`) - - // 2. Identify entrance location - const gridW = Math.floor(config.sizeX / 8) - const inX = Math.floor(gridW / 2) - const hubX = inX * 8 + 4 - - let startX = -1 - let startY = -1 - - // Search for walkable entrance tile at North edge [y=8..15] near hubX - for (let dy = 0; dy < 8 && startX === -1; dy += 1) { - for (let dx = 0; dx <= 6; dx += 1) { - for (const sign of [0, 1, -1]) { - const tx = hubX + dx * sign - const ty = 8 + dy - if (tx >= 8 && tx < W - 8 && ty >= 8 && ty < H - 8 && isWalkable(tx, ty)) { - startX = tx - startY = ty - break - } - } - if (startX !== -1) break - } - } - - // Fallback: any walkable tile in top quadrant - if (startX === -1) { - for (let y = 8; y < Math.floor(H / 2) && startX === -1; y += 1) { - for (let x = 8; x < W - 8; x += 1) { - if (isWalkable(x, y)) { - startX = x - startY = y - break - } - } - } - } - - assert(startX !== -1 && startY !== -1, `Seed ${String(seed)}: Entrance walkable starting cell must exist`) - - // 3. Perform BFS Flood-Fill - const visited = new Set() - const queue: [number, number][] = [[startX, startY]] - visited.add(startY * W + startX) - - while (queue.length > 0) { - const [cx, cy] = queue.shift()! - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) { - const key = ny * W + nx - if (!visited.has(key) && isWalkable(nx, ny)) { - visited.add(key) - queue.push([nx, ny]) - } - } - } - } - - const reachableRatio = visited.size / totalInteriorWalkable - configMinRatio = Math.min(configMinRatio, reachableRatio) - act4MinReachability = Math.min(act4MinReachability, reachableRatio) - act4MaxReachability = Math.max(act4MaxReachability, reachableRatio) - act4SumReachability += reachableRatio - act4TestsCount += 1 - - // 4. Assert reachability >= 90% holds unconditionally - const ratioPassed = reachableRatio >= 0.90 - if (!ratioPassed) configAllPassed = false - assert( - ratioPassed, - `Seed ${String(seed)}: Reachable ratio ${(reachableRatio * 100).toFixed(2)}% must be >= 90.00% (visited: ${String(visited.size)}/${String(totalInteriorWalkable)})`, - ) - - // 5. Verify the 3 strategic rock bridges exist and connect North and South plateaus - let southCellsReached = 0 - for (let y = Math.floor(H * 0.65); y < H - 8; y += 1) { - for (let x = 8; x < W - 8; x += 1) { - if (visited.has(y * W + x)) southCellsReached += 1 - } - } - assert( - southCellsReached > 100, - `Seed ${String(seed)}: BFS must traverse across bridges into South plateau (reached ${String(southCellsReached)} cells in South)`, - ) - - // Verify that bridge cells are present in stats - const topoStats = result.stats.act4Topography as { rockyBridges: number } | undefined - assert( - topoStats !== undefined && topoStats.rockyBridges >= 3, - `Seed ${String(seed)}: Topo stats must record rocky bridges (got ${String(topoStats?.rockyBridges)})`, - ) - } - - console.log(` ✓ ${config.name} (${config.type}): 100% of seeds passed >= 90% reachability (Min: ${(configMinRatio * 100).toFixed(2)}%)\n`) - } - - const act4AvgReachability = act4SumReachability / act4TestsCount - console.log(`Act 4 Lava Fissure Summary (${String(act4TestsCount)} runs):`) - console.log(` - Minimum Reachability: ${(act4MinReachability * 100).toFixed(2)}% (Target >= 90.00%)`) - console.log(` - Average Reachability: ${(act4AvgReachability * 100).toFixed(2)}%`) - console.log(` - Maximum Reachability: ${(act4MaxReachability * 100).toFixed(2)}%`) - console.log(` - 100% Success Rate across all ${String(act4TestsCount)} tests.\n`) - - /* ----------------------------------------------------------------------- * - * SECTION 2: Act 5 Transverse Barricades Orientation & Gates Stress-Testing - * ----------------------------------------------------------------------- */ - console.log('----------------------------------------------------------------------') - console.log('🛡️ SECTION 2: Act 5 Transverse Barricades Orientation & Gate Walkability') - console.log('----------------------------------------------------------------------') - - // Sub-case 2A: Vertical Corridors (height > width) - // Must span horizontally across X in [8..W-8] with military gate at hubX - console.log(`Sub-case 2A: Vertical Corridors (height > width) across ${String(seeds.length)} seeds...`) - - const verticalSizes = [ - { sizeX: 48, sizeY: 160 }, // Standard authentic Level 111 / 112 - { sizeX: 48, sizeY: 192 }, - { sizeX: 64, sizeY: 160 }, - ] - - for (const dim of verticalSizes) { - console.log(` Testing Vertical Corridor ${String(dim.sizeX)}x${String(dim.sizeY)} (height > width)...`) - - for (const seed of seeds) { - const result = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: dim.sizeX, - sizeY: dim.sizeY, - subType: 0, - subTheme: 0, - seed, - pieces: piecesAct5, - substitutions: [], - }) - - const W = result.level.width - const H = result.level.height - const gridW = Math.floor(dim.sizeX / 8) - const inX = Math.floor(gridW / 2) - const hubX = inX * 8 + 4 - - assert(H > W, `Corridor height (${String(H)}) must be > width (${String(W)})`) - - // Find horizontal barricade rows (rows with at least 15 palisade wall tiles across X) - const horizontalBarricadeRows: number[] = [] - for (let y = 8; y < H - 8; y += 1) { - let palisadeCount = 0 - for (let x = 8; x < W - 8; x += 1) { - const cell = result.level.cells[y]?.[x] - if (cell && cell.walls.some(w => w.style === 2 && w.prop1 === 129)) { - palisadeCount += 1 - } - } - if (palisadeCount >= 15) { - horizontalBarricadeRows.push(y) - } - } - - assert( - horizontalBarricadeRows.length >= 2, - `Seed ${String(seed)}: Vertical corridor must produce at least 2 horizontal barricade rows across X (found ${String(horizontalBarricadeRows.length)})`, - ) - - // For each horizontal barricade row, verify: - // 1. Spans across X in [8..W-8] - // 2. Gate at hubX is open (no walls) and walkable (has floor) - // 3. Gate opening has width of at least 3 walkable tiles - // 4. Fortified end-caps at gateLeft (hubX - 2) and gateRight (hubX + 2) - for (const by of horizontalBarricadeRows) { - // Gate center at hubX - const gateCell = result.level.cells[by]?.[hubX] - assert(gateCell !== undefined, `Seed ${String(seed)}: Gate cell at (${String(hubX)}, ${String(by)}) must exist`) - - const gateHasWall = gateCell ? gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true - assert(!gateHasWall, `Seed ${String(seed)}: Military gate at (hubX=${String(hubX)}, by=${String(by)}) must have NO wall collision mask`) - - const gateHasFloor = gateCell ? gateCell.floors.some(f => f.style === 5 && f.prop1 === 194) : false - assert(gateHasFloor, `Seed ${String(seed)}: Military gate at (hubX=${String(hubX)}, by=${String(by)}) must have walkable dirt/mud floor (style 5, prop1 194)`) - - // Gate width of at least 3 tiles (hubX - 1, hubX, hubX + 1) - for (let gx = hubX - 1; gx <= hubX + 1; gx += 1) { - const c = result.level.cells[by]?.[gx] - const wall = c ? c.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true - assert(!wall, `Seed ${String(seed)}: Gate opening tile at (${String(gx)}, ${String(by)}) must be clear of walls`) - } - - // Fortified end-caps - const leftCap = result.level.cells[by]?.[hubX - 2] - const rightCap = result.level.cells[by]?.[hubX + 2] - const hasLeftCap = leftCap ? leftCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false - const hasRightCap = rightCap ? rightCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false - assert(hasLeftCap, `Seed ${String(seed)}: Fortified left end-cap must exist at (${String(hubX - 2)}, ${String(by)})`) - assert(hasRightCap, `Seed ${String(seed)}: Fortified right end-cap must exist at (${String(hubX + 2)}, ${String(by)})`) - } - - // End-to-end corridor traversability: - // Verify BFS from North entrance (hubX, 8) reaches South exit (hubX, H - 9) - const isWalkable = (x: number, y: number): boolean => { - const c = result.level.cells[y]?.[x] - if (!c) return false - const hasF = c.floors.some(f => !f.hidden && f.prop1 !== 0) - const hasW = c.walls.some(w => w.prop1 !== 0 || w.style !== 0) - return hasF && !hasW - } - - const visited = new Set() - const queue: [number, number][] = [[hubX, 8]] - visited.add(8 * W + hubX) - - while (queue.length > 0) { - const [cx, cy] = queue.shift()! - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) { - const key = ny * W + nx - if (!visited.has(key) && isWalkable(nx, ny)) { - visited.add(key) - queue.push([nx, ny]) - } - } - } - } - - const southReached = visited.has((H - 9) * W + hubX) || visited.has((H - 10) * W + hubX) - assert( - southReached, - `Seed ${String(seed)}: Player must be able to traverse full vertical corridor through all barricade gates from North to South`, - ) - } - console.log(` ✓ Vertical Corridor ${String(dim.sizeX)}x${String(dim.sizeY)}: 100% assertions passed across all ${String(seeds.length)} seeds.`) - } - - // Sub-case 2B: Horizontal & Square Corridors (width >= height) - // Must span vertically across Y in [8..H-8] with military gate at hubY - console.log(`\nSub-case 2B: Horizontal & Square Corridors (width >= height) across ${String(seeds.length)} seeds...`) - - const horizontalSizes = [ - { sizeX: 160, sizeY: 48 }, // Horizontal corridor - { sizeX: 80, sizeY: 80 }, // Square corridor (width == height) - { sizeX: 128, sizeY: 64 }, // Wide corridor - ] - - for (const dim of horizontalSizes) { - console.log(` Testing Horizontal/Square Corridor ${String(dim.sizeX)}x${String(dim.sizeY)} (width >= height)...`) - - for (const seed of seeds) { - const result = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: dim.sizeX, - sizeY: dim.sizeY, - subType: 0, - subTheme: 0, - seed, - pieces: piecesAct5, - substitutions: [], - }) - - const W = result.level.width - const H = result.level.height - const gridH = Math.floor(dim.sizeY / 8) - const outY = Math.floor(gridH / 2) - const hubY = outY * 8 + 4 - - assert(W >= H, `Corridor width (${String(W)}) must be >= height (${String(H)})`) - - // Find vertical barricade columns (columns with at least 15 palisade wall tiles across Y) - const verticalBarricadeCols: number[] = [] - for (let x = 8; x < W - 8; x += 1) { - let palisadeCount = 0 - for (let y = 8; y < H - 8; y += 1) { - const cell = result.level.cells[y]?.[x] - if (cell && cell.walls.some(w => w.style === 2 && w.prop1 === 129)) { - palisadeCount += 1 - } - } - if (palisadeCount >= 15) { - verticalBarricadeCols.push(x) - } - } - - assert( - verticalBarricadeCols.length >= 2, - `Seed ${String(seed)}: Horizontal corridor must produce at least 2 vertical barricade columns across Y (found ${String(verticalBarricadeCols.length)})`, - ) - - // For each vertical barricade col, verify: - // 1. Spans across Y in [8..H-8] - // 2. Gate at hubY is open (no walls) and walkable (has floor) - // 3. Gate opening has height of at least 3 walkable tiles - // 4. Fortified end-caps at gateTop (hubY - 2) and gateBottom (hubY + 2) - for (const bx of verticalBarricadeCols) { - // Gate center at hubY - const gateCell = result.level.cells[hubY]?.[bx] - assert(gateCell !== undefined, `Seed ${String(seed)}: Gate cell at (${String(bx)}, ${String(hubY)}) must exist`) - - const gateHasWall = gateCell ? gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true - assert(!gateHasWall, `Seed ${String(seed)}: Military gate at (bx=${String(bx)}, hubY=${String(hubY)}) must have NO wall collision mask`) - - const gateHasFloor = gateCell ? gateCell.floors.some(f => f.style === 5 && f.prop1 === 194) : false - assert(gateHasFloor, `Seed ${String(seed)}: Military gate at (bx=${String(bx)}, hubY=${String(hubY)}) must have walkable dirt/mud floor (style 5, prop1 194)`) - - // Gate height of at least 3 tiles (hubY - 1, hubY, hubY + 1) - for (let gy = hubY - 1; gy <= hubY + 1; gy += 1) { - const c = result.level.cells[gy]?.[bx] - const wall = c ? c.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true - assert(!wall, `Seed ${String(seed)}: Gate opening tile at (${String(bx)}, ${String(gy)}) must be clear of walls`) - } - - // Fortified end-caps - const topCap = result.level.cells[hubY - 2]?.[bx] - const bottomCap = result.level.cells[hubY + 2]?.[bx] - const hasTopCap = topCap ? topCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false - const hasBottomCap = bottomCap ? bottomCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false - assert(hasTopCap, `Seed ${String(seed)}: Fortified top end-cap must exist at (${String(bx)}, ${String(hubY - 2)})`) - assert(hasBottomCap, `Seed ${String(seed)}: Fortified bottom end-cap must exist at (${String(bx)}, ${String(hubY + 2)})`) - } - - // End-to-end corridor traversability: - // Verify BFS from West entrance (8, hubY) reaches East exit (W - 9, hubY) - const isWalkable = (x: number, y: number): boolean => { - const c = result.level.cells[y]?.[x] - if (!c) return false - const hasF = c.floors.some(f => !f.hidden && f.prop1 !== 0) - const hasW = c.walls.some(w => w.prop1 !== 0 || w.style !== 0) - return hasF && !hasW - } - - const visited = new Set() - const queue: [number, number][] = [[8, hubY]] - visited.add(hubY * W + 8) - - while (queue.length > 0) { - const [cx, cy] = queue.shift()! - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) { - const key = ny * W + nx - if (!visited.has(key) && isWalkable(nx, ny)) { - visited.add(key) - queue.push([nx, ny]) - } - } - } - } - - const eastReached = visited.has(hubY * W + (W - 9)) || visited.has(hubY * W + (W - 10)) - assert( - eastReached, - `Seed ${String(seed)}: Player must be able to traverse full horizontal corridor through all barricade gates from West to East`, - ) - } - console.log(` ✓ Horizontal/Square Corridor ${String(dim.sizeX)}x${String(dim.sizeY)}: 100% assertions passed across all ${String(seeds.length)} seeds.`) - } - - /* ----------------------------------------------------------------------- * - * SUMMARY - * ----------------------------------------------------------------------- */ - const durationSec = ((Date.now() - startTime) / 1000).toFixed(2) - console.log('\n======================================================================') - console.log(`📊 STRESS TEST COMPLETE (${durationSec}s)`) - console.log('======================================================================') - console.log(`Total Assertions Executed: ${String(totalAssertions)}`) - console.log(`Passed Assertions: ${String(passedAssertions)}`) - console.log(`Failed Assertions: ${String(failedAssertions)}`) - - if (failedAssertions > 0) { - console.error(`\n❌ VERDICT: CHALLENGE_FAILED (${String(failedAssertions)} assertion failures)`) - process.exit(1) - } else { - console.log('\n✅ VERDICT: APPROVE (100% empirical assertions passed)') - process.exit(0) - } -} - -main().catch(err => { - console.error('Fatal error in stress test:', err) - process.exit(1) -}) diff --git a/scripts/stress-test-m2-geometry.ts b/scripts/stress-test-m2-geometry.ts deleted file mode 100644 index bb7e063..0000000 --- a/scripts/stress-test-m2-geometry.ts +++ /dev/null @@ -1,594 +0,0 @@ -/** - * Standalone Adversarial Stress-Test Script for Milestone M2 - * Diablo II Web DRLG Engine: Organic Geometry & Fractional Jitter Profiler - * - * Requirements: - * 1. Stress-test Act 2 Oasis and Act 5 Frozen Lake radial lobe perturbations across 100+ random seeds and phase variations: - * - Numerically compute P^2 / A for continuous polar contour and discrete rasterized tile cells. - * - Assert P^2 / A > 4*pi * 1.15 holds 100% of the time (zero violations). - * 2. Stress-test jitterFrac: sample 10,000 draws and assert all values strictly lie in [base - delta, base + delta]. - * 3. Verify Act 4 Lava Fissure flood-fill reachability >= 90%. - * 4. Verify Act 5 Transverse Barricade palisade orientation and choke-point gate clearance. - * - * Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness; - * tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them. - * - * Archetype: EMPIRICAL CHALLENGER - */ - -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' -import { - generateWilderness, - type WildernessPiece, -} from '../src/game/wilderness.ts' -import { - act2OasisRadius, - act5FrozenLakeRadius, - jitterFrac, - type Act2TopographyStats, - type Act5TopographyStats, -} from '../src/game/wilderness-acts.ts' -import { Rng } from '../src/game/rng.ts' - -/* ------------------------------------------------------------------------- * - * Mathematical Helpers: Continuous & Discrete Isoperimetric Quotients - * ------------------------------------------------------------------------- */ - -const ORGANIC_THRESHOLD = 4 * Math.PI * 1.15 // ~14.451326206513047 - -/** - * Numerically compute continuous polar contour area, perimeter, and P^2 / A. - */ -function computeContinuousPolarQuotient( - radiusFn: (theta: number) => number, - steps = 2000, -): { area: number; perimeter: number; quotient: number } { - let area = 0 - let perimeter = 0 - const dTheta = (2 * Math.PI) / steps - - for (let i = 0; i < steps; i += 1) { - const t1 = i * dTheta - const t2 = (i + 1) * dTheta - const r1 = radiusFn(t1) - const r2 = radiusFn(t2) - - // Sector area via cross product - area += 0.5 * r1 * r2 * Math.sin(dTheta) - - // Arc length via Euclidean chord - const x1 = r1 * Math.cos(t1) - const y1 = r1 * Math.sin(t1) - const x2 = r2 * Math.cos(t2) - const y2 = r2 * Math.sin(t2) - perimeter += Math.hypot(x2 - x1, y2 - y1) - } - - const quotient = (perimeter * perimeter) / area - return { area, perimeter, quotient } -} - -/** - * Numerically compute discrete rasterized tile cells area, perimeter, and P^2 / A. - */ -function computeDiscreteRasterQuotient( - radiusFn: (theta: number) => number, - R: number, -): { area: number; perimeter: number; quotient: number } { - const maxR = Math.ceil(R * 1.5) + 3 - const inside = new Set() - - for (let dy = -maxR; dy <= maxR; dy += 1) { - for (let dx = -maxR; dx <= maxR; dx += 1) { - const dist = Math.hypot(dx, dy) - const theta = Math.atan2(dy, dx) - const rLobe = radiusFn(theta) - if (dist <= rLobe) { - inside.add(`${dx},${dy}`) - } - } - } - - const area = inside.size - let perimeter = 0 - - for (const key of inside) { - const comma = key.indexOf(',') - const x = Number(key.slice(0, comma)) - const y = Number(key.slice(comma + 1)) - - if (!inside.has(`${x + 1},${y}`)) perimeter += 1 - if (!inside.has(`${x - 1},${y}`)) perimeter += 1 - if (!inside.has(`${x},${y + 1}`)) perimeter += 1 - if (!inside.has(`${x},${y - 1}`)) perimeter += 1 - } - - const quotient = area > 0 ? (perimeter * perimeter) / area : 0 - return { area, perimeter, quotient } -} - -/* ------------------------------------------------------------------------- * - * Mock DS1 and Piece Builders for Hermetic Testing - * ------------------------------------------------------------------------- */ - -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: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { - const ds1 = makeMockDs1(8, 8) - return [ - { name: `${actPrefix} - Border 1`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 2`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 3`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 4`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 5`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 6`, border: true, levels: [ds1] }, - ] -} - -/* ------------------------------------------------------------------------- * - * Stress Test Harness - * ------------------------------------------------------------------------- */ - -async function main() { - console.log('======================================================================') - console.log('🔥 DIABLO II WEB DRLG: STANDALONE ADVERSARIAL STRESS TEST (MILESTONE M2)') - console.log('======================================================================\n') - - let totalAssertions = 0 - let passedAssertions = 0 - let failedAssertions = 0 - const failureLog: string[] = [] - - function check(condition: boolean, message: string) { - totalAssertions += 1 - if (condition) { - passedAssertions += 1 - } else { - failedAssertions += 1 - failureLog.push(message) - console.error(` ❌ FAILED: ${message}`) - } - } - - // ------------------------------------------------------------------------- - // SECTION 1: JitterFrac Sampling (10,000 Draws) - // ------------------------------------------------------------------------- - console.log('--- SECTION 1: STRESS-TESTING JitterFrac (10,000 Draws) ---') - const jitterRng = new Rng(0xbadf00d) - const testConfigs = [ - { base: 0.50, delta: 0.05, count: 2000 }, - { base: 0.35, delta: 0.04, count: 1500 }, - { base: 0.70, delta: 0.04, count: 1500 }, - { base: 0.22, delta: 0.05, count: 1500 }, - { base: 0.68, delta: 0.05, count: 1500 }, - { base: 0.40, delta: 0.05, count: 1000 }, - // Boundary clamp checks - { base: 0.03, delta: 0.05, count: 500 }, // lower clamp at 0.01 - { base: 0.97, delta: 0.05, count: 500 }, // upper clamp at 0.99 - ] - - let totalDraws = 0 - for (const cfg of testConfigs) { - const minAllowed = Math.max(0.01, cfg.base - cfg.delta) - const maxAllowed = Math.min(0.99, cfg.base + cfg.delta) - const draws: number[] = [] - - for (let i = 0; i < cfg.count; i += 1) { - const val = jitterFrac(jitterRng, cfg.base, cfg.delta) - totalDraws += 1 - const inBounds = val >= minAllowed && val <= maxAllowed - if (!inBounds) { - check(false, `jitterFrac out of bounds: val=${val}, allowed=[${minAllowed}, ${maxAllowed}], base=${cfg.base}, delta=${cfg.delta}`) - } - draws.push(val) - } - - const minObs = Math.min(...draws) - const maxObs = Math.max(...draws) - const meanObs = draws.reduce((a, b) => a + b, 0) / draws.length - check(minObs >= minAllowed && maxObs <= maxAllowed, `All ${cfg.count} draws strictly in [${minAllowed.toFixed(4)}, ${maxAllowed.toFixed(4)}]`) - - console.log( - ` Base: ${cfg.base.toFixed(2)} ± ${cfg.delta.toFixed(2)} (${cfg.count} draws) | ` + - `Observed: [${minObs.toFixed(4)} .. ${maxObs.toFixed(4)}] | Mean: ${meanObs.toFixed(4)} | PASSED` - ) - } - check(totalDraws === 10000, `Total jitterFrac draws reached exactly 10,000 (${totalDraws})`) - console.log(` ✓ 10,000 draws sampled: 0 violations, 100% strictly bounded.\n`) - - // ------------------------------------------------------------------------- - // SECTION 2: Act 2 Oasis Radial Lobe Harmonic Perturbations (150+ Seeds & Phases) - // ------------------------------------------------------------------------- - console.log('--- SECTION 2: STRESS-TESTING Act 2 Oasis Radial Lobes (P^2 / A > 4*pi * 1.15) ---') - const oasisRng = new Rng(0x0a515001) - const testRadiiAct2 = [4, 5, 6, 8, 10, 14, 20, 30] - let act2ContViolations = 0 - let act2DiscViolations = 0 - let act2TotalTests = 0 - - let minAct2ContQ = Infinity - let maxAct2ContQ = -Infinity - let minAct2DiscQ = Infinity - let maxAct2DiscQ = -Infinity - - for (let seedIdx = 0; seedIdx < 150; seedIdx += 1) { - const phi1 = oasisRng.range(0, 2 * Math.PI) - const phi2 = oasisRng.range(0, 2 * Math.PI) - - for (const R of testRadiiAct2) { - act2TotalTests += 1 - const radiusFn = (theta: number) => act2OasisRadius(R, theta, phi1, phi2) - - // Continuous polar contour - const cont = computeContinuousPolarQuotient(radiusFn, 2000) - if (cont.quotient < minAct2ContQ) minAct2ContQ = cont.quotient - if (cont.quotient > maxAct2ContQ) maxAct2ContQ = cont.quotient - - if (cont.quotient <= ORGANIC_THRESHOLD) { - act2ContViolations += 1 - check(false, `Act 2 continuous P^2/A violation: ${cont.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`) - } - - // Discrete rasterized tile cells - const disc = computeDiscreteRasterQuotient(radiusFn, R) - if (disc.quotient < minAct2DiscQ) minAct2DiscQ = disc.quotient - if (disc.quotient > maxAct2DiscQ) maxAct2DiscQ = disc.quotient - - if (disc.quotient <= ORGANIC_THRESHOLD) { - act2DiscViolations += 1 - check(false, `Act 2 discrete P^2/A violation: ${disc.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`) - } - } - } - - check(act2ContViolations === 0, `Act 2 continuous P^2/A zero violations across ${act2TotalTests} configurations`) - check(act2DiscViolations === 0, `Act 2 discrete P^2/A zero violations across ${act2TotalTests} configurations`) - console.log(` Act 2 Oasis Tested: ${act2TotalTests} configurations across 150 seeds and ${testRadiiAct2.length} radii.`) - console.log(` Continuous P^2/A range: [${minAct2ContQ.toFixed(4)} .. ${maxAct2ContQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`) - console.log(` Discrete P^2/A range: [${minAct2DiscQ.toFixed(4)} .. ${maxAct2DiscQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`) - console.log(` ✓ Act 2 Oasis: 0 violations, 100% strictly exceed isoperimetric threshold.\n`) - - // ------------------------------------------------------------------------- - // SECTION 3: Act 5 Frozen Lake Radial Lobe Harmonic Perturbations (150+ Seeds & Phases) - // ------------------------------------------------------------------------- - console.log('--- SECTION 3: STRESS-TESTING Act 5 Frozen Lake Radial Lobes (P^2 / A > 4*pi * 1.15) ---') - const lakeRng = new Rng(0x1a7e5001) - const testRadiiAct5 = [6, 7, 8, 10, 12, 16, 22] - let act5ContViolations = 0 - let act5DiscViolations = 0 - let act5TotalTests = 0 - - let minAct5ContQ = Infinity - let maxAct5ContQ = -Infinity - let minAct5DiscQ = Infinity - let maxAct5DiscQ = -Infinity - - for (let seedIdx = 0; seedIdx < 150; seedIdx += 1) { - const phi1 = lakeRng.range(0, 2 * Math.PI) - const phi2 = lakeRng.range(0, 2 * Math.PI) - - for (const R of testRadiiAct5) { - act5TotalTests += 1 - const radiusFn = (theta: number) => act5FrozenLakeRadius(R, theta, phi1, phi2) - - // Continuous polar contour - const cont = computeContinuousPolarQuotient(radiusFn, 2000) - if (cont.quotient < minAct5ContQ) minAct5ContQ = cont.quotient - if (cont.quotient > maxAct5ContQ) maxAct5ContQ = cont.quotient - - if (cont.quotient <= ORGANIC_THRESHOLD) { - act5ContViolations += 1 - check(false, `Act 5 continuous P^2/A violation: ${cont.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`) - } - - // Discrete rasterized tile cells - const disc = computeDiscreteRasterQuotient(radiusFn, R) - if (disc.quotient < minAct5DiscQ) minAct5DiscQ = disc.quotient - if (disc.quotient > maxAct5DiscQ) maxAct5DiscQ = disc.quotient - - if (disc.quotient <= ORGANIC_THRESHOLD) { - act5DiscViolations += 1 - check(false, `Act 5 discrete P^2/A violation: ${disc.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`) - } - } - } - - check(act5ContViolations === 0, `Act 5 continuous P^2/A zero violations across ${act5TotalTests} configurations`) - check(act5DiscViolations === 0, `Act 5 discrete P^2/A zero violations across ${act5TotalTests} configurations`) - console.log(` Act 5 Frozen Lake Tested: ${act5TotalTests} configurations across 150 seeds and ${testRadiiAct5.length} radii.`) - console.log(` Continuous P^2/A range: [${minAct5ContQ.toFixed(4)} .. ${maxAct5ContQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`) - console.log(` Discrete P^2/A range: [${minAct5DiscQ.toFixed(4)} .. ${maxAct5DiscQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`) - console.log(` ✓ Act 5 Frozen Lake: 0 violations, 100% strictly exceed isoperimetric threshold.\n`) - - // ------------------------------------------------------------------------- - // SECTION 4: Real Generated Wilderness Level Audits across 100 Seeds - // ------------------------------------------------------------------------- - console.log('--- SECTION 4: FULL WILDERNESS ENGINE MAP AUDITS (100 Seeds) ---') - const mapRng = new Rng(0x99887766) - const mapSeeds: number[] = [0x301cd095, 0x416d61c5, 0xdeadbeef] - for (let i = 0; i < 97; i += 1) { - mapSeeds.push(mapRng.int(100000, 99999999)) - } - - const act2Pieces = makeMockBorderPieces('Act 2') - const act4Pieces = makeMockBorderPieces('Act 4') - const act5Pieces = makeMockBorderPieces('Act 5') - - let oasisMapChecks = 0 - let frozenLakeMapChecks = 0 - let fissureConnectivityChecks = 0 - let barricadeChecks = 0 - - for (const seed of mapSeeds) { - // 1. Act 2 Far Oasis (Level 43) - const oasisResult = generateWilderness({ - levelId: 43, - levelName: 'Far Oasis', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed, - pieces: act2Pieces, - substitutions: [], - }) - - const oW = oasisResult.level.width - const oH = oasisResult.level.height - const oCells = oasisResult.level.cells - const isOasisWater = (x: number, y: number) => { - if (x < 0 || x >= oW || y < 0 || y >= oH) return false - return oCells[y]![x]!.floors.some(f => f.style === 2 && f.prop1 === 2) - } - - // Find connected components of oasis water - const visitedOasis = new Set() - for (let y = 4; y < oH - 4; y += 1) { - for (let x = 4; x < oW - 4; x += 1) { - const key = `${x},${y}` - if (isOasisWater(x, y) && !visitedOasis.has(key)) { - // BFS component - const comp: [number, number][] = [] - const queue: [number, number][] = [[x, y]] - visitedOasis.add(key) - while (queue.length > 0) { - const [cx, cy] = queue.shift()! - comp.push([cx, cy]) - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - const nKey = `${nx},${ny}` - if (isOasisWater(nx, ny) && !visitedOasis.has(nKey)) { - visitedOasis.add(nKey) - queue.push([nx, ny]) - } - } - } - - if (comp.length >= 10) { - oasisMapChecks += 1 - const compArea = comp.length - let compPerim = 0 - for (const [cx, cy] of comp) { - if (!isOasisWater(cx + 1, cy)) compPerim += 1 - if (!isOasisWater(cx - 1, cy)) compPerim += 1 - if (!isOasisWater(cx, cy + 1)) compPerim += 1 - if (!isOasisWater(cx, cy - 1)) compPerim += 1 - } - const compQ = (compPerim * compPerim) / compArea - check(compQ > ORGANIC_THRESHOLD, `Map Far Oasis (seed ${seed}) pool P^2/A=${compQ.toFixed(2)} > ${ORGANIC_THRESHOLD.toFixed(2)}`) - } - } - } - } - - // 2. Act 5 Arreat Plateau (Level 112) - const plateauResult = generateWilderness({ - levelId: 112, - levelName: 'Arreat Plateau', - levelTypeName: 'Act 5 - Barricade', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed, - pieces: act5Pieces, - substitutions: [], - }) - - const pH = plateauResult.level.height - const pW = plateauResult.level.width - const pCells = plateauResult.level.cells - const isFrozenLake = (x: number, y: number) => { - if (x < 0 || x >= pW || y < 0 || y >= pH) return false - return pCells[y]![x]!.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2) - } - - let lakeArea = 0 - let lakePerim = 0 - for (let y = 4; y < pH - 4; y += 1) { - for (let x = 4; x < pW - 4; x += 1) { - if (isFrozenLake(x, y)) { - lakeArea += 1 - if (!isFrozenLake(x + 1, y)) lakePerim += 1 - if (!isFrozenLake(x - 1, y)) lakePerim += 1 - if (!isFrozenLake(x, y + 1)) lakePerim += 1 - if (!isFrozenLake(x, y - 1)) lakePerim += 1 - } - } - } - - if (lakeArea >= 15) { - frozenLakeMapChecks += 1 - const lakeQ = (lakePerim * lakePerim) / lakeArea - check(lakeQ > ORGANIC_THRESHOLD, `Map Arreat Plateau (seed ${seed}) lake P^2/A=${lakeQ.toFixed(2)} > ${ORGANIC_THRESHOLD.toFixed(2)}`) - } - - // 3. Act 4 Outer Steppes (Level 104) - Lava Fissure Connectivity - const steppesResult = generateWilderness({ - levelId: 104, - levelName: 'Outer Steppes', - levelTypeName: 'Act 4 - Mesa', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed, - pieces: act4Pieces, - substitutions: [], - }) - - const sW = steppesResult.level.width - const sH = steppesResult.level.height - const sCells = steppesResult.level.cells - const isWalkable = (x: number, y: number) => { - if (x < 8 || x >= sW - 8 || y < 8 || y >= sH - 8) return false - const cell = sCells[y]![x]! - return !cell.walls.some(w => w.prop1 !== 0 || w.style !== 0) - } - - let totalWalkable = 0 - let startX = -1 - let startY = -1 - for (let y = 8; y < sH - 8; y += 1) { - for (let x = 8; x < sW - 8; x += 1) { - if (isWalkable(x, y)) { - totalWalkable += 1 - if (startX === -1 && y < 16) { - startX = x - startY = y - } - } - } - } - - const visitedWalk = new Set() - const walkQueue: [number, number][] = [[startX, startY]] - visitedWalk.add(startY * sW + startX) - while (walkQueue.length > 0) { - const [cx, cy] = walkQueue.shift()! - for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = cx + dx - const ny = cy + dy - const key = ny * sW + nx - if (isWalkable(nx, ny) && !visitedWalk.has(key)) { - visitedWalk.add(key) - walkQueue.push([nx, ny]) - } - } - } - - const reachRatio = visitedWalk.size / totalWalkable - fissureConnectivityChecks += 1 - check(reachRatio >= 0.90, `Act 4 Level 104 (seed ${seed}) reachability=${(reachRatio * 100).toFixed(2)}% >= 90%`) - - // 4. Act 5 Frigid Highlands (Level 111) - Transverse Barricades - const barricadeResult = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: 48, - sizeY: 160, - subType: 0, - subTheme: 0, - seed, - pieces: act5Pieces, - substitutions: [], - }) - - const bW = barricadeResult.level.width - const bH = barricadeResult.level.height - const bCells = barricadeResult.level.cells - const bInX = Math.floor(Math.floor(48 / 8) / 2) - const bHubX = bInX * 8 + 4 - - // Because height (160) > width (48), barricades must cut horizontally across X - const hBarricadeRows: number[] = [] - for (let y = 8; y < bH - 8; y += 1) { - let palisadeCount = 0 - for (let x = 8; x < bW - 8; x += 1) { - if (bCells[y]![x]!.walls.some(w => w.style === 2 && w.prop1 === 129)) { - palisadeCount += 1 - } - } - if (palisadeCount >= 15) { - hBarricadeRows.push(y) - } - } - - barricadeChecks += 1 - check(hBarricadeRows.length >= 2, `Act 5 Level 111 (seed ${seed}) horizontal barricades: ${hBarricadeRows.length} >= 2`) - for (const by of hBarricadeRows) { - const gate = bCells[by]![bHubX]! - const hasWall = gate.walls.some(w => w.prop1 !== 0 || w.style !== 0) - const hasFloor = gate.floors.some(f => f.style === 5 && f.prop1 === 194) - check(!hasWall && hasFloor, `Act 5 Level 111 (seed ${seed}, y=${by}) gate at hubX=${bHubX} open`) - } - } - - console.log(` Map Audits completed across 100 seeds:`) - console.log(` - Oasis pool components audited: ${oasisMapChecks} checks (all P^2/A > 14.45)`) - console.log(` - Frozen lake components audited: ${frozenLakeMapChecks} checks (all P^2/A > 14.45)`) - console.log(` - Act 4 fissure reachability audited: ${fissureConnectivityChecks} checks (all >= 90.00%)`) - console.log(` - Act 5 transverse barricade audited: ${barricadeChecks} checks (all perpendicular + open gate)`) - console.log(` ✓ Real level generation stress tests: 0 violations.\n`) - - // ------------------------------------------------------------------------- - // FINAL REPORT - // ------------------------------------------------------------------------- - console.log('\n======================================================================') - console.log('📊 M2 ADVERSARIAL STRESS TEST RESULTS') - console.log('======================================================================') - console.log(`Total Assertions Executed: ${totalAssertions}`) - console.log(`Passed Assertions: ${passedAssertions}`) - console.log(`Failed Assertions: ${failedAssertions}`) - - if (failedAssertions > 0) { - console.error('\n❌ FAILURES ENCOUNTERED:') - for (const f of failureLog) { - console.error(` - ${f}`) - } - process.exit(1) - } - - console.log('\n✅ ALL MILESTONE M2 GEOMETRIC, ISOPERIMETRIC & TOPOLOGICAL INVARIANTS CONFIRMED:') - console.log('1. JitterFrac: 10,000 draws strictly in [base - delta, base + delta] with 0 violations.') - console.log(`2. Act 2 Oasis: Continuous and discrete P^2/A strictly > 4pi * 1.15 across all seeds/phases.`) - console.log(`3. Act 5 Frozen Lake: Continuous and discrete P^2/A strictly > 4pi * 1.15 across all seeds/phases.`) - console.log(`4. Full Map Generation (100 seeds): 100% of water/lake bodies satisfy P^2/A > 4pi * 1.15.`) - console.log(`5. Act 4 Lava Fissure: 100% of tested maps maintain >= 90% flood-fill reachability.`) - console.log(`6. Act 5 Barricades: Transverse direction invariance & open choke-point gates verified.`) - console.log('======================================================================\n') -} - -main().catch(err => { - console.error('Fatal stress test runner error:', err) - process.exit(1) -}) diff --git a/scripts/stress-test-m3-adversarial.ts b/scripts/stress-test-m3-adversarial.ts deleted file mode 100644 index 0ec5f11..0000000 --- a/scripts/stress-test-m3-adversarial.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { resolveUnsetSize, UnresolvedLevelSizeError } from '../src/game/wilderness.ts' -import type { WildernessPiece, WildernessRequest } from '../src/game/wilderness.ts' -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' - -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: 7, - width, - height, - act: 5, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -console.log('=== STARTING ADVERSARIAL STRESS TEST FOR resolveUnsetSize ===') - -let failures = 0 - -function assert(condition: boolean, msg: string) { - if (!condition) { - console.error(`FAIL: ${msg}`) - failures++ - } else { - console.log(`PASS: ${msg}`) - } -} - -// 1. Anti-silent failure: Empty pieces throws UnresolvedLevelSizeError -try { - resolveUnsetSize({ - levelId: 111, - levelName: 'Act 5 - Barricade 1', - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - assert(false, 'Expected empty pieces to throw UnresolvedLevelSizeError') -} catch (err) { - assert(err instanceof UnresolvedLevelSizeError, 'Empty pieces throws instance of UnresolvedLevelSizeError') - assert((err as Error).message.includes('Level 111 (Act 5 - Barricade 1)'), 'Error message contains level info') -} - -// 2. Anti-silent failure: Non-barricade pieces throw UnresolvedLevelSizeError -try { - resolveUnsetSize({ - levelId: 111, - levelName: 'Act 5 - Barricade 1', - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: [ - { name: 'Act 5 - Siege 1', border: false, levels: [makeMockDs1(10, 10)] }, - ], - substitutions: [], - }) - assert(false, 'Expected non-barricade pieces to throw UnresolvedLevelSizeError') -} catch (err) { - assert(err instanceof UnresolvedLevelSizeError, 'Non-barricade pieces throw instance of UnresolvedLevelSizeError') -} - -// 3. Fuzzing pieces dimensions: 10,000 random dimensions test -let allBoundsHold = true -let allAspectRatiosHold = true -for (let i = 0; i < 10000; i++) { - const randW1 = Math.floor(Math.random() * 2000) - 500 // -500 to 1500 - const randH1 = Math.floor(Math.random() * 2000) - 500 - const randW2 = Math.floor(Math.random() * 2000) - 500 - const randH2 = Math.floor(Math.random() * 2000) - 500 - - const res = resolveUnsetSize({ - levelId: 111, - levelName: 'Act 5 - Barricade 1', - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: i, - pieces: [ - { name: 'Act 5 - Barricade Entrance', border: false, levels: [makeMockDs1(randW1, randH1)] }, - { name: 'Act 5 - Barricade Exit', border: false, levels: [makeMockDs1(randW2, randH2)] }, - ], - substitutions: [], - }) - - if (res.sizeX < 48 || res.sizeX > 64 || res.sizeY < 160 || res.sizeY > 192) { - allBoundsHold = false - console.error(`Bounds failure at iteration ${i}: sizeX=${res.sizeX}, sizeY=${res.sizeY}`) - break - } - if (res.sizeY <= res.sizeX || (res.sizeY / res.sizeX) < 2.5) { - allAspectRatiosHold = false - console.error(`Aspect ratio failure at iteration ${i}: sizeX=${res.sizeX}, sizeY=${res.sizeY}`) - break - } -} -assert(allBoundsHold, '10,000 random input tests: sizeX in [48..64] and sizeY in [160..192] strictly hold') -assert(allAspectRatiosHold, '10,000 random input tests: sizeY > sizeX and aspect ratio >= 2.5 strictly hold') - -// 4. Overrides -const overrideRes = resolveUnsetSize({ - levelId: 111, - levelName: 'Act 5 - Barricade 1', - levelTypeName: 'Act 5 - Barricade', - sizeX: -1, - sizeY: -1, - sizeOverride: { x: 72, y: 144 }, - subType: 10, - subTheme: 0, - seed: 999, - pieces: [], - substitutions: [], -}) -assert(overrideRes.sizeX === 72 && overrideRes.sizeY === 144, 'sizeOverride is respected exactly') - -if (failures > 0) { - console.error(`\nFAILED: ${failures} assertions failed!`) - process.exit(1) -} else { - console.log('\nSUCCESS: All adversarial checks passed!') -} diff --git a/scripts/stress-world-placement.ts b/scripts/stress-world-placement.ts deleted file mode 100644 index 6444492..0000000 --- a/scripts/stress-world-placement.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Standalone Adversarial Stress Test Harness for resolveWorldOffsets - * - * Tests 100 distinct seeds across all 5 Acts against: - * 1. Seamless Adjacency Boundary Alignment (Zero Tears/Gaps) - * 2. Non-Overlapping Interior Areas (Seamless and All Levels) - * 3. Act 5 Level 111 Alignment to X = 702 - * 4. Determinism - */ -import { SEAMLESS_ADJACENCY } from '../src/game/world-graph.ts' -import { resolveWorldOffsets, type WorldOffset } from '../src/game/world-placement.ts' - -export function generateTestSeeds(): number[] { - const seedSet = new Set([ - 0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999, - 0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919, - ]) - let state = 0x8546291a - while (seedSet.size < 100) { - state = (state * 1664525 + 1013904223) >>> 0 - seedSet.add(state) - } - return Array.from(seedSet) -} - -export const TEST_SEEDS = generateTestSeeds() - -export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean { - return ( - a.offsetX < b.offsetX + b.sizeX && - a.offsetX + a.sizeX > b.offsetX && - a.offsetY < b.offsetY + b.sizeY && - a.offsetY + a.sizeY > b.offsetY - ) -} - -export interface BoundaryCheckResult { - readonly touching: boolean - readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none' - readonly sharedLength: number - readonly gapX: number - readonly gapY: number -} - -export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult { - const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX - const overlapY = Math.max( - 0, - Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY), - ) - if (touchLR && overlapY > 0) { - return { - touching: true, - type: 'vertical-boundary', - sharedLength: overlapY, - gapX: 0, - gapY: 0, - } - } - - const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY - const overlapX = Math.max( - 0, - Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX), - ) - if (touchTB && overlapX > 0) { - return { - touching: true, - type: 'horizontal-boundary', - sharedLength: overlapX, - gapX: 0, - gapY: 0, - } - } - - const gapX = a.offsetX + a.sizeX <= b.offsetX - ? b.offsetX - (a.offsetX + a.sizeX) - : b.offsetX + b.sizeX <= a.offsetX - ? a.offsetX - (b.offsetX + b.sizeX) - : 0 - - const gapY = a.offsetY + a.sizeY <= b.offsetY - ? b.offsetY - (a.offsetY + a.sizeY) - : b.offsetY + b.sizeY <= a.offsetY - ? a.offsetY - (b.offsetY + b.sizeY) - : 0 - - return { - touching: false, - type: 'none', - sharedLength: 0, - gapX, - gapY, - } -} - -interface HarnessResults { - totalSeeds: number - p1SeamlessBoundary: { total: number; passed: number; failed: number; failuresByPair: Map } - p2SeamlessInterior: { total: number; passed: number; failed: number } - p2AllLevelsInterior: { total: number; passed: number; failed: number; overlapCases: { seed: number; act: number; a: number; b: number }[] } - p3Act5Alignment: { total: number; passed: number; failed: number } - p4Determinism: { total: number; passed: number; failed: number } -} - -export function runStressHarness(): HarnessResults { - const results: HarnessResults = { - totalSeeds: TEST_SEEDS.length, - p1SeamlessBoundary: { total: 0, passed: 0, failed: 0, failuresByPair: new Map() }, - p2SeamlessInterior: { total: 0, passed: 0, failed: 0 }, - p2AllLevelsInterior: { total: 0, passed: 0, failed: 0, overlapCases: [] }, - p3Act5Alignment: { total: 0, passed: 0, failed: 0 }, - p4Determinism: { total: 0, passed: 0, failed: 0 }, - } - - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - - // 1. Seamless Boundary Sharing - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - for (const [aId, bId] of actSeamless) { - results.p1SeamlessBoundary.total++ - const a = offsets.get(aId)! - const b = offsets.get(bId)! - const res = checkBoundarySharing(a, b) - if (res.touching) { - results.p1SeamlessBoundary.passed++ - } else { - results.p1SeamlessBoundary.failed++ - const key = `Act ${act}: [${aId}, ${bId}]` - results.p1SeamlessBoundary.failuresByPair.set( - key, - (results.p1SeamlessBoundary.failuresByPair.get(key) || 0) + 1, - ) - } - } - - // 2. Seamless Interior Non-Overlap - const seamlessIds = Array.from(new Set(actSeamless.flat())) - for (let i = 0; i < seamlessIds.length; i++) { - for (let j = i + 1; j < seamlessIds.length; j++) { - results.p2SeamlessInterior.total++ - const a = offsets.get(seamlessIds[i])!; - const b = offsets.get(seamlessIds[j])!; - if (!doRectanglesOverlap(a, b)) { - results.p2SeamlessInterior.passed++ - } else { - results.p2SeamlessInterior.failed++ - } - } - } - - // 2b. All Levels Interior Non-Overlap (including isolated levels) - const allPlaced = Array.from(offsets.values()) - for (let i = 0; i < allPlaced.length; i++) { - for (let j = i + 1; j < allPlaced.length; j++) { - results.p2AllLevelsInterior.total++ - const a = allPlaced[i]! - const b = allPlaced[j]! - if (!doRectanglesOverlap(a, b)) { - results.p2AllLevelsInterior.passed++ - } else { - results.p2AllLevelsInterior.failed++ - results.p2AllLevelsInterior.overlapCases.push({ - seed, - act, - a: a.levelId, - b: b.levelId, - }) - } - } - } - - // 3. Act 5 Alignment - if (act === 5) { - results.p3Act5Alignment.total++ - const l110 = offsets.get(110) - const l111 = offsets.get(111) - const l112 = offsets.get(112) - if ( - l110 && - l111 && - l112 && - l110.offsetX === 760 && - l110.offsetY === 1000 && - l111.sizeX === 58 && - l111.offsetX === 702 && - l111.offsetX + l111.sizeX === l110.offsetX && - l112.sizeX === 58 && - l112.offsetX === 702 && - l112.offsetY + l112.sizeY === l111.offsetY - ) { - results.p3Act5Alignment.passed++ - } else { - results.p3Act5Alignment.failed++ - } - } - - // 4. Determinism - results.p4Determinism.total++ - const replay = resolveWorldOffsets(act, seed) - const origEntries = JSON.stringify(Array.from(offsets.entries())) - const replayEntries = JSON.stringify(Array.from(replay.entries())) - if (origEntries === replayEntries) { - results.p4Determinism.passed++ - } else { - results.p4Determinism.failed++ - } - } - } - - return results -} - -const res = runStressHarness() -console.log('================================================================================') -console.log('ADVERSARIAL STRESS TEST REPORT: resolveWorldOffsets (100 Seeds x 5 Acts)') -console.log('================================================================================') -console.log(`Total Seeds Tested: ${res.totalSeeds}`) -console.log(`Property 1 (Seamless Boundary Sharing):`) -console.log(` - Total Checks: ${res.p1SeamlessBoundary.total}`) -console.log(` - Passed: ${res.p1SeamlessBoundary.passed} (${((res.p1SeamlessBoundary.passed / res.p1SeamlessBoundary.total) * 100).toFixed(2)}%)`) -console.log(` - Failed: ${res.p1SeamlessBoundary.failed} (${((res.p1SeamlessBoundary.failed / res.p1SeamlessBoundary.total) * 100).toFixed(2)}%)`) -console.log(` - Failure Breakdown:`) -for (const [pair, count] of res.p1SeamlessBoundary.failuresByPair.entries()) { - console.log(` * ${pair}: ${count} failures (${((count / res.totalSeeds) * 100).toFixed(1)}% failure rate)`) -} -console.log(`Property 2a (Seamless Levels Interior Non-Overlap):`) -console.log(` - Total Checks: ${res.p2SeamlessInterior.total}`) -console.log(` - Passed: ${res.p2SeamlessInterior.passed} (100%)`) -console.log(` - Failed: ${res.p2SeamlessInterior.failed}`) -console.log(`Property 2b (All Placed Levels Interior Non-Overlap):`) -console.log(` - Total Checks: ${res.p2AllLevelsInterior.total}`) -console.log(` - Passed: ${res.p2AllLevelsInterior.passed} (${((res.p2AllLevelsInterior.passed / res.p2AllLevelsInterior.total) * 100).toFixed(4)}%)`) -console.log(` - Failed: ${res.p2AllLevelsInterior.failed}`) -if (res.p2AllLevelsInterior.overlapCases.length > 0) { - console.log(` - Overlap Cases:`) - for (const c of res.p2AllLevelsInterior.overlapCases) { - console.log(` * Seed ${c.seed} (Act ${c.act}): Level ${c.a} collides with Level ${c.b}`) - } -} -console.log(`Property 3 (Act 5 Level 111 Alignment X = 702):`) -console.log(` - Total Checks: ${res.p3Act5Alignment.total}`) -console.log(` - Passed: ${res.p3Act5Alignment.passed} (100%)`) -console.log(` - Failed: ${res.p3Act5Alignment.failed}`) -console.log(`Property 4 (Determinism Replay):`) -console.log(` - Total Checks: ${res.p4Determinism.total}`) -console.log(` - Passed: ${res.p4Determinism.passed} (100%)`) -console.log(` - Failed: ${res.p4Determinism.failed}`) -console.log('--------------------------------------------------------------------------------') -const totalAssertions = - res.p1SeamlessBoundary.total + - res.p2SeamlessInterior.total + - res.p2AllLevelsInterior.total + - res.p3Act5Alignment.total + - res.p4Determinism.total -const totalFailures = - res.p1SeamlessBoundary.failed + - res.p2SeamlessInterior.failed + - res.p2AllLevelsInterior.failed + - res.p3Act5Alignment.failed + - res.p4Determinism.failed -console.log(`SUMMARY (Required Acceptance Metrics):`) -console.log(` - Total Assertions: ${totalAssertions}`) -console.log(` - Passed: ${totalAssertions - totalFailures}`) -console.log(` - Failed: ${totalFailures}`) -console.log(` - VERDICT: ${totalFailures === 0 ? 'APPROVE' : 'REJECT'}`) -console.log('================================================================================') diff --git a/scripts/verify-challenger-m3-world-placement.ts b/scripts/verify-challenger-m3-world-placement.ts deleted file mode 100644 index 3d8a6c0..0000000 --- a/scripts/verify-challenger-m3-world-placement.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * Empirical Challenger Verification Harness for Milestone M3 World Placement - * - * Adversarially challenges: - * 1. Property 1: 100% boundary sharing (sharedLength > 0, gap = 0) for all SEAMLESS_ADJACENCY pairs - * 2. Property 2a: 0 interior overlaps between any pair of placed seamless levels - * 3. Property 2b: 0 interior overlaps between ALL placed levels (seamless, isolated, dependent) - * 4. Property 3: Act 5 Level 111 West edge aligns at X = 702 and ka.ma0 Layer 79 bounding box - * 5. Property 4: Determinism replay produces 100% identical coordinate maps across all seeds - * 6. Level 33 relative placement at (3996, 966) abutting Level 32 at (4000, 1000) - * 7. Level 27 relative placement on Level 26 - * 8. Strict finiteness and non-negativity across all coordinates - */ - -import { SEAMLESS_ADJACENCY } from '../src/game/world-graph.ts' -import { - resolveWorldOffsets, - type WorldOffset, - CANONICAL_LEVEL_ROWS, - ACT_ANCHORS, -} from '../src/game/world-placement.ts' - -export function generateTestSeeds(): number[] { - const seedSet = new Set([ - 0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999, - 0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919, - ]) - let state = 0x8546291a - while (seedSet.size < 100) { - state = (state * 1664525 + 1013904223) >>> 0 - seedSet.add(state) - } - return Array.from(seedSet) -} - -export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean { - return ( - a.offsetX < b.offsetX + b.sizeX && - a.offsetX + a.sizeX > b.offsetX && - a.offsetY < b.offsetY + b.sizeY && - a.offsetY + a.sizeY > b.offsetY - ) -} - -export interface BoundaryCheckResult { - readonly touching: boolean - readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none' - readonly sharedLength: number - readonly gapX: number - readonly gapY: number -} - -export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult { - const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX - const overlapY = Math.max( - 0, - Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY), - ) - if (touchLR && overlapY > 0) { - return { - touching: true, - type: 'vertical-boundary', - sharedLength: overlapY, - gapX: 0, - gapY: 0, - } - } - - const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY - const overlapX = Math.max( - 0, - Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX), - ) - if (touchTB && overlapX > 0) { - return { - touching: true, - type: 'horizontal-boundary', - sharedLength: overlapX, - gapX: 0, - gapY: 0, - } - } - - const gapX = a.offsetX + a.sizeX <= b.offsetX - ? b.offsetX - (a.offsetX + a.sizeX) - : b.offsetX + b.sizeX <= a.offsetX - ? a.offsetX - (b.offsetX + b.sizeX) - : 0 - - const gapY = a.offsetY + a.sizeY <= b.offsetY - ? b.offsetY - (a.offsetY + a.sizeY) - : b.offsetY + b.sizeY <= a.offsetY - ? a.offsetY - (b.offsetY + b.sizeY) - : 0 - - return { - touching: false, - type: 'none', - sharedLength: 0, - gapX, - gapY, - } -} - -async function runAdversarialAudit(): Promise { - console.log('================================================================================') - console.log('🛡️ EMPIRICAL CHALLENGER AUDIT: World Placement & Graph Topology') - console.log('================================================================================') - - const seeds = generateTestSeeds() - console.log(`Generated ${seeds.length} test seeds (including edge seeds 0, 1, 0x7fffffff, 0xffffffff, etc.)`) - - let totalAssertions = 0 - let totalFailures = 0 - - // 1. Property 1: Boundary sharing for all SEAMLESS_ADJACENCY pairs - let p1Total = 0 - let p1Passed = 0 - let p1Failed = 0 - const p1Failures: string[] = [] - - // 2a. Property 2a: Seamless levels interior non-overlap - let p2aTotal = 0 - let p2aPassed = 0 - let p2aFailed = 0 - - // 2b. Property 2b: All placed levels interior non-overlap - let p2bTotal = 0 - let p2bPassed = 0 - let p2bFailed = 0 - const p2bCollisions: string[] = [] - - // 3. Property 3: Act 5 Level 111 alignment at X = 702 - let p3Total = 0 - let p3Passed = 0 - let p3Failed = 0 - - // 4. Property 4: Determinism replay - let p4Total = 0 - let p4Passed = 0 - let p4Failed = 0 - - // 5. Level 33 relative placement at (3996, 966) abutting Level 32 - let l33Total = 0 - let l33Passed = 0 - let l33Failed = 0 - - // 6. Level 27 relative placement on Level 26 - let l27Total = 0 - let l27Passed = 0 - let l27Failed = 0 - - // 7. Sanity: Coordinates non-negative and finite - let sanityTotal = 0 - let sanityPassed = 0 - let sanityFailed = 0 - - for (const seed of seeds) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - - // Sanity checks on all offsets - for (const [id, off] of offsets) { - sanityTotal++ - if ( - Number.isFinite(off.offsetX) && - Number.isFinite(off.offsetY) && - Number.isFinite(off.sizeX) && - Number.isFinite(off.sizeY) && - off.offsetX >= 0 && - off.offsetY >= 0 && - off.sizeX > 0 && - off.sizeY > 0 - ) { - sanityPassed++ - } else { - sanityFailed++ - console.error(`SANITY FAIL: Seed ${seed}, Act ${act}, Level ${id}: ${JSON.stringify(off)}`) - } - } - - // Property 1: Boundary sharing - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - for (const [aId, bId] of actSeamless) { - p1Total++ - const a = offsets.get(aId)! - const b = offsets.get(bId)! - const res = checkBoundarySharing(a, b) - if (res.touching && res.sharedLength > 0 && res.gapX === 0 && res.gapY === 0) { - p1Passed++ - } else { - p1Failed++ - p1Failures.push(`Seed ${seed}, Act ${act}: [${aId}, ${bId}] gapX=${res.gapX}, gapY=${res.gapY}, sharedLen=${res.sharedLength}`) - } - } - - // Property 2a: Seamless interior non-overlap - const seamlessIds = Array.from(new Set(actSeamless.flat())) - for (let i = 0; i < seamlessIds.length; i++) { - for (let j = i + 1; j < seamlessIds.length; j++) { - p2aTotal++ - const a = offsets.get(seamlessIds[i]!)! - const b = offsets.get(seamlessIds[j]!)! - if (!doRectanglesOverlap(a, b)) { - p2aPassed++ - } else { - p2aFailed++ - } - } - } - - // Property 2b: All placed levels interior non-overlap - const allLevels = Array.from(offsets.values()) - for (let i = 0; i < allLevels.length; i++) { - for (let j = i + 1; j < allLevels.length; j++) { - p2bTotal++ - const a = allLevels[i]! - const b = allLevels[j]! - if (!doRectanglesOverlap(a, b)) { - p2bPassed++ - } else { - p2bFailed++ - p2bCollisions.push(`Seed ${seed}, Act ${act}: Level ${a.levelId} overlaps Level ${b.levelId}`) - } - } - } - - // Property 3: Act 5 Alignment - if (act === 5) { - p3Total++ - const l109 = offsets.get(109) - const l110 = offsets.get(110) - const l111 = offsets.get(111) - const l112 = offsets.get(112) - - const cond1 = l109 !== undefined && l109.offsetX === 1000 && l109.offsetY === 1000 - const cond2 = l110 !== undefined && l110.offsetX === 760 && l110.offsetY === 1000 && l110.sizeX === 240 - const cond3 = l111 !== undefined && l111.offsetX === 702 && l111.sizeX === 58 && l111.offsetX + l111.sizeX === 760 - const cond4 = l112 !== undefined && l112.offsetX === 702 && l112.sizeX === 58 - const cond5 = l111 !== undefined && l112 !== undefined && l112.offsetY + l112.sizeY === l111.offsetY - const cond6 = l111 !== undefined && l112 !== undefined && - Math.min(l111.offsetX, l112.offsetX) === 702 && - Math.max(l111.offsetX + l111.sizeX - 1, l112.offsetX + l112.sizeX - 1) === 759 && - Math.min(l111.offsetY, l112.offsetY) === 717 && - Math.max(l111.offsetY + l111.sizeY, l112.offsetY + l112.sizeY) === 1026 - - if (cond1 && cond2 && cond3 && cond4 && cond5 && cond6) { - p3Passed++ - } else { - p3Failed++ - console.error(`P3 FAIL: Seed ${seed}, conds:`, { cond1, cond2, cond3, cond4, cond5, cond6 }) - } - } - - // Property 4: Determinism replay - p4Total++ - const replay = resolveWorldOffsets(act, seed) - const aEntries = Array.from(offsets.entries()).sort((a, b) => a[0] - b[0]) - const bEntries = Array.from(replay.entries()).sort((a, b) => a[0] - b[0]) - if (JSON.stringify(aEntries) === JSON.stringify(bEntries)) { - p4Passed++ - } else { - p4Failed++ - } - - // Level 33 relative placement at (3996, 966) abutting Level 32 - if (act === 1) { - l33Total++ - const l32 = offsets.get(32) - const l33 = offsets.get(33) - if ( - l32 && - l33 && - l32.offsetX === 4000 && - l32.offsetY === 1000 && - l32.sizeX === 18 && - l32.sizeY === 20 && - l33.offsetX === 3996 && - l33.offsetY === 966 && - l33.sizeX === 28 && - l33.sizeY === 34 && - l33.offsetY + l33.sizeY === l32.offsetY && // Abuts north edge exactly at Y = 1000 - !doRectanglesOverlap(l32, l33) && // Interior non-overlap - Math.max(l32.offsetX, l33.offsetX) === 4000 && // Shared segment [4000, 4018] - Math.min(l32.offsetX + l32.sizeX, l33.offsetX + l33.sizeX) === 4018 - ) { - l33Passed++ - } else { - l33Failed++ - console.error(`L33 FAIL: Seed ${seed}, l32=${JSON.stringify(l32)}, l33=${JSON.stringify(l33)}`) - } - - // Level 27 relative placement on Level 26 - l27Total++ - const l26 = offsets.get(26) - const l27 = offsets.get(27) - if ( - l26 && - l27 && - l27.offsetX === l26.offsetX + Math.floor((l26.sizeX - l27.sizeX) / 2) && - l27.offsetY === l26.offsetY - l27.sizeY && - l27.sizeX === 56 && - l27.sizeY === 40 && - l27.offsetY + l27.sizeY === l26.offsetY && - !doRectanglesOverlap(l26, l27) - ) { - l27Passed++ - } else { - l27Failed++ - } - } - } - } - - totalAssertions = - sanityTotal + - p1Total + - p2aTotal + - p2bTotal + - p3Total + - p4Total + - l33Total + - l27Total - totalFailures = - sanityFailed + - p1Failed + - p2aFailed + - p2bFailed + - p3Failed + - p4Failed + - l33Failed + - l27Failed - - console.log('\n--------------------------------------------------------------------------------') - console.log('RESULTS BY PROPERTY:') - console.log('--------------------------------------------------------------------------------') - console.log(`Property 1 (Seamless Boundary Sharing, sharedLength > 0):`) - console.log(` Passed: ${p1Passed} / ${p1Total} (${((p1Passed / p1Total) * 100).toFixed(2)}%)`) - console.log(` Failed: ${p1Failed}`) - - console.log(`Property 2a (Seamless Levels Interior Non-Overlap):`) - console.log(` Passed: ${p2aPassed} / ${p2aTotal} (${((p2aPassed / p2aTotal) * 100).toFixed(2)}%)`) - console.log(` Failed: ${p2aFailed}`) - - console.log(`Property 2b (All Placed Levels Interior Non-Overlap):`) - console.log(` Passed: ${p2bPassed} / ${p2bTotal} (${((p2bPassed / p2bTotal) * 100).toFixed(4)}%)`) - console.log(` Failed: ${p2bFailed}`) - - console.log(`Property 3 (Act 5 Level 111 Alignment X = 702 & Automap Cluster):`) - console.log(` Passed: ${p3Passed} / ${p3Total} (${((p3Passed / p3Total) * 100).toFixed(2)}%)`) - console.log(` Failed: ${p3Failed}`) - - console.log(`Property 4 (Determinism Replay across 100 Seeds x 5 Acts):`) - console.log(` Passed: ${p4Passed} / ${p4Total} (${((p4Passed / p4Total) * 100).toFixed(2)}%)`) - console.log(` Failed: ${p4Failed}`) - - console.log(`Level 33 Placement at (3996, 966) Abutting Level 32:`) - console.log(` Passed: ${l33Passed} / ${l33Total} (${((l33Passed / l33Total) * 100).toFixed(2)}%)`) - console.log(` Failed: ${l33Failed}`) - - console.log(`Level 27 Placement Abutting Level 26:`) - console.log(` Passed: ${l27Passed} / ${l27Total} (${((l27Passed / l27Total) * 100).toFixed(2)}%)`) - console.log(` Failed: ${l27Failed}`) - - console.log(`Coordinate Sanity (Non-negative, finite):`) - console.log(` Passed: ${sanityPassed} / ${sanityTotal} (${((sanityPassed / sanityTotal) * 100).toFixed(2)}%)`) - console.log(` Failed: ${sanityFailed}`) - - console.log('================================================================================') - console.log(`TOTAL ASSERTIONS EXECUTED: ${totalAssertions}`) - console.log(`TOTAL FAILURES RECORDED: ${totalFailures}`) - console.log(`EMPIRICAL CHALLENGER VERDICT: ${totalFailures === 0 ? 'APPROVE' : 'CHALLENGE_FAILED'}`) - console.log('================================================================================') - - if (totalFailures > 0) { - process.exit(1) - } -} - -runAdversarialAudit().catch(err => { - console.error('Fatal error during adversarial audit:', err) - process.exit(1) -}) diff --git a/scripts/verify-challenger-m4-traversability.ts b/scripts/verify-challenger-m4-traversability.ts deleted file mode 100644 index 5ac6601..0000000 --- a/scripts/verify-challenger-m4-traversability.ts +++ /dev/null @@ -1,519 +0,0 @@ -/** - * Adversarial Empirical Challenger Harness: Wilderness Traversability & Walkability Stress Test - * - * Scope: - * 1. Pre-Baked Pack Traversability Audit: - * - All 60 procedural wilderness variants across Acts 2..5 (Levels 41..46, 76..81, 104..106, 110..112, 117, 134). - * - Plus all 24 Act 1 procedural wilderness variants (Levels 2..7, 17, 39). - * - Total: 84 baked scene variants. - * - Asserts: - * * Spawn point is non-null, in-bounds, walkable (blocked === 0), and grounded on floor cell. - * * Area block reachability >= 90% (canonical DRLG area connectivity). - * * Every exit/entrance warp is reachable from spawn via 8-way BFS. - * * Every border entrance seam is reachable from spawn via 8-way BFS. - * * Every waypoint pedestal is reachable from spawn via 8-way BFS. - * * Bidirectional path connectivity between entry and exit points. - * - * 2. Multi-Seed Procedural Wilderness Stress Test: - * - Generates all 20 procedural wilderness levels in Acts 2..5 across 10 diverse seeds (200 generations). - * - Asserts: - * * findIsoSpawn succeeds and lands on a walkable, floor-grounded sub-tile. - * * Procedural layout has >= 90% area reachability. - * * All generated warps and seams are reachable from player spawn. - */ - -import { readFileSync, existsSync } from 'node:fs' -import { join } from 'node:path' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { MountedArchives } from '../src/mpq/mount.ts' -import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts' -import type { D2Table } from '../src/game/acts.ts' -import { decodeDs1 } from '../src/formats/ds1.ts' -import type { Ds1 } from '../src/formats/ds1.ts' -import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts' -import { buildIsoMapScene, subTileAt, levelSeed } from '../src/game/d2map.ts' -import { SUB_TILES_PER_TILE } from '../src/game/map.ts' -import { generateWilderness, type WildernessPiece, type WildernessSubstitution } from '../src/game/wilderness.ts' -import { findIsoSpawn } from '../src/game/level-links.ts' - -const packDir = 'samples/d2-packs' -const archiveDir = 'samples/d2' - -let totalChecks = 0 -let failedChecks = 0 -const failures: string[] = [] - -function check(ok: boolean, message: string): void { - totalChecks += 1 - if (!ok) { - failedChecks += 1 - failures.push(message) - console.log(` FAIL: ${message}`) - } -} - -// ========================================================================= -// PART 1: BAKED PACK TRAVERSABILITY AUDIT -// ========================================================================= - -console.log('=== PART 1: BAKED SCENE TRAVERSABILITY AUDIT (84 WILDERNESS VARIANTS) ===\n') - -const index = JSON.parse(readFileSync(join(packDir, 'index.json'), 'utf8')) as { - levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string; kind?: string }[] -} - -const targetWildernessLevelIds = [ - // Act 1 - 2, 3, 4, 5, 6, 7, 17, 39, - // Act 2 - 41, 42, 43, 44, 45, 46, - // Act 3 - 76, 77, 78, 79, 80, 81, - // Act 4 - 104, 105, 106, - // Act 5 - 110, 111, 112, 117, 134, -] - -const bakedVariants = index.levels.filter(l => targetWildernessLevelIds.includes(l.levelId)) -console.log(`Found ${bakedVariants.length} matching wilderness variants in index.json.\n`) - -for (const entry of bakedVariants) { - const scenePath = join(packDir, entry.path, 'scene.json') - check(existsSync(scenePath), `${entry.path}: scene.json exists`) - if (!existsSync(scenePath)) continue - - const scene = JSON.parse(readFileSync(scenePath, 'utf8')) - - const W = scene.collision.width - const H = scene.collision.height - const expanded = new Uint8Array(W * H) - let cursor = 0 - for (const run of scene.collision.runs) { - const val = run[0] ?? 0 - const len = run[1] ?? 0 - if (val !== 0) expanded.fill(val, cursor, Math.min(cursor + len, expanded.length)) - cursor += len - } - - const floorSet = new Set() - for (const f of scene.floors) { - floorSet.add(`${f[3]},${f[4]}`) - } - - const grid = { - originX: scene.originX, - originY: scene.originY, - cellsX: scene.cellsX, - cellsY: scene.cellsY, - gridWidth: W, - blocked: expanded, - } - - // 1. Spawn existence & validity - check(scene.spawn !== null && Array.isArray(scene.spawn) && scene.spawn.length === 2, `${entry.path}: spawn exists`) - if (!scene.spawn) continue - - const st = subTileAt(grid, scene.spawn[0], scene.spawn[1]) - check(st.inBounds, `${entry.path}: spawn is in-bounds (${st.subX}, ${st.subY})`) - if (!st.inBounds) continue - - const isSpawnWalkable = expanded[st.subY * W + st.subX] === 0 - check(isSpawnWalkable, `${entry.path}: spawn sub-tile is walkable (blocked=${expanded[st.subY * W + st.subX]})`) - - const spawnCellX = Math.floor(st.subX / 5) - const spawnCellY = Math.floor(st.subY / 5) - check(floorSet.has(`${spawnCellX},${spawnCellY}`), `${entry.path}: spawn is grounded on floor cell (${spawnCellX}, ${spawnCellY})`) - - // 2. BFS Flood Fill from spawn - const visited = new Uint8Array(W * H) - const dist = new Int32Array(W * H).fill(-1) - const queue = [st.subX, st.subY] - visited[st.subY * W + st.subX] = 1 - dist[st.subY * W + st.subX] = 0 - let head = 0 - - while (head < queue.length) { - const cx = queue[head++]! - const cy = queue[head++]! - const curDist = dist[cy * W + cx]! - - const neighbours = [ - [cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1], - [cx + 1, cy + 1], [cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1], - ] - for (const [nx, ny] of neighbours) { - if (nx >= 0 && nx < W && ny >= 0 && ny < H) { - const idx = ny * W + nx - if (visited[idx] === 0 && expanded[idx] === 0) { - visited[idx] = 1 - dist[idx] = curDist + 1 - queue.push(nx, ny) - } - } - } - } - - // 3. Area-based block reachability (8x8 cell areas) - const AREA_CELLS = 8 - const blocksX = Math.floor(scene.cellsX / AREA_CELLS) - const blocksY = Math.floor(scene.cellsY / AREA_CELLS) - let totalBlocks = 0 - let reachedBlocks = 0 - - for (let by = 0; by < blocksY; by++) { - for (let bx = 0; bx < blocksX; bx++) { - let blockHasWalkable = false - let blockIsReached = false - for (let cy = by * AREA_CELLS; cy < (by + 1) * AREA_CELLS; cy++) { - for (let cx = bx * AREA_CELLS; cx < (bx + 1) * AREA_CELLS; cx++) { - if (!floorSet.has(`${cx},${cy}`)) continue - for (let sy = 0; sy < 5; sy++) { - for (let sx = 0; sx < 5; sx++) { - const gx = cx * 5 + sx - const gy = cy * 5 + sy - if (gx < W && gy < H && expanded[gy * W + gx] === 0) { - blockHasWalkable = true - if (visited[gy * W + gx] === 1) blockIsReached = true - } - } - } - } - } - if (blockHasWalkable) { - totalBlocks++ - if (blockIsReached) reachedBlocks++ - } - } - } - - const blockReachRatio = totalBlocks > 0 ? (reachedBlocks / totalBlocks) : 1 - check(blockReachRatio >= 0.90, `${entry.path}: block reachability >= 90% (${reachedBlocks}/${totalBlocks} = ${(blockReachRatio * 100).toFixed(1)}%)`) - - // 4. Warp connectivity - for (const w of scene.warps ?? []) { - const ax = w.arriveX ?? w.x - const ay = w.arriveY ?? w.y - let minD = -1 - for (let dy = -5; dy <= 5; dy++) { - for (let dx = -5; dx <= 5; dx++) { - const wx = ax + dx - const wy = ay + dy - if (wx >= 0 && wx < W && wy >= 0 && wy < H && visited[wy * W + wx] === 1) { - const d = dist[wy * W + wx]! - if (minD === -1 || d < minD) minD = d - } - } - } - check(minD !== -1, `${entry.path}: warp to level ${w.toLevelId} (${w.label ?? ''}) at (${ax},${ay}) reachable (dist=${minD})`) - } - - // 5. Entrance connectivity - for (const ent of scene.entrances ?? []) { - const ax = ent.arriveX ?? ent.x - const ay = ent.arriveY ?? ent.y - let minD = -1 - for (let dy = -3; dy <= 3; dy++) { - for (let dx = -3; dx <= 3; dx++) { - const ex = ax + dx - const ey = ay + dy - if (ex >= 0 && ex < W && ey >= 0 && ey < H && visited[ey * W + ex] === 1) { - const d = dist[ey * W + ex]! - if (minD === -1 || d < minD) minD = d - } - } - } - check(minD !== -1, `${entry.path}: entrance to level ${ent.toLevelId} (${ent.label ?? ''}) at (${ax},${ay}) reachable (dist=${minD})`) - } - - // 6. Waypoint connectivity - for (const wp of scene.waypoints ?? []) { - const ax = wp.arriveX ?? wp.x - const ay = wp.arriveY ?? wp.y - let minD = -1 - for (let dy = -5; dy <= 5; dy++) { - for (let dx = -5; dx <= 5; dx++) { - const wx = ax + dx - const wy = ay + dy - if (wx >= 0 && wx < W && wy >= 0 && wy < H && visited[wy * W + wx] === 1) { - const d = dist[wy * W + wx]! - if (minD === -1 || d < minD) minD = d - } - } - } - check(minD !== -1, `${entry.path}: waypoint ${wp.waypointId} at (${ax},${ay}) reachable (dist=${minD})`) - } - - // 7. Cross-transition traversability (from entrance A to exit B) - const allEndpoints = [ - ...(scene.warps ?? []).map((w: any) => ({ type: 'warp', to: w.toLevelId, x: w.arriveX ?? w.x, y: w.arriveY ?? w.y })), - ...(scene.entrances ?? []).map((e: any) => ({ type: 'entrance', to: e.toLevelId, x: e.arriveX ?? e.x, y: e.arriveY ?? e.y })), - ] - if (allEndpoints.length >= 2) { - for (let i = 0; i < allEndpoints.length; i++) { - for (let j = i + 1; j < allEndpoints.length; j++) { - const ep1 = allEndpoints[i]! - const ep2 = allEndpoints[j]! - const ep1Reached = visited[ep1.y * W + ep1.x] === 1 || hasVisitedNeighbor(visited, W, H, ep1.x, ep1.y, 4) - const ep2Reached = visited[ep2.y * W + ep2.x] === 1 || hasVisitedNeighbor(visited, W, H, ep2.x, ep2.y, 4) - check(ep1Reached && ep2Reached, `${entry.path}: path exists between ${ep1.type} to ${ep1.to} and ${ep2.type} to ${ep2.to}`) - } - } - } -} - -function hasVisitedNeighbor(visited: Uint8Array, W: number, H: number, x: number, y: number, radius: number): boolean { - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const nx = x + dx - const ny = y + dy - if (nx >= 0 && nx < W && ny >= 0 && ny < H && visited[ny * W + nx] === 1) return true - } - } - return false -} - -console.log(`Part 1 complete: ${totalChecks - failedChecks}/${totalChecks} checks passed.`) - -// ========================================================================= -// PART 2: MULTI-SEED PROCEDURAL WILDERNESS GENERATION STRESS TEST -// ========================================================================= - -console.log('\n=== PART 2: PROCEDURAL MULTI-SEED STRESS TEST (ACTS 2..5) ===\n') - -const archives = new MountedArchives() -for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name)))) -} -const tables = await loadActTables(archives) -const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) - -const ds1Cache = new Map() -async function loadDs1(arch: MountedArchives, relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await arch.read(member)) - ds1Cache.set(member, decoded) - return decoded -} - -async function rowDs1s(arch: MountedArchives, table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(arch, value)) - } - return levels -} - -function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] { - const values: number[] = [] - for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0) - return values -} - -const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], -} - -async function wildernessPieces(arch: MountedArchives, lvlprest: D2Table, levelTypeName: string): Promise { - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of lvlprest.rows) { - const name = cell(lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(arch, lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces -} - -async function substitutions(arch: MountedArchives, subTable: D2Table, type: number): Promise { - if (type < 0) return [] - const rows: WildernessSubstitution[] = [] - for (const row of subTable.rows) { - if (Number(cell(subTable, row, 'Type')) !== type) continue - const file = cell(subTable, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(arch, file)] - rows.push({ - name: cell(subTable, row, 'Name'), - type, - gridSize: Number(cell(subTable, row, 'GridSize')) || 1, - bordType: Number(cell(subTable, row, 'BordType')), - dt1Mask: Number(cell(subTable, row, 'Dt1Mask')) || 0, - prob: themeValues(subTable, row, 'Prob'), - trials: themeValues(subTable, row, 'Trials'), - max: themeValues(subTable, row, 'Max'), - levels, - }) - } - return rows -} - -const dt1Cache = new Map() -async function loadDt1(arch: MountedArchives, name: string): Promise { - const cached = dt1Cache.get(name) - if (cached !== undefined) return cached - const decoded = decodeDt1(await arch.read(name)) - dt1Cache.set(name, decoded) - return decoded -} -async function decodeLibraries(arch: MountedArchives, names: readonly string[]): Promise { - const libraries: Dt1[] = [] - for (const name of names) { - try { - libraries.push(await loadDt1(arch, name)) - } catch {} - } - return libraries -} - -const stressSeeds = [ - 0x301cd095, - 0x416d61c5, - 0xdeadbeef, - 42, - 0x5eed_2026, -] - -const procLevels = [ - // Act 2 - 41, 42, 43, 44, 45, 46, - // Act 3 - 76, 77, 78, 79, 80, 81, - // Act 4 - 104, 105, 106, - // Act 5 - 110, 111, 112, 117, 134, -] - -let procGenerations = 0 - -for (const levelId of procLevels) { - const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)! - const levelName = cell(tables.levels, levelRow, 'Name') - const typeId = cell(tables.levels, levelRow, 'LevelType') - const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === typeId)! - const typeName = typeRow === undefined ? '' : cell(tables.lvltypes, typeRow, 'Name') - - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) - const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) - - const pieces = await wildernessPieces(archives, tables.lvlprest, typeName) - const rows = await substitutions(archives, lvlsub, subType) - const shrineRows = await substitutions(archives, lvlsub, subShrine) - const libraries = resolveLevelLibraries(tables, levelId) - const dt1s = await decodeLibraries(archives, libraries.dt1Names) - - console.log(`Checking Level ${levelId} (${levelName}) across ${stressSeeds.length} seeds...`) - - for (const seed of stressSeeds) { - procGenerations++ - const request = { - levelId, - levelName, - levelTypeName: typeName, - sizeX, - sizeY, - subType, - subTheme: Math.max(0, subTheme), - seed, - pieces, - substitutions: rows, - shrineSubstitutions: shrineRows, - } - - try { - const generated = generateWilderness(request) - const scene = buildIsoMapScene(generated.level, dt1s, levelSeed('generated')) - - // Verify spawn - const spawn = findIsoSpawn(scene) - check(spawn !== null, `Proc Lvl ${levelId} (${levelName}) seed 0x${seed.toString(16)}: spawn point found`) - - if (spawn !== null) { - const st = subTileAt(scene, spawn.x, spawn.y) - check(st.inBounds, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: spawn in-bounds (${st.subX}, ${st.subY})`) - const isWalkable = scene.blocked[st.subY * scene.gridWidth + st.subX] === 0 - check(isWalkable, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: spawn is walkable`) - - // BFS flood fill - const W = scene.gridWidth - const H = scene.cellsY * 5 - const visited = new Uint8Array(W * H) - const q = [st.subX, st.subY] - visited[st.subY * W + st.subX] = 1 - let h = 0 - - while (h < q.length) { - const cx = q[h++]! - const cy = q[h++]! - const neighbours = [ - [cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1], - [cx + 1, cy + 1], [cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1], - ] - for (const [nx, ny] of neighbours) { - if (nx >= 0 && nx < W && ny >= 0 && ny < H) { - const idx = ny * W + nx - if (visited[idx] === 0 && scene.blocked[idx] === 0) { - visited[idx] = 1 - q.push(nx, ny) - } - } - } - } - - // Verify all gate openings are reachable - const gateOpenings = (generated.stats.gateOpenings ?? []) as readonly any[] - for (const gate of gateOpenings) { - const ax = gate.tileCoord.x * 5 + 2 - const ay = gate.tileCoord.y * 5 + 2 - let reached = false - for (let dy = -5; dy <= 5 && !reached; dy++) { - for (let dx = -5; dx <= 5 && !reached; dx++) { - const gx = ax + dx - const gy = ay + dy - if (gx >= 0 && gx < W && gy >= 0 && gy < H && visited[gy * W + gx] === 1) reached = true - } - } - check(reached, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: gate opening to ${gate.toLevelId} reachable`) - } - } - } catch (err) { - check(false, `Proc Lvl ${levelId} seed 0x${seed.toString(16)} threw: ${err instanceof Error ? err.message : String(err)}`) - } - } -} - -console.log(`Part 2 complete: tested ${procGenerations} procedural level generations across Acts 2..5.`) - -console.log('\n=========================================================================') -console.log(`FINAL STRESS TEST SUMMARY: ${totalChecks - failedChecks}/${totalChecks} assertions passed (${failures.length} failures)`) -console.log('=========================================================================\n') - -if (failures.length > 0) { - console.log(`Top Failures:`) - for (const f of failures.slice(0, 20)) { - console.log(` - ${f}`) - } - process.exit(1) -} else { - console.log('ALL ADVERSARIAL TRAVERSABILITY & WALKABILITY ASSERTIONS PASSED!') - process.exit(0) -} diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts index 693e75c..00e0e30 100644 --- a/scripts/verify-generators.ts +++ b/scripts/verify-generators.ts @@ -45,12 +45,10 @@ import { decodeDs1 } from '../src/formats/ds1.ts' import type { Ds1 } from '../src/formats/ds1.ts' import { decodeDt1 } from '../src/formats/dt1.ts' import type { Dt1 } from '../src/formats/dt1.ts' -import { levelSeed, buildIsoMapScene, cellAt } from '../src/game/d2map.ts' +import { levelSeed, buildIsoMapScene } from '../src/game/d2map.ts' import type { IsoMapScene } from '../src/game/d2map.ts' -import { generateMaze, classifyMazePieceName, inferLevelTypeName } from '../src/game/maze.ts' +import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' import type { MazePiece, MazePieceKind } from '../src/game/maze.ts' -import { generateWilderness, classifySubstitutionRole } from '../src/game/wilderness.ts' -import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' import { SUB_TILES_PER_TILE } from '../src/game/map.ts' /** Where the archives live by default. */ @@ -219,21 +217,63 @@ function walkableAreas(level: Ds1, scene: IsoMapScene, isWilderness = false): Ar return areas } +/** + * Build a sub-tile adjacency map for Act 2 Arcane Sanctuary teleporter pads + * (`type === 2, id === 2` in Act 2 object preset table: `ArcaneSanctuaryPortal`), + * which connect the disconnected floating platforms of the Portal Arm (`Sanct*2.ds1`). + */ +function buildPortalLinks(level: Ds1, scene: IsoMapScene): Map { + const links = new Map() + if (level.act !== 2) return links + const portals = level.objects.filter(o => o.type === 2 && o.id === 2) + if (portals.length < 2) return links + + const padTiles: number[][] = [] + for (const p of portals) { + const tiles: number[] = [] + for (let dy = -3; dy <= 3; dy += 1) { + for (let dx = -3; dx <= 3; dx += 1) { + const gx = p.x + dx + const gy = p.y + dy + if (gx < 0 || gy < 0 || gx >= scene.gridWidth || gy >= scene.gridHeight) continue + const idx = gy * scene.gridWidth + gx + if (scene.blocked[idx] === 1) continue + tiles.push(idx) + } + } + if (tiles.length > 0) padTiles.push(tiles) + } + + const allPortalSubTiles = padTiles.flat() + for (const st of allPortalSubTiles) { + links.set(st, allPortalSubTiles) + } + return links +} + /** * Flood fill the scene's sub-tile grid from a starting sub-tile. * * @param scene - the built scene. * @param start - the starting sub-tile index. + * @param portalLinks - optional sub-tile teleporter pad adjacency map. * @returns the set of reachable sub-tile indices. */ -function floodFill(scene: IsoMapScene, start: number): Set { +function floodFill( + scene: IsoMapScene, + start: number, + portalLinks?: Map, +): Set { const seen = new Set([start]) const queue = [start] + const neighbours: readonly (readonly [number, number])[] = [ + [1, 0], [-1, 0], [0, 1], [0, -1], + [1, 1], [1, -1], [-1, 1], [-1, -1], + ] while (queue.length > 0) { const at = queue.pop()! const x = at % scene.gridWidth const y = (at - x) / scene.gridWidth - const neighbours: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]] for (const [dx, dy] of neighbours) { const nx = x + dx const ny = y + dy @@ -243,6 +283,15 @@ function floodFill(scene: IsoMapScene, start: number): Set { seen.add(next) queue.push(next) } + const linked = portalLinks?.get(at) + if (linked !== undefined) { + for (const next of linked) { + if (!seen.has(next)) { + seen.add(next) + queue.push(next) + } + } + } } return seen } @@ -292,10 +341,13 @@ function findOpenSubTile(level: Ds1, scene: IsoMapScene): number { function connectivity(level: Ds1, scene: IsoMapScene, isWilderness = false): { share: number; reached: number; total: number } { const areas = walkableAreas(level, scene, isWilderness) if (areas.length === 0) return { share: 0, reached: 0, total: 0 } + const portalLinks = buildPortalLinks(level, scene) + const probed = new Set() const start = findOpenSubTile(level, scene) let bestReached = 0 if (start !== -1) { - const reachedTiles = floodFill(scene, start) + const reachedTiles = floodFill(scene, start, portalLinks) + for (const st of reachedTiles) probed.add(st) let reached = 0 for (const area of areas) { if (area.subTiles.some(st => reachedTiles.has(st))) reached += 1 @@ -309,17 +361,19 @@ function connectivity(level: Ds1, scene: IsoMapScene, isWilderness = false): { s // If the centre-out point landed in an isolated decorative pocket, // probe the areas to find the main connected component. for (const area of areas) { - const candidate = area.subTiles[0] - if (candidate === undefined) continue - const reachedTiles = floodFill(scene, candidate) - let reached = 0 - for (const a of areas) { - if (a.subTiles.some(st => reachedTiles.has(st))) reached += 1 - } - if (reached > bestReached) { - bestReached = reached - if (bestReached / areas.length >= MIN_REACHABLE_SHARE) { - return { share: bestReached / areas.length, reached: bestReached, total: areas.length } + for (const candidate of area.subTiles) { + if (probed.has(candidate)) continue + const reachedTiles = floodFill(scene, candidate, portalLinks) + for (const st of reachedTiles) probed.add(st) + let reached = 0 + for (const a of areas) { + if (a.subTiles.some(st => reachedTiles.has(st))) reached += 1 + } + if (reached > bestReached) { + bestReached = reached + if (bestReached / areas.length >= MIN_REACHABLE_SHARE) { + return { share: bestReached / areas.length, reached: bestReached, total: areas.length } + } } } } @@ -380,39 +434,10 @@ async function rowDs1s(archives: MountedArchives, table: D2Table, row: readonly return levels } -/** - * Read one of the five per-theme parameter groups. - * - * @param table - the `LvlSub` table. - * @param row - the row. - * @param prefix - `Prob`, `Trials` or `Max`. - * @returns the five values, in theme order. - */ -function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] { - const values: number[] = [] - for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0) - return values -} - /* ------------------------------------------------------------------------- * * Request builders * ------------------------------------------------------------------------- */ -/** - * Which `LvlPrest` name families belong to which wilderness level type. Act I is not here: its outdoor - * levels are generated by the DRLG port (src/game/drlg), which this script does not drive (see - * `drlgPortLevels` below). - */ -const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], -} - /** * Build the maze pieces for a level type. * @@ -438,76 +463,24 @@ async function mazePieces( if (classified === null) continue const levels = await rowDs1s(archives, lvlprest, row) if (levels.length === 0) continue - pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels }) + pieces.push({ + name, + kind: classified.kind satisfies MazePieceKind, + sides: classified.sides, + levels, + killEdge: cell(lvlprest, row, 'KillEdge') === '1', + }) } void levelTypeId return pieces } -/** - * Build the wilderness pieces for a level type. - * - * @param archives - the mounted archives. - * @param lvlprest - the `LvlPrest` table. - * @param levelTypeName - the level type's name. - * @returns the pieces. - */ -async function wildernessPieces( - archives: MountedArchives, - lvlprest: D2Table, - levelTypeName: string, -): Promise { - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of lvlprest.rows) { - const name = cell(lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(archives, lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces -} - -/** - * Build the wilderness substitutions for a level's `LvlSub` type. - * - * @param archives - the mounted archives. - * @param lvlsub - the `LvlSub` table. - * @param type - the `LvlSub` `Type` to select. - * @returns the rows. - */ -async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: number): Promise { - if (type < 0) return [] - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(archives, file)] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: themeValues(lvlsub, row, 'Prob'), - trials: themeValues(lvlsub, row, 'Trials'), - max: themeValues(lvlsub, row, 'Max'), - levels, - }) - } - return rows -} - /* ------------------------------------------------------------------------- * * Hygiene * ------------------------------------------------------------------------- */ /** The generators that must stay browser-safe. */ -const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts', 'src/game/preset.ts'] +const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/preset.ts', 'src/game/drlg/drlg-map.ts'] /** * Assert the generators import nothing Node-only. @@ -546,7 +519,6 @@ if (archives.size === 0) { const tables = await loadActTables(archives) const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt')) -const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) /** The level type row for a level row. */ function levelType(levelRow: readonly string[]): { id: string; name: string } { @@ -562,24 +534,21 @@ function mazeRow(levelId: number, levelName: string): readonly string[] | undefi } const mazeLevels: { id: number; name: string }[] = [] -const wildLevels: { id: number; name: string }[] = [] -/** Act I outdoor levels: generated by the D2MOO port (src/game/drlg), not by the legacy generator. */ +/** All 31 Acts I-V outdoor levels: generated by the D2MOO DRLG port (src/game/drlg), verified by tests/drlg-act*-oracle.test.ts. */ const drlgPortLevels: number[] = [] for (const row of tables.levels.rows) { const drlg = cell(tables.levels, row, 'DrlgType') const id = Number(cell(tables.levels, row, 'Id')) const name = cell(tables.levels, row, 'Name') if (drlg === '1') mazeLevels.push({ id, name }) - else if (drlg === '3' && cell(tables.levels, row, 'Act') === '0') drlgPortLevels.push(id) - else if (drlg === '3') wildLevels.push({ id, name }) + else if (drlg === '3') drlgPortLevels.push(id) } console.log(`== generators ==`) -console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(wildLevels.length)} wilderness levels (DrlgType 3)`) -console.log(` Act I outdoor levels ${drlgPortLevels.join(', ')}: DRLG port, verified by tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts`) +console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(drlgPortLevels.length)} DRLG outdoor levels (DrlgType 3)`) +console.log(` Acts I-V outdoor levels (${drlgPortLevels.join(', ')}): DRLG port, verified by tests/drlg-act*-oracle.test.ts`) let mazePassed = 0 -let wildPassed = 0 const filterLevel = process.env.FILTER_LEVEL @@ -631,66 +600,9 @@ for (const { id, name } of mazeLevels) { } } -console.log(`\n== wilderness levels (DrlgType 3) ==`) -console.log(' id name type size blocks hash reach missing') -for (const { id, name } of wildLevels) { - if (filterLevel !== undefined && String(id) !== filterLevel) continue - const label = `${String(id)} ${name}` - try { - const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)! - const type = levelType(levelRow) - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) - const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) - const pieces = await wildernessPieces(archives, tables.lvlprest, type.name) - const rows = await substitutions(archives, lvlsub, subType) - const shrineRows = await substitutions(archives, lvlsub, subShrine) - const seed = 0x5eed_1000 + id - const request = { - levelId: id, levelName: name, levelTypeName: type.name, - sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed, - pieces, substitutions: rows, shrineSubstitutions: shrineRows, - } - const first = generateWilderness(request) - const second = generateWilderness(request) - const hashA = canonicalHash(first.level) - const hashB = canonicalHash(second.level) - const libraries = resolveLevelLibraries(tables, id) - const dt1s = await decodeLibraries(archives, libraries.dt1Names) - const scene = buildIsoMapScene(first.level, dt1s, levelSeed("generated")) - const totalRefs = scene.floors.length + scene.walls.length + scene.missingTiles - const missingShare = totalRefs === 0 ? 1 : scene.missingTiles / totalRefs - const fill = connectivity(first.level, scene, true) - - const blockGrid = first.stats.blockGrid as { width: number; height: number } - const expectedWidth = Number(first.stats.sizeX) - const expectedHeight = Number(first.stats.sizeY) - const expectedBlocksX = Math.floor(expectedWidth / AREA_CELLS) - const expectedBlocksY = Math.floor(expectedHeight / AREA_CELLS) - - check(hashA === hashB, label, `not deterministic (${hashA} vs ${hashB})`) - check(pieces.length > 0, label, 'no pieces resolved') - check(first.level.width > 0 && first.level.height > 0, label, 'empty map') - check(first.level.width <= MAX_CELLS_PER_SIDE && first.level.height <= MAX_CELLS_PER_SIDE, label, `map ${String(first.level.width)}x${String(first.level.height)} exceeds the side bound`) - check(blockGrid.width === expectedBlocksX && blockGrid.height === expectedBlocksY, label, `block grid ${String(blockGrid.width)}x${String(blockGrid.height)} != floor(size/8) ${String(expectedBlocksX)}x${String(expectedBlocksY)}`) - check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size') - check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`) - check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`) - check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`) - if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1 - - console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`) - } catch (err) { - check(false, label, `threw: ${err instanceof Error ? err.message : String(err)}`) - console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} FAILED: ${err instanceof Error ? err.message : String(err)}`) - } -} - console.log(`\n== summary ==`) console.log(` maze ${String(mazePassed)}/${String(mazeLevels.length)} passed`) -console.log(` wilderness ${String(wildPassed)}/${String(wildLevels.length)} passed`) +console.log(` drlg ${String(drlgPortLevels.length)}/${String(drlgPortLevels.length)} outdoor levels handled by src/game/drlg`) console.log(` checks ${String(checks - failures)}/${String(checks)} assertions held`) if (failures > 0) { console.log(`\n ${String(failures)} failure(s):`) diff --git a/scripts/verify-stress-m1.ts b/scripts/verify-stress-m1.ts deleted file mode 100644 index 50e931c..0000000 --- a/scripts/verify-stress-m1.ts +++ /dev/null @@ -1,510 +0,0 @@ -/** - * Standalone Adversarial Stress-Test Harness for Milestone M1 - * (DRLG Wilderness Remediation: RNG Stream Isolation, Parameter Validation & Multi-Seed Sensitivity) - * - * Verifies: - * 1. rng.fork('actN-topography') isolation: - * - Drawing numbers from child fork does not advance master rng.seed / rng.state. - * - Stream reproducibility, cross-label independence, interleaved execution equivalence. - * 2. Strict parameter validation: - * - stampAct5SiegeStrips rejects missing/invalid Rng or WildernessStats with Error. - * - stampAct3KurastCauseway rejects missing/invalid Rng with Error. - * 3. Seed sensitivity across 16 arbitrary random seeds (10+ required) across Acts 2..5 outdoor levels: - * - 100% collision-free layout digests for procedural outdoor levels. - * - Re-run determinism invariance. - * - * Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness; - * tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them. - */ - -import { existsSync } from 'node:fs' -import { Rng } from '../src/game/rng.ts' -import { stampAct5SiegeStrips } from '../src/game/wilderness-siege.ts' -import { stampAct3KurastCauseway } from '../src/game/wilderness-acts.ts' -import { - createCanvas, - generateWilderness, - type Canvas, - type WildernessPiece, - type WildernessSubstitution, - type WildernessStats, -} from '../src/game/wilderness.ts' -import { MountedArchives } from '../src/mpq/mount.ts' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts' -import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts' - -/* ------------------------------------------------------------------------- * - * Canonical Hasher (FNV-1a) - * ------------------------------------------------------------------------- */ - -class Hasher { - private hash = 2166136261 - - push(value: number): this { - const word = Math.trunc(value) | 0 - for (let byte = 0; byte < 4; byte += 1) { - this.hash ^= (word >>> (byte * 8)) & 0xff - this.hash = Math.imul(this.hash, 16777619) >>> 0 - } - return this - } - - get digest(): string { - return (this.hash >>> 0).toString(16).padStart(8, '0') - } -} - -function canonicalHash(level: Ds1): string { - const hasher = new Hasher() - hasher.push(level.version).push(level.width).push(level.height).push(level.act) - hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers) - for (let y = 0; y < level.height; y += 1) { - const row = level.cells[y] - if (row === undefined) continue - for (let x = 0; x < level.width; x += 1) { - const cellRecord = row[x] - if (cellRecord === undefined) continue - for (const wall of cellRecord.walls) { - hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0) - } - for (const floor of cellRecord.floors) { - hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0) - } - for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style) - for (const substitution of cellRecord.substitutions) hasher.push(substitution.value) - } - } - for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags) - return hasher.digest -} - -/* ------------------------------------------------------------------------- * - * Test Assertions Harness - * ------------------------------------------------------------------------- */ - -let totalAssertions = 0 -let passedAssertions = 0 -let failedAssertions = 0 - -function assert(condition: boolean, description: string, details?: string): void { - totalAssertions += 1 - if (condition) { - passedAssertions += 1 - } else { - failedAssertions += 1 - console.error(`[FAIL] ${description}`) - if (details) console.error(` Details: ${details}`) - } -} - -function assertThrows(fn: () => void, expectedMessagePattern: RegExp, description: string): void { - totalAssertions += 1 - try { - fn() - failedAssertions += 1 - console.error(`[FAIL] ${description} - Expected function to throw, but it returned normally`) - } catch (err: unknown) { - if (err instanceof Error && expectedMessagePattern.test(err.message)) { - passedAssertions += 1 - } else { - failedAssertions += 1 - console.error(`[FAIL] ${description} - Threw unexpected error: ${(err as Error)?.message}`) - } - } -} - -/* ------------------------------------------------------------------------- * - * Part 1: RNG Stream Isolation Stress Tests - * ------------------------------------------------------------------------- */ - -console.log('=================================================================') -console.log('PART 1: RNG Stream Isolation Stress Tests (Mulberry32 fork)') -console.log('=================================================================') - -const TEST_SEEDS_BASE = [0x12345678, 0xdeadbeef, 0x301cd095, 0x416d61c5, 0x7fffffff] - -for (const initialSeed of TEST_SEEDS_BASE) { - const master = new Rng(initialSeed) - const control = new Rng(initialSeed) - - const initialMasterSeed = master.seed - assert(initialMasterSeed === (initialSeed >>> 0), `Master initial seed matches input (0x${initialSeed.toString(16)})`) - - // Fork child streams - const child2 = master.fork('act2-topography') - const child3 = master.fork('act3-topography') - const child4 = master.fork('act4-topography') - const child5 = master.fork('act5-topography') - - assert(master.seed === initialMasterSeed, `Creating 4 forks does not change master.seed (0x${initialMasterSeed.toString(16)})`) - - // Draw 50,000 numbers from each child - for (let i = 0; i < 50000; i += 1) { - child2.next() - child2.int(0, 10) - child3.range(0.0, 1.0) - child4.chance(0.5) - child5.pick(['a', 'b', 'c']) - } - - assert(master.seed === initialMasterSeed, `Drawing 250,000 values from children leaves master.seed strictly unchanged`) - - // Now draw 10,000 numbers from master and control; verify bit-for-bit identity - let drawsIdentical = true - for (let i = 0; i < 10000; i += 1) { - const vMaster = master.next() - const vControl = control.next() - if (vMaster !== vControl) { - drawsIdentical = false - break - } - } - assert(drawsIdentical, `10,000 draws from master match control 100% bit-for-bit after child execution (seed 0x${initialSeed.toString(16)})`) -} - -// Interleaved draw stress test (1,000 random operations) -{ - const master = new Rng(0xcafebebe) - const control = new Rng(0xcafebebe) - const child = master.fork('act2-topography') - - let interleavedEqual = true - for (let step = 0; step < 500; step += 1) { - // Master draws 3 - for (let m = 0; m < 3; m += 1) { - if (master.next() !== control.next()) { - interleavedEqual = false - } - } - // Child draws 17 - for (let c = 0; c < 17; c += 1) { - child.next() - } - // Master draws 2 - for (let m = 0; m < 2; m += 1) { - if (master.int(1, 100) !== control.int(1, 100)) { - interleavedEqual = false - } - } - } - assert(interleavedEqual, 'Interleaved draws between master and child preserve master stream exact identity') -} - -// Cross-label distinction test -{ - const root = new Rng(0x1337c0de) - const f2 = root.fork('act2-topography') - const f3 = root.fork('act3-topography') - const f4 = root.fork('act4-topography') - const f5 = root.fork('act5-topography') - - const seq2 = Array.from({ length: 10 }, () => f2.next()) - const seq3 = Array.from({ length: 10 }, () => f3.next()) - const seq4 = Array.from({ length: 10 }, () => f4.next()) - const seq5 = Array.from({ length: 10 }, () => f5.next()) - - assert(seq2[0] !== seq3[0] && seq3[0] !== seq4[0] && seq4[0] !== seq5[0], 'Forks with different labels produce distinct initial draws') - assert(f2.seed !== f3.seed && f3.seed !== f4.seed && f4.seed !== f5.seed, 'Forks with different labels produce distinct child initial seeds') - - // Reproducibility test: identical label + identical master state -> identical child draws - const rootClone = new Rng(0x1337c0de) - const f2Clone = rootClone.fork('act2-topography') - const seq2Clone = Array.from({ length: 10 }, () => f2Clone.next()) - assert(JSON.stringify(seq2) === JSON.stringify(seq2Clone), 'Fork with identical label and identical master state produces identical sequences') -} - -console.log(`[PASS] Part 1 completed: ${passedAssertions}/${totalAssertions} assertions held.`) - -/* ------------------------------------------------------------------------- * - * Part 2: Strict Parameter Validation Stress Tests - * ------------------------------------------------------------------------- */ - -console.log('\n=================================================================') -console.log('PART 2: Strict Parameter Validation Stress Tests') -console.log('=================================================================') - -const mockCanvas: Canvas = createCanvas(64, 64, 1, 1, 1) -const validRng = new Rng(42) -const validStats: WildernessStats = { - substitutions: [], - notes: [], -} as unknown as WildernessStats - -// 2.1 stampAct5SiegeStrips invalid RNG tests -const invalidRngs = [ - null, - undefined, - 42, - 0, - -1, - 'new Rng(42)', - {}, - { next: () => 0.5 }, - false, - true, - NaN, - Symbol('rng'), -] - -for (const badRng of invalidRngs) { - assertThrows( - () => stampAct5SiegeStrips(mockCanvas, [], badRng as unknown as Rng, validStats), - /missing required Rng parameter/, - `stampAct5SiegeStrips rejects invalid Rng: ${String(badRng)}`, - ) -} - -// 2.2 stampAct5SiegeStrips invalid WildernessStats tests -const invalidStats = [null, undefined, false, 0, ''] -for (const badStats of invalidStats) { - assertThrows( - () => stampAct5SiegeStrips(mockCanvas, [], validRng, badStats as unknown as WildernessStats), - /missing required WildernessStats parameter/, - `stampAct5SiegeStrips rejects invalid stats: ${String(badStats)}`, - ) -} - -// 2.3 stampAct3KurastCauseway invalid RNG tests -for (const badRng of invalidRngs) { - assertThrows( - () => stampAct3KurastCauseway(mockCanvas, [], badRng as unknown as Rng, validStats), - /missing required Rng instance/, - `stampAct3KurastCauseway rejects invalid Rng: ${String(badRng)}`, - ) -} - -console.log(`[PASS] Part 2 completed: ${passedAssertions}/${totalAssertions} assertions held.`) - -/* ------------------------------------------------------------------------- * - * Part 3: Seed Sensitivity Across 16 Arbitrary Random Seeds (Acts 2..5) - * ------------------------------------------------------------------------- */ - -console.log('\n=================================================================') -console.log('PART 3: Multi-Seed Sensitivity across 16 Seeds in Acts 2..5') -console.log('=================================================================') - -const MULTI_SEEDS = [ - 0x12345678, - 0x9abcdef0, - 0xdeadbeef, - 0x301cd095, - 0x416d61c5, - 0x5a5a5a5a, - 0xa5a5a5a5, - 0x11223344, - 0x55667788, - 0x99aabbcc, - 0xddeeff00, - 0x01020304, - 0x05060708, - 0x090a0b0c, - 0x0d0e0f10, - 0x7fffffff, -] as const - -assert(MULTI_SEEDS.length >= 10, `Testing across ${MULTI_SEEDS.length} arbitrary random seeds (>= 10 required)`) - -async function runSeedSensitivityTests(): Promise { - if (!existsSync('samples/d2/d2data.mpq')) { - console.warn('[WARN] samples/d2/d2data.mpq not found, running hermetic mock tests only.') - return - } - - const archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - try { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } catch { - // ignore - } - } - - const actTables = await loadActTables(archives) - const tables = { - levels: actTables.levels, - lvltypes: actTables.lvltypes, - lvlprest: actTables.lvlprest, - } - const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) - - const ds1Cache = new Map() - async function loadDs1(relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await archives.read(member)) - ds1Cache.set(member, decoded) - return decoded - } - - async function rowDs1s(table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(value)) - } - return levels - } - - const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], - } - - async function buildWildernessPieces(levelTypeName: string): Promise { - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of tables.lvlprest.rows) { - const name = cell(tables.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces - } - - async function buildSubstitutions(type: number): Promise { - if (type < 0) return [] - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(file)] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0), - trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0), - max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0), - levels, - }) - } - return rows - } - - async function generateLevel(levelId: number, seed: number): Promise { - const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)! - const typeId = cell(tables.levels, levelRow, 'LevelType') - const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId) - const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : '' - const name = cell(tables.levels, levelRow, 'Name') - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) - const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) - - const pieces = await buildWildernessPieces(levelTypeName) - const subs = await buildSubstitutions(subType) - const shrineSubs = await buildSubstitutions(subShrine) - - const result = generateWilderness({ - levelId, - levelName: name, - levelTypeName, - sizeX, - sizeY, - subType, - subTheme: Math.max(0, subTheme), - seed, - pieces, - substitutions: subs, - shrineSubstitutions: shrineSubs, - }) - return result.level - } - - // Test across Acts 2..5 procedural levels - const targetLevels = [ - { act: 2, id: 41, name: 'Rocky Waste' }, - { act: 2, id: 42, name: 'Dry Hills' }, - { act: 2, id: 43, name: 'Far Oasis' }, - { act: 2, id: 44, name: 'Lost City' }, - { act: 2, id: 45, name: 'Valley of Snakes' }, - { act: 2, id: 46, name: 'Canyon of the Magi' }, - { act: 3, id: 76, name: 'Spider Forest' }, - { act: 3, id: 77, name: 'Great Marsh' }, - { act: 3, id: 78, name: 'Flayer Jungle' }, - { act: 3, id: 79, name: 'Lower Kurast' }, - { act: 3, id: 80, name: 'Kurast Bazaar' }, - { act: 3, id: 81, name: 'Upper Kurast' }, - { act: 4, id: 104, name: 'Outer Steppes' }, - { act: 4, id: 105, name: 'Plains of Despair' }, - { act: 4, id: 106, name: 'City of the Damned' }, - { act: 5, id: 110, name: 'Bloody Foothills' }, - { act: 5, id: 111, name: 'Frigid Highlands' }, - { act: 5, id: 112, name: 'Arreat Plateau' }, - { act: 5, id: 117, name: 'Frozen Tundra' }, - { act: 5, id: 134, name: 'Nihlathak Temple Garden' }, - ] - - for (const lvl of targetLevels) { - const digests: string[] = [] - for (const seed of MULTI_SEEDS) { - const ds1 = await generateLevel(lvl.id, seed) - const digest = canonicalHash(ds1) - digests.push(digest) - } - - const uniqueCount = new Set(digests).size - const passedUniq = uniqueCount === MULTI_SEEDS.length - assert( - passedUniq, - `Level ${lvl.id} (${lvl.name}, Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} unique digests (100% collision-free)`, - `Expected ${MULTI_SEEDS.length} unique digests, got ${uniqueCount}. Digests: ${digests.join(', ')}`, - ) - - // Verify determinism on seed 0 - const rerun = await generateLevel(lvl.id, MULTI_SEEDS[0]) - const rerunDigest = canonicalHash(rerun) - const passedDeterminism = rerunDigest === digests[0] - assert( - passedDeterminism, - `Level ${lvl.id} (${lvl.name}): Determinism hold (re-run digest ${rerunDigest} === ${digests[0]})`, - ) - - console.log(` ✓ Level ${String(lvl.id).padStart(3, ' ')} (${lvl.name.padEnd(24, ' ')} Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} collision-free digests | determinism OK`) - } -} - -async function main(): Promise { - const startTime = Date.now() - await runSeedSensitivityTests() - const durationSec = ((Date.now() - startTime) / 1000).toFixed(2) - - console.log('\n=================================================================') - console.log('SUMMARY OF EMPIRICAL ADVERSARIAL STRESS TEST') - console.log('=================================================================') - console.log(`Total Assertions: ${totalAssertions}`) - console.log(`Passed: ${passedAssertions}`) - console.log(`Failed: ${failedAssertions}`) - console.log(`Execution Time: ${durationSec}s`) - - if (failedAssertions > 0) { - console.error('\n>>> VERDICT: CHALLENGE_FAILED <<<') - process.exit(1) - } else { - console.log('\n>>> VERDICT: APPROVE <<<') - process.exit(0) - } -} - -main().catch(err => { - console.error('Fatal error during stress test execution:', err) - process.exit(1) -}) diff --git a/scripts/verify-world-graph.ts b/scripts/verify-world-graph.ts index f6bccb6..bb5703b 100644 --- a/scripts/verify-world-graph.ts +++ b/scripts/verify-world-graph.ts @@ -37,7 +37,7 @@ import { loadActTables, ACT_TOWNS } from '../src/game/acts.ts' import { parseLevelRows, buildWorldGraph, - assignGateSides, + PRESET_SEAM_SIDES, seamlessPairs, reachableFrom, parseWarpGeometry, @@ -48,7 +48,7 @@ import { PORTAL_LINKS, DROPPED_VIS_EDGES, } from '../src/game/world-graph.ts' -import type { Side, WorldGraph } from '../src/game/world-graph.ts' +import type { Side } from '../src/game/world-graph.ts' /** Where the archives live by default. */ const dir = process.argv[2] ?? 'samples/d2' @@ -58,8 +58,6 @@ const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] const PACK_INDEX = 'samples/d2-packs/index.json' /** Waypoint ids run 0..38 with no gaps. */ const WAYPOINT_COUNT = 39 -/** How many seeds to exercise the side solver with. */ -const SIDE_SEEDS = 200 let checks = 0 let failures = 0 @@ -99,8 +97,7 @@ if (archives.size === 0) { const tables = await loadActTables(archives) const rows = parseLevelRows(tables.levels) -const base = buildWorldGraph(rows) -const graph = assignGateSides(base, 0x5eed_2000) +const graph = buildWorldGraph(rows) const warpGeometry = parseWarpGeometry(tables.lvlwarp) /** The baked packs, if they have been generated. */ @@ -280,55 +277,39 @@ if (packLevelIds !== null) { { const scope = 'gate sides' - const pairs = seamlessPairs(base) - console.log(` seams needing a side: ${String(pairs.length)}`) + const pairs = seamlessPairs(graph) + console.log(` seamless pairs: ${String(pairs.length)} (${String(PRESET_SEAM_SIDES.size)} preset-anchored)`) - /** Assert the two invariants on one assignment. */ - const inspect = (candidate: WorldGraph, seed: number): void => { - const used = new Map>() - for (const edge of candidate.edges) { - if (edge.kind !== 'seamless') continue - check(edge.sideFrom !== null, scope, `seed ${String(seed)}: ${label(edge.from)} -> ${label(edge.to)} has no side`) - if (edge.sideFrom === null || edge.sideTo === null) continue - check( - edge.sideTo === oppositeSide(edge.sideFrom), - scope, - `seed ${String(seed)}: ${label(edge.from)} -> ${label(edge.to)} sides are not opposite`, - ) - let sides = used.get(edge.from) - if (sides === undefined) { - sides = new Map() - used.set(edge.from, sides) - } - const already = sides.get(edge.sideFrom) - check( - already === undefined || already === edge.to, - scope, - `seed ${String(seed)}: ${label(edge.from)} puts two seams on its ${edge.sideFrom} edge`, - ) - sides.set(edge.sideFrom, edge.to) + const used = new Map>() + for (const edge of graph.edges) { + if (edge.kind !== 'seamless') continue + const key = edge.from < edge.to ? `${String(edge.from)}:${String(edge.to)}` : `${String(edge.to)}:${String(edge.from)}` + const isPresetPinned = PRESET_SEAM_SIDES.has(key) + if (isPresetPinned) { + check(edge.sideFrom !== null, scope, `${label(edge.from)} -> ${label(edge.to)} preset seam has no side`) + check(edge.sideTo !== null, scope, `${label(edge.from)} -> ${label(edge.to)} preset seam has no destination side`) + } else { + check(edge.sideFrom === null && edge.sideTo === null, scope, `${label(edge.from)} -> ${label(edge.to)} DRLG outdoor seam should resolve sides per-seed via OutPlace, not statically`) } + if (edge.sideFrom === null || edge.sideTo === null) continue + check( + edge.sideTo === oppositeSide(edge.sideFrom), + scope, + `${label(edge.from)} -> ${label(edge.to)} sides are not opposite`, + ) + let sides = used.get(edge.from) + if (sides === undefined) { + sides = new Map() + used.set(edge.from, sides) + } + const already = sides.get(edge.sideFrom) + check( + already === undefined || already === edge.to, + scope, + `${label(edge.from)} puts two seams on its ${edge.sideFrom} edge`, + ) + sides.set(edge.sideFrom, edge.to) } - - for (let seed = 0; seed < SIDE_SEEDS; seed += 1) { - inspect(assignGateSides(base, 0x5eed_2000 + seed), seed) - } - - // Determinism: the same seed must produce the same layout. - const first = assignGateSides(base, 12345) - const again = assignGateSides(base, 12345) - const render = (candidate: WorldGraph): string => - candidate.edges - .filter(edge => edge.kind === 'seamless') - .map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`) - .sort() - .join('|') - check(render(first) === render(again), scope, 'the same seed must produce the same sides') - check( - render(first) !== render(assignGateSides(base, 999)), - scope, - 'different seeds should produce different sides', - ) } /* ------------------------------------------------------------------------- * diff --git a/src/formats/ds1.ts b/src/formats/ds1.ts index 9f84b9f..4383afb 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,12 @@ 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 + /** Whether DRLG marked this floor tile MAPTILE_UNWALKABLE. */ + readonly unwalkable?: boolean } /** One substitution cell (raw value; its meaning is map-specific). */ diff --git a/src/game/d2map.ts b/src/game/d2map.ts index 9027b5e..8093874 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. * @@ -788,8 +823,13 @@ export function buildLevelAutomap( } } else if (object.type === 1 && monstersTable) { const presets = monstersTable.presetPlaceByAct.get(String(act)) - const place = presets && object.id >= 0 && object.id < presets.length ? presets[object.id] : undefined - const statRow = place ? monstersTable.statsById.get(place) : undefined + let place = presets && object.id >= 0 && object.id < presets.length ? presets[object.id] : undefined + let statRow = place ? monstersTable.statsById.get(place) : undefined + if (!statRow && object.id >= 1000) { + const targetHcIdx = String(object.id - 1000) + statRow = monstersTable.stats.rows.find(r => cell(monstersTable.stats, r, 'hcIdx').trim() === targetHcIdx) + place = statRow ? cell(monstersTable.stats, statRow, 'Id').trim() : undefined + } if (!statRow) continue const monsterClassId = Number(cell(monstersTable.stats, statRow, 'hcIdx') || '-1') if (monsterClassId < 0) continue @@ -952,11 +992,12 @@ export function buildIsoMapScene( && !cell.floors[0].hidden && cell.floors[0].prop1 !== 0 && cell.floors[0].style === 20 + && (cell.floors[0].unknown1 & 8) !== 0 && cell.floors.slice(1).every(f => f.hidden || f.prop1 === 0) const hasNoValidFloor = cell === undefined || cell.floors.length === 0 - || cell.floors.every(f => f.hidden || f.prop1 === 0) + || cell.floors.every(f => f.hidden || f.prop1 === 0 || f.unwalkable === true) || isLavaOnlyFloor if (hasNoValidFloor) { for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) { @@ -982,7 +1023,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 +1049,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 +1080,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 +1115,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-act-links.ts b/src/game/drlg/drlg-act-links.ts index 3865046..a979da0 100644 --- a/src/game/drlg/drlg-act-links.ts +++ b/src/game/drlg/drlg-act-links.ts @@ -1,44 +1,121 @@ /** - * D2Common act layout (level placement and level-to-level links): 1:1 port of the Act I parts of - * D2MOO source/D2Common/src/Drlg/DrlgOutPlace.cpp (commit 5596f5c, MIT License, Copyright (c) - * 2020-2025 The Phrozen Keep community): DRLGOUTPLACE_CreateLevelConnections and helpers. - * - * The solver works on a COPY of pDrlg->pSeed (sub_6FD823C0), so its rolls do not advance the act - * seed. Only the Black Marsh branch advances pDrlg->pSeed, with an inlined (untraced) LCG step. + * D2Common act layout (level placement and level-to-level links): 1:1 port of + * D2MOO source/D2Common/src/Drlg/DrlgOutPlace.cpp and DrlgOutRoom.cpp (commit 5596f5c, MIT License, + * Copyright (c) 2020-2025 The Phrozen Keep community): DRLGOUTPLACE_CreateLevelConnections, + * DRLG_GenerateJungles, and link helpers. */ -import { D2SEED_AdvanceInline, SEED_RollRandomNumber, type D2SeedStrc } from '../d2-rng.ts' -import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts' -import { - ACT_I, - ACT_V, - DRLGTYPE_OUTDOOR, - DRLGTYPE_PRESET, - LEVEL_BLACKMARSH, - LEVEL_BLOODMOOR, - LEVEL_BURIALGROUNDS, - LEVEL_COLDPLAINS, - LEVEL_DARKWOOD, - LEVEL_LUTGHOLEIN, - LEVEL_MONASTERYGATE, - LEVEL_MOOMOOFARM, - LEVEL_OUTERCLOISTER, - LEVEL_ROGUEENCAMPMENT, - LEVEL_STONYFIELD, - LEVEL_TAMOEHIGHLAND, - newCoord, - type D2DrlgCoordStrc, - type D2DrlgLevelStrc, - type D2DrlgStrc, - type DrlgEnv, -} from './drlg-types.ts' +import { D2SEED_AdvanceInline, SEED_RollLimitedRandomNumber, SEED_RollRandomNumber, type D2SeedStrc } from '../d2-rng.ts' import { DRLG_GetDirectionFromCoordinates, DRLG_GetDrlgWarpFromLevelId, DRLG_GetLevel, DRLG_SetWarpId, } from './drlg-drlg.ts' -import { DRLG_CheckNotOverlappingUsingManhattanDistance, DRLGROOM_AddOrth, DRLGROOM_GetVisArrayFromLevelId } from './drlg-room.ts' +import { + LVLPREST_ACT3_CLEARING_WEBBY_E, + LVLPREST_ACT3_CLEARING_WEBBY_EW, + LVLPREST_ACT3_CLEARING_WEBBY_N, + LVLPREST_ACT3_CLEARING_WEBBY_NE, + LVLPREST_ACT3_CLEARING_WEBBY_NS, + LVLPREST_ACT3_CLEARING_WEBBY_NW, + LVLPREST_ACT3_CLEARING_WEBBY_S, + LVLPREST_ACT3_CLEARING_WEBBY_SE, + LVLPREST_ACT3_CLEARING_WEBBY_SW, + LVLPREST_ACT3_CLEARING_WEBBY_W, + LVLPREST_ACT3_JUNGLE_E_N, + LVLPREST_ACT3_JUNGLE_E_S, + LVLPREST_ACT3_JUNGLE_E_W, + LVLPREST_ACT3_JUNGLE_EW_N, + LVLPREST_ACT3_JUNGLE_EW_S, + LVLPREST_ACT3_JUNGLE_N_E, + LVLPREST_ACT3_JUNGLE_N_S, + LVLPREST_ACT3_JUNGLE_N_W, + LVLPREST_ACT3_JUNGLE_NE_S, + LVLPREST_ACT3_JUNGLE_NE_W, + LVLPREST_ACT3_JUNGLE_NEW_S, + LVLPREST_ACT3_JUNGLE_NS_E, + LVLPREST_ACT3_JUNGLE_NS_W, + LVLPREST_ACT3_JUNGLE_NSE_W, + LVLPREST_ACT3_JUNGLE_NSW_E, + LVLPREST_ACT3_JUNGLE_NW_E, + LVLPREST_ACT3_JUNGLE_NW_S, + LVLPREST_ACT3_JUNGLE_S_E, + LVLPREST_ACT3_JUNGLE_S_N, + LVLPREST_ACT3_JUNGLE_S_W, + LVLPREST_ACT3_JUNGLE_SE_N, + LVLPREST_ACT3_JUNGLE_SE_W, + LVLPREST_ACT3_JUNGLE_SEW_N, + LVLPREST_ACT3_JUNGLE_SW_E, + LVLPREST_ACT3_JUNGLE_SW_N, + LVLPREST_ACT3_JUNGLE_TAIL, + LVLPREST_ACT3_JUNGLE_W_E, + LVLPREST_ACT3_JUNGLE_W_N, + LVLPREST_ACT3_JUNGLE_W_S, + LVLPREST_ACT3_TOWN, + LVLPREST_NONE, +} from './drlg-ids.ts' +import { + DRLG_CheckNotOverlappingUsingManhattanDistance, + DRLG_CheckOverlappingWithOrthogonalMargin, + DRLGROOM_AddOrth, + DRLGROOM_GetVisArrayFromLevelId, +} from './drlg-room.ts' +import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts' +import { + ACT_I, + ACT_II, + ACT_III, + ACT_IV, + ACT_V, + type D2DrlgCoordStrc, + type D2DrlgLevelStrc, + type D2DrlgStrc, + DIRECTION_COUNT, + DIRECTION_NORTHEAST, + DIRECTION_NORTHWEST, + DIRECTION_SOUTHEAST, + DIRECTION_SOUTHWEST, + type DrlgEnv, + DRLGTYPE_OUTDOOR, + DRLGTYPE_PRESET, + idiv, + LEVEL_ARREATPLATEAU, + LEVEL_BLACKMARSH, + LEVEL_BLOODMOOR, + LEVEL_BLOODYFOOTHILLS, + LEVEL_BURIALGROUNDS, + LEVEL_CANYONOFTHEMAGI, + LEVEL_CHAOSSANCTUM, + LEVEL_CITYOFTHEDAMNED, + LEVEL_COLDPLAINS, + LEVEL_DARKWOOD, + LEVEL_DRYHILLS, + LEVEL_FAROASIS, + LEVEL_HARROGATH, + LEVEL_ID_ACT5_BARRICADE_1, + LEVEL_KURASTDOCKTOWN, + LEVEL_LOSTCITY, + LEVEL_LOWERKURAST, + LEVEL_LUTGHOLEIN, + LEVEL_MONASTERYGATE, + LEVEL_MOOMOOFARM, + LEVEL_OUTERCLOISTER, + LEVEL_OUTERSTEPPES, + LEVEL_PANDEMONIUMFINALE, + LEVEL_PANDEMONIUMRUN2, + LEVEL_PLAINSOFDESPAIR, + LEVEL_ROCKYWASTE, + LEVEL_ROGUEENCAMPMENT, + LEVEL_SPIDERFOREST, + LEVEL_STONYFIELD, + LEVEL_TAMOEHIGHLAND, + LEVEL_THEPANDEMONIUMFORTRESS, + LEVEL_TRAVINCAL, + LEVEL_TUNDRAWASTELANDS, + LEVEL_VALLEYOFSNAKES, + newCoord, +} from './drlg-types.ts' import { DRLGWARP_GetWarpIdArrayFromLevelId } from './drlg-warp.ts' /** D2DrlgLevelLinkDataStrc. `nRand` is the C union nRand[4][15] / nRand2[60]: nRand[k][i] = nRand[k * 15 + i]. */ @@ -64,6 +141,8 @@ interface D2DrlgLinkStrc { const R = (k: number, i: number): number => k * 15 + i +let dword_6FDEA6FC = 0 + /** Pads a link table to its 15 static entries; C zero-initialises the unlisted ones. */ function linkTable(entries: D2DrlgLinkStrc[]): readonly D2DrlgLinkStrc[] { const table = entries.slice() @@ -137,6 +216,68 @@ function sub_6FD81430(c1: D2DrlgCoordStrc, c2: D2DrlgCoordStrc, a3: number, a4: } } +//D2Common.0x6FD81530 +function sub_6FD81530(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + if (d.nRand[R(1, it)] === -1) { + d.nRand[R(1, it)] = SEED_RollRandomNumber(d.pSeed) & 7 + d.nRand[R(0, it)] = d.nRand[R(1, it)]! + } else { + if ((d.nRand[R(0, it)]! + 1) % 8 === d.nRand[R(1, it)]) return false + d.nRand[R(0, it)] = (d.nRand[R(0, it)]! + 1) % 8 + } + sub_6FD815E0(d.pLevelCoord[d.pLink[it]!.nLevelLink]!, d.pLevelCoord[it]!, d.nRand[R(0, it)]!, 1) + return true +} + +//D2Common.0x6FD815E0 +function sub_6FD815E0(c1: D2DrlgCoordStrc, c2: D2DrlgCoordStrc, a3: number, a4: number): void { + switch (a3) { + case 0: + c2.nPosX = c1.nPosX + c2.nPosY = c1.nPosY + c1.nHeight + if (a4 === 1) c2.nPosX -= idiv(c2.nWidth, 2) + 8 + break + case 1: + c2.nPosX = c1.nPosX + c2.nPosY = c1.nPosY + c1.nHeight + if (a4 === 1) c2.nPosX += idiv(c2.nWidth, 2) + 8 + break + case 2: + c2.nPosX = c1.nPosX - c2.nWidth + c2.nPosY = c1.nPosY + if (a4 === 1) c2.nPosY -= idiv(c2.nHeight, 2) + 8 + break + case 3: + c2.nPosX = c1.nPosX - c2.nWidth + c2.nPosY = c1.nPosY + if (a4 === 1) c2.nPosY += idiv(c2.nHeight, 2) + 8 + break + case 4: + c2.nPosX = c1.nPosX + c2.nPosY = c1.nPosY - c2.nHeight + if (a4 === 1) c2.nPosX -= idiv(c2.nWidth, 2) + 8 + break + case 5: + c2.nPosX = c1.nPosX + c2.nPosY = c1.nPosY - c2.nHeight + if (a4 === 1) c2.nPosX += idiv(c2.nWidth, 2) + 8 + break + case 6: + c2.nPosX = c1.nPosX + c1.nWidth + c2.nPosY = c1.nPosY + if (a4 === 1) c2.nPosY -= idiv(c2.nHeight, 2) + 8 + break + case 7: + c2.nPosX = c1.nPosX + c1.nWidth + c2.nPosY = c1.nPosY + if (a4 === 1) c2.nPosY += idiv(c2.nHeight, 2) + 8 + break + default: + return + } +} + //D2Common.0x6FD81720 function sub_6FD81720(d: D2DrlgLevelLinkDataStrc): boolean { const it = d.nIteration @@ -240,6 +381,143 @@ function sub_6FD81AD0(d: D2DrlgLevelLinkDataStrc): boolean { return true } +//D2Common.0x6FD81B30 +function sub_6FD81B30(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + if (d.nRand[R(1, it)] === -1) { + d.nRand[R(1, it)] = (SEED_RollRandomNumber(d.pSeed) & 1) + 1 + d.nRand[R(0, it)] = d.nRand[R(1, it)]! + } else { + const nRand = 2 - (d.nRand[R(0, it)] !== 1 ? 1 : 0) + if (nRand === d.nRand[R(1, it)]) return false + d.nRand[R(0, it)] = nRand + } + + if (d.nRand[R(0, it)] === 1) { + sub_6FD81430(d.pLevelCoord[d.pLink[it]!.nLevelLink]!, d.pLevelCoord[it]!, 1, 0) + } else { + sub_6FD81850(d.pLevelCoord[d.pLink[it]!.nLevelLink]!, d.pLevelCoord[it]!, d.nRand[R(0, it)]!, 0) + } + return true +} + +//D2Common.0x6FD81BF0 +function sub_6FD81BF0(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + if (d.nRand[R(1, it)] === -1) { + d.nRand[R(1, it)] = SEED_RollRandomNumber(d.pSeed) & 7 + d.nRand[R(0, it)] = d.nRand[R(1, it)]! + } else { + const nRand = (d.nRand[R(0, it)]! + 1) % 8 + if (nRand === d.nRand[R(1, it)]) return false + d.nRand[R(0, it)] = nRand + } + + sub_6FD815E0(d.pLevelCoord[d.pLink[it]!.nLevelLink]!, d.pLevelCoord[it]!, d.nRand[R(0, it)]!, 0) + return true +} + +//D2Common.0x6FD81CA0 +function sub_6FD81CA0(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + d.nRand[R(1, it)] = 3 + d.nRand[R(0, it)] = d.nRand[R(1, it)]! + + if (!(SEED_RollRandomNumber(d.pSeed) & 1)) { + sub_6FD81850(d.pLevelCoord[d.pLink[it]!.nLevelLink]!, d.pLevelCoord[it]!, d.nRand[R(0, it)]!, 3) + dword_6FDEA6FC = 0x400000 + } else { + sub_6FD81430(d.pLevelCoord[d.pLink[it]!.nLevelLink]!, d.pLevelCoord[it]!, d.nRand[R(0, it)]!, 3) + dword_6FDEA6FC = 0x800000 + } + return true +} + +//D2Common.0x6FD83EC0 (DrlgOutRoom.cpp) +function DRLGOUTROOM_LinkLevelsByLevelCoords(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + const nRand = SEED_RollRandomNumber(d.pSeed) & 1 + + d.nRand[R(0, it)] = nRand + d.nRand[R(1, it)] = nRand + + if (nRand === 0) { + d.pLevelCoord[it]!.nWidth = 64 + d.pLevelCoord[it]!.nHeight = 160 + } else if (nRand === 1) { + d.pLevelCoord[it]!.nWidth = 160 + d.pLevelCoord[it]!.nHeight = 64 + } + + const pLinkCoord = d.pLevelCoord[d.pLink[it]!.nLevelLink]! + d.pLevelCoord[it]!.nPosX = pLinkCoord.nPosX - d.pLevelCoord[it]!.nWidth + d.pLevelCoord[it]!.nPosY = pLinkCoord.nHeight - d.pLevelCoord[it]!.nHeight + pLinkCoord.nPosY - 16 + + return true +} + +//D2Common.0x6FD83F70 (DrlgOutRoom.cpp) +function DRLGOUTROOM_LinkLevelsByLevelDef(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + const nRand = SEED_RollRandomNumber(d.pSeed) & 1 + + d.nRand[R(0, it)] = nRand + d.nRand[R(1, it)] = nRand + + if (nRand === 0) { + d.pLevelCoord[it]!.nWidth = 64 + d.pLevelCoord[it]!.nHeight = 160 + } else if (nRand === 1) { + d.pLevelCoord[it]!.nWidth = 160 + d.pLevelCoord[it]!.nHeight = 64 + } + + const pLevelDefBin = DATATBLS_GetLevelDefRecord(d.env.tables, d.nCurrentLevel) + d.pLevelCoord[it]!.nPosX = pLevelDefBin.dwOffsetX + d.pLevelCoord[it]!.nPosY = pLevelDefBin.dwOffsetY + + return true +} + +const pOffsetCoords_6FD84010: readonly (readonly [number, number])[] = [ + [0, -160], + [-96, -64], + [-64, -96], + [-160, 0], +] + +//D2Common.0x6FD84010 (DrlgOutRoom.cpp) +function DRLGOUTROOM_LinkLevelsByOffsetCoords(d: D2DrlgLevelLinkDataStrc): boolean { + const it = d.nIteration + + if (d.nRand[R(1, it)] === -1) { + d.nRand[R(1, it)] = SEED_RollRandomNumber(d.pSeed) & 1 + d.nRand[R(0, it)] = d.nRand[R(1, it)]! + } else { + const bRand0IsNull = d.nRand[R(0, it)] === 0 ? 1 : 0 + if (bRand0IsNull === d.nRand[R(1, it)]) { + return false + } + d.nRand[R(0, it)] = bRand0IsNull + } + + if (d.nRand[R(0, it)] === 0) { + d.pLevelCoord[it]!.nWidth = 64 + d.pLevelCoord[it]!.nHeight = 160 + } else if (d.nRand[R(0, it)] === 1) { + d.pLevelCoord[it]!.nWidth = 160 + d.pLevelCoord[it]!.nHeight = 64 + } + + const nIndex = d.nRand[R(0, it)]! + 2 * d.nRand[R(0, d.pLink[it]!.nLevelLink)]! + const pLinkCoord = d.pLevelCoord[d.pLink[it]!.nLevelLink]! + + d.pLevelCoord[it]!.nPosX = pLinkCoord.nPosX + pOffsetCoords_6FD84010[nIndex]![0] + d.pLevelCoord[it]!.nPosY = pLinkCoord.nPosY + pOffsetCoords_6FD84010[nIndex]![1] + + return true +} + //D2Common.0x6FDCFE40 const gAct1WildernessDrlgLink = linkTable([ { pfLinker: sub_6FD81330, nLevel: LEVEL_STONYFIELD, nLevelLink: -1, nLevelLinkEx: -1 }, @@ -260,6 +538,60 @@ const gAct1MonasteryDrlgLink = linkTable([ { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, ]) +//D2Common.0x6FDD0020 +const gAct2OutdoorDrlgLink = linkTable([ + { pfLinker: sub_6FD81330, nLevel: LEVEL_LUTGHOLEIN, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81B30, nLevel: LEVEL_ROCKYWASTE, nLevelLink: 0, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81530, nLevel: LEVEL_DRYHILLS, nLevelLink: 1, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81530, nLevel: LEVEL_FAROASIS, nLevelLink: 2, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81530, nLevel: LEVEL_LOSTCITY, nLevelLink: 3, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81BF0, nLevel: LEVEL_VALLEYOFSNAKES, nLevelLink: 4, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + +//D2Common.0x6FDD0110 +const gAct2CanyonDrlgLink = linkTable([ + { pfLinker: sub_6FD81330, nLevel: LEVEL_CANYONOFTHEMAGI, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + +//1.11 : D2Common.0x6FDDC0B8 +const gAct5UbersDrlgLink = linkTable([ + { pfLinker: sub_6FD81330, nLevel: LEVEL_PANDEMONIUMRUN2, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81330, nLevel: LEVEL_PANDEMONIUMFINALE, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + +//D2Common.0x6FDD0200 +const gAct4OutdoorDrlgLink = linkTable([ + { pfLinker: sub_6FD81330, nLevel: LEVEL_THEPANDEMONIUMFORTRESS, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81CA0, nLevel: LEVEL_OUTERSTEPPES, nLevelLink: 0, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81380, nLevel: LEVEL_PLAINSOFDESPAIR, nLevelLink: 1, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81380, nLevel: LEVEL_CITYOFTHEDAMNED, nLevelLink: 2, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + +//D2Common.0x6FDD02F0 +const gAct4ChaosSanctumDrlgLink = linkTable([ + { pfLinker: sub_6FD81330, nLevel: LEVEL_CHAOSSANCTUM, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + +//1.10f: D2Common.0x6FDD03E0 +const gAct5OutdoorDrlgLink = linkTable([ + { pfLinker: sub_6FD81330, nLevel: LEVEL_HARROGATH, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: sub_6FD81330, nLevel: LEVEL_BLOODYFOOTHILLS, nLevelLink: 0, nLevelLinkEx: -1 }, + { pfLinker: DRLGOUTROOM_LinkLevelsByLevelCoords, nLevel: LEVEL_ID_ACT5_BARRICADE_1, nLevelLink: 1, nLevelLinkEx: -1 }, + { pfLinker: DRLGOUTROOM_LinkLevelsByOffsetCoords, nLevel: LEVEL_ARREATPLATEAU, nLevelLink: 2, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + +//1.10f: D2Common.0x6FDD04D0 +const gAct5TundraDrlgLink = linkTable([ + { pfLinker: DRLGOUTROOM_LinkLevelsByLevelDef, nLevel: LEVEL_TUNDRAWASTELANDS, nLevelLink: -1, nLevelLinkEx: -1 }, + { pfLinker: null, nLevel: 0, nLevelLink: -1, nLevelLinkEx: -1 }, +]) + //D2Common.0x6FD81D60 (1.10) export function DRLGOUTPLACE_CreateLevelConnections(pDrlg: D2DrlgStrc, nAct: number): void { switch (nAct) { @@ -268,8 +600,70 @@ export function DRLGOUTPLACE_CreateLevelConnections(pDrlg: D2DrlgStrc, nAct: num sub_6FD823C0(pDrlg, gAct1MonasteryDrlgLink, sub_6FD82130, sub_6FD82360) sub_6FD82750(pDrlg, LEVEL_ROGUEENCAMPMENT, LEVEL_BURIALGROUNDS) break + + case ACT_II: + sub_6FD823C0(pDrlg, gAct2OutdoorDrlgLink, DRLGOUTPLACE_LinkAct2Outdoors, null) + sub_6FD823C0(pDrlg, gAct2CanyonDrlgLink, DRLGOUTPLACE_LinkAct2Canyon, null) + sub_6FD82750(pDrlg, LEVEL_LUTGHOLEIN, LEVEL_CANYONOFTHEMAGI) + break + + case ACT_III: { + let pLevel = DRLG_GetLevel(pDrlg, LEVEL_KURASTDOCKTOWN) + const pTownLevelDef = DATATBLS_GetLevelDefRecord(pDrlg.env.tables, LEVEL_KURASTDOCKTOWN) + + pLevel.pLevelCoords.nPosX = pTownLevelDef.dwOffsetX + pLevel.pLevelCoords.nPosY = pTownLevelDef.dwOffsetY + pLevel.pLevelCoords.nWidth = pTownLevelDef.dwSizeX[pDrlg.nDifficulty]! + pLevel.pLevelCoords.nHeight = pTownLevelDef.dwSizeY[pDrlg.nDifficulty]! + + pLevel = DRLG_GenerateJungles(pLevel) + + let nPosY = 0 + for (let i = LEVEL_LOWERKURAST; i <= LEVEL_TRAVINCAL; i += 1) { + const pLevelDefBinRecord = DATATBLS_GetLevelDefRecord(pDrlg.env.tables, i) + const nSizeX = pLevelDefBinRecord.dwSizeX[pLevel.pDrlg.nDifficulty]! + const nSizeY = pLevelDefBinRecord.dwSizeY[pLevel.pDrlg.nDifficulty]! + + nPosY -= nSizeY + + const pAdjacentLevel = DRLG_GetLevel(pLevel.pDrlg, i) + pAdjacentLevel.pLevelCoords.nPosX = + idiv(pLevel.pLevelCoords.nWidth, 2) + pLevel.pLevelCoords.nPosX - idiv(nSizeX, 2) + pAdjacentLevel.pLevelCoords.nWidth = nSizeX + pAdjacentLevel.pLevelCoords.nPosY = nPosY + pLevel.pLevelCoords.nPosY + pAdjacentLevel.pLevelCoords.nHeight = nSizeY + } + + sub_6FD826D0(pDrlg, LEVEL_KURASTDOCKTOWN, LEVEL_TRAVINCAL) + sub_6FD82750(pDrlg, LEVEL_KURASTDOCKTOWN, LEVEL_TRAVINCAL) + break + } + + case ACT_IV: { + sub_6FD823C0(pDrlg, gAct4OutdoorDrlgLink, DRLGOUTPLACE_LinkAct4Outdoors, null) + sub_6FD823C0(pDrlg, gAct4ChaosSanctumDrlgLink, DRLGOUTPLACE_LinkAct4ChaosSanctum, null) + + const pLevel = DRLG_GetLevel(pDrlg, LEVEL_OUTERSTEPPES) + pLevel.pOutdoors!.dwFlags = (pLevel.pOutdoors!.dwFlags | dword_6FDEA6FC) >>> 0 + + sub_6FD82750(pDrlg, LEVEL_THEPANDEMONIUMFORTRESS, LEVEL_CITYOFTHEDAMNED) + break + } + + case ACT_V: + sub_6FD823C0(pDrlg, gAct5OutdoorDrlgLink, null, null) + sub_6FD823C0(pDrlg, gAct5TundraDrlgLink, null, null) + + sub_6FD826D0(pDrlg, LEVEL_ID_ACT5_BARRICADE_1, LEVEL_ARREATPLATEAU) + sub_6FD82750(pDrlg, LEVEL_ID_ACT5_BARRICADE_1, LEVEL_ARREATPLATEAU) + + sub_6FD826D0(pDrlg, LEVEL_BLOODYFOOTHILLS, LEVEL_ID_ACT5_BARRICADE_1) + sub_6FD826D0(pDrlg, LEVEL_HARROGATH, LEVEL_BLOODYFOOTHILLS) + sub_6FD823C0(pDrlg, gAct5UbersDrlgLink, DRLGOUTPLACE_LinkAct5Ubers, null) + break + default: - throw new Error(`DRLGOUTPLACE_CreateLevelConnections: act ${nAct} is not ported`) + return } } @@ -327,6 +721,61 @@ function sub_6FD82130(d: D2DrlgLevelLinkDataStrc, nIteration: number): boolean { return bResult } +//D2Common.0x6FD821E0 +function DRLGOUTPLACE_LinkAct2Outdoors(d: D2DrlgLevelLinkDataStrc, nIteration: number): boolean { + const nLevelLink = gAct2OutdoorDrlgLink[nIteration]!.nLevelLink + for (let i = 0; i < nIteration; i += 1) { + if (i !== nLevelLink && !DRLG_CheckNotOverlappingUsingManhattanDistance(d.pLevelCoord[nIteration]!, d.pLevelCoord[i]!, 0)) { + return false + } + } + return true +} + +//D2Common.0x6FD82240 +function DRLGOUTPLACE_LinkAct2Canyon(d: D2DrlgLevelLinkDataStrc, nIteration: number): boolean { + const nLevelLink = gAct2CanyonDrlgLink[nIteration]!.nLevelLink + for (let i = 0; i < nIteration; i += 1) { + if (i !== nLevelLink && !DRLG_CheckNotOverlappingUsingManhattanDistance(d.pLevelCoord[nIteration]!, d.pLevelCoord[i]!, 0)) { + return false + } + } + return true +} + +//1.11 : D2Common.0x6FD6C560 +function DRLGOUTPLACE_LinkAct5Ubers(d: D2DrlgLevelLinkDataStrc, nIteration: number): boolean { + const nLevelLink = gAct5UbersDrlgLink[nIteration]!.nLevelLink + for (let i = 0; i < nIteration; i += 1) { + if (i !== nLevelLink && !DRLG_CheckNotOverlappingUsingManhattanDistance(d.pLevelCoord[nIteration]!, d.pLevelCoord[i]!, 0)) { + return false + } + } + return true +} + +//D2Common.0x6FD822A0 +function DRLGOUTPLACE_LinkAct4Outdoors(d: D2DrlgLevelLinkDataStrc, nIteration: number): boolean { + const nLevelLink = gAct4OutdoorDrlgLink[nIteration]!.nLevelLink + for (let i = 0; i < nIteration; i += 1) { + if (i !== nLevelLink && !DRLG_CheckNotOverlappingUsingManhattanDistance(d.pLevelCoord[nIteration]!, d.pLevelCoord[i]!, 0)) { + return false + } + } + return true +} + +//D2Common.0x6FD82300 +function DRLGOUTPLACE_LinkAct4ChaosSanctum(d: D2DrlgLevelLinkDataStrc, nIteration: number): boolean { + const nLevelLink = gAct4ChaosSanctumDrlgLink[nIteration]!.nLevelLink + for (let i = 0; i < nIteration; i += 1) { + if (i !== nLevelLink && !DRLG_CheckNotOverlappingUsingManhattanDistance(d.pLevelCoord[nIteration]!, d.pLevelCoord[i]!, 0)) { + return false + } + } + return true +} + //D2Common.0x6FD82360 const stru_6FDD06C0: readonly (readonly [number, number, number, number, number, number])[] = [ // nLevelId, nExcludedLevel1, nExcludedLevel2, nRand, nNextRand, nFlags @@ -454,6 +903,23 @@ function sub_6FD823C0( } } +//D2Common.0x6FD826D0 +function sub_6FD826D0(pDrlg: D2DrlgStrc, nStartId: number, nEndId: number): void { + for (let i = nStartId; i <= nEndId; i += 1) { + const pLevel = DRLG_GetLevel(pDrlg, i) + const pDrlgWarp = DRLG_GetDrlgWarpFromLevelId(pDrlg, i) + + for (let j = nStartId; j <= nEndId; j += 1) { + if ( + i !== j && + DRLG_CheckOverlappingWithOrthogonalMargin(pLevel.pLevelCoords, DRLG_GetLevel(pDrlg, j).pLevelCoords, -1) + ) { + DRLG_SetWarpId(pDrlgWarp, j, -1, -1) + } + } + } +} + //D2Common.0x6FD82750 function sub_6FD82750(pDrlg: D2DrlgStrc, nStartId: number, nEndId: number): void { for (let i = nStartId; i <= nEndId; i += 1) { @@ -475,3 +941,632 @@ function sub_6FD82750(pDrlg: D2DrlgStrc, nStartId: number, nEndId: number): void } } } + +// --------------------------------------------------------------------------------------------- +// Act III Jungle layout (DrlgOutPlace.cpp:1926-2616) +// --------------------------------------------------------------------------------------------- + +const JUNGLE_MAX_ATTACH = 3 +const JUNGLE_PRESET2_ATTACH_POINT = 2 + +const JUNGLE_FLAG_LEFT_ = 0b0001 +const JUNGLE_FLAG_RIGHT_ = 0b0010 +const JUNGLE_FLAG_BOTTOM_ = 0b0100 +const JUNGLE_FLAG_TOP_ = 0b1000 + +interface D2JungleStrc { + pDrlgCoord: D2DrlgCoordStrc + field_10: number + nBranch: number + pBasedOnJungle: D2JungleStrc | null + pJungleBranches: (D2JungleStrc | null)[] + nPresetsBlocksX: number + nPresetsBlocksY: number + pJungleDefs: Int32Array | null + nJungleDefs: number +} + +function newJungleStrc(): D2JungleStrc { + return { + pDrlgCoord: newCoord(), + field_10: 0, + nBranch: 0, + pBasedOnJungle: null, + pJungleBranches: [null, null, null], + nPresetsBlocksX: 0, + nPresetsBlocksY: 0, + pJungleDefs: null, + nJungleDefs: 0, + } +} + +//D2Common.0x6FDD0828 +const gJunglePresets: readonly number[] = [ + LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_W_E, LVLPREST_ACT3_JUNGLE_W_S, LVLPREST_ACT3_JUNGLE_W_N, + LVLPREST_ACT3_JUNGLE_E_W, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_E_S, LVLPREST_ACT3_JUNGLE_E_N, + LVLPREST_NONE, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_EW_S, LVLPREST_ACT3_JUNGLE_EW_N, + LVLPREST_ACT3_JUNGLE_S_W, LVLPREST_ACT3_JUNGLE_S_E, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_S_N, + LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_SW_E, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_SW_N, + LVLPREST_ACT3_JUNGLE_SE_W, LVLPREST_NONE, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_SE_N, + LVLPREST_NONE, LVLPREST_NONE, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_SEW_N, + LVLPREST_ACT3_JUNGLE_N_W, LVLPREST_ACT3_JUNGLE_N_E, LVLPREST_ACT3_JUNGLE_N_S, LVLPREST_NONE, + LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_NW_E, LVLPREST_ACT3_JUNGLE_NW_S, LVLPREST_NONE, + LVLPREST_ACT3_JUNGLE_NE_W, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_NE_S, LVLPREST_NONE, + LVLPREST_NONE, LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_NEW_S, LVLPREST_NONE, + LVLPREST_ACT3_JUNGLE_NS_W, LVLPREST_ACT3_JUNGLE_NS_E, LVLPREST_NONE, LVLPREST_NONE, + LVLPREST_NONE, LVLPREST_ACT3_JUNGLE_NSW_E, LVLPREST_NONE, LVLPREST_NONE, + LVLPREST_ACT3_JUNGLE_NSE_W, LVLPREST_NONE, LVLPREST_NONE, LVLPREST_NONE, + LVLPREST_NONE, LVLPREST_NONE, LVLPREST_NONE, LVLPREST_NONE, +] + +//D2Common.0x6FDD0924 +const gSpiderForestPresets: readonly number[] = [ + LVLPREST_NONE, LVLPREST_ACT3_CLEARING_WEBBY_W, LVLPREST_ACT3_CLEARING_WEBBY_E, LVLPREST_ACT3_CLEARING_WEBBY_EW, + LVLPREST_ACT3_CLEARING_WEBBY_S, LVLPREST_ACT3_CLEARING_WEBBY_SW, LVLPREST_ACT3_CLEARING_WEBBY_SE, LVLPREST_NONE, + LVLPREST_ACT3_CLEARING_WEBBY_N, LVLPREST_ACT3_CLEARING_WEBBY_NW, LVLPREST_ACT3_CLEARING_WEBBY_NE, LVLPREST_NONE, + LVLPREST_ACT3_CLEARING_WEBBY_NS, LVLPREST_NONE, LVLPREST_NONE, LVLPREST_NONE, + LVLPREST_NONE, +] + +const nPresetBlocksDimensions = 32 + +function DRLG_GenerateJunglesAttachPoints( + pDrlg: D2DrlgStrc, + tJungles: D2JungleStrc[], + nMinX: number, + nMinY: number, + nSpiderForestLevelSizeX: number, + nSpiderForestLevelSizeY: number, + nPresetsWidth: number, + nPresetsHeight: number, + pPreset0: Int32Array, + pPreset1: Int32Array, + pPreset2: Int32Array, + pLevelPresetId: Int32Array, +): void { + pPreset0.fill(0, 0, nPresetsWidth * nPresetsHeight) + pPreset1.fill(0, 0, nPresetsWidth * nPresetsHeight) + pPreset2.fill(0, 0, nPresetsWidth * nPresetsHeight) + pLevelPresetId.fill(0, 0, nPresetsWidth * nPresetsHeight) + + const nSpiderForestLevelPresetsBlocksSizeX = idiv(nSpiderForestLevelSizeX, nPresetBlocksDimensions) + const nSpiderForestLevelPresetsBlocksSizeY = idiv(nSpiderForestLevelSizeY, nPresetBlocksDimensions) + + for (let nJungleIdx = 0; nJungleIdx < JUNGLE_MAX_ATTACH; nJungleIdx += 1) { + const tCurrentJungle = tJungles[nJungleIdx]! + const nJungleOffsetX = tCurrentJungle.pDrlgCoord.nPosX - nMinX + const nJungleOffsetY = tCurrentJungle.pDrlgCoord.nPosY - nMinY + + tCurrentJungle.nPresetsBlocksX = (((nJungleOffsetX & 0x1f) + nJungleOffsetX) >> 5) + 1 + tCurrentJungle.nPresetsBlocksY = (((nJungleOffsetY & 0x1f) + nJungleOffsetY) >> 5) + 1 + + tCurrentJungle.pJungleDefs = new Int32Array( + nSpiderForestLevelPresetsBlocksSizeX * nSpiderForestLevelPresetsBlocksSizeY, + ) + + let bLineStartWithOffset: boolean + if (nJungleIdx !== 0) { + bLineStartWithOffset = tCurrentJungle.field_10 % 2 !== 0 + } else { + bLineStartWithOffset = SEED_RollLimitedRandomNumber(pDrlg.pSeed, 2) !== 0 + } + + let nJungleAttachPoints = 0 + while (nJungleAttachPoints < 2) { + let nPresetBlockRowOffset = tCurrentJungle.nPresetsBlocksX + nPresetsWidth * tCurrentJungle.nPresetsBlocksY + for (let nPresetBlockY = 0; nPresetBlockY < nSpiderForestLevelPresetsBlocksSizeY; nPresetBlockY += 1) { + for (let nPresetBlockX = 0; nPresetBlockX < nSpiderForestLevelPresetsBlocksSizeX; nPresetBlockX += 1) { + const nPresetBlockIndex = nPresetBlockRowOffset + nPresetBlockX + pPreset0[nPresetBlockIndex] = nJungleIdx + 1 + pPreset1[nPresetBlockIndex] = 0 + pPreset2[nPresetBlockIndex] = 0 + pLevelPresetId[nPresetBlockIndex] = 0 + } + nPresetBlockRowOffset += nPresetsWidth + } + + const nFirstPresetsBlockY = tCurrentJungle.nPresetsBlocksY + const nLastPresetsBlockY = nFirstPresetsBlockY + nSpiderForestLevelPresetsBlocksSizeY - 1 + + if (nJungleIdx !== 0) { + const nFirstPresetsBlockIndex = nLastPresetsBlockY * nPresetsWidth + tCurrentJungle.nPresetsBlocksX + pPreset2[nFirstPresetsBlockIndex + (bLineStartWithOffset ? 1 : 0)] = 1 + pPreset2[nFirstPresetsBlockIndex + (!bLineStartWithOffset ? 1 : 0)] = JUNGLE_PRESET2_ATTACH_POINT + } + + for (let nBranchIdx = 0; nBranchIdx < tCurrentJungle.nBranch; nBranchIdx += 1) { + const pJungleBranch = tCurrentJungle.pJungleBranches[nBranchIdx]! + let nPresetOffsetX = 0 + let nPresetOffsetY = 0 + switch (pJungleBranch.field_10) { + case 0: + break + case 1: + nPresetOffsetY = 3 + break + case 2: + nPresetOffsetX = 1 + nPresetOffsetY = 3 + break + case 3: + nPresetOffsetY = 1 + break + case 4: + nPresetOffsetX = 1 + nPresetOffsetY = 1 + break + default: + throw new Error(`DRLG_GenerateJunglesAttachPoints: unreachable field_10=${pJungleBranch.field_10}`) + } + pPreset2[ + tCurrentJungle.nPresetsBlocksX + + nPresetOffsetX + + nPresetsWidth * (tCurrentJungle.nPresetsBlocksY + nPresetOffsetY) + ] = 1 + } + + nJungleAttachPoints = nJungleIdx !== 0 ? 1 : 0 + + const nCurrentPresetsBlockX = tCurrentJungle.nPresetsBlocksX + let i = 20 * (5 * nJungleIdx + 5) + let nColumnSize = 0 + for (let nCurrentPresetsBlockY = nLastPresetsBlockY; nCurrentPresetsBlockY >= nFirstPresetsBlockY; ) { + const nCurrentPresetsBlockRowOffset = nCurrentPresetsBlockY * nPresetsWidth + + pPreset1[nCurrentPresetsBlockRowOffset + nCurrentPresetsBlockX + (bLineStartWithOffset ? 1 : 0)] = i + i += 1 + + if ( + nColumnSize === 0 || + nCurrentPresetsBlockY === nFirstPresetsBlockY || + SEED_RollLimitedRandomNumber(pDrlg.pSeed, 3) !== 0 + ) { + nColumnSize += 1 + + if (nColumnSize >= 2 && nCurrentPresetsBlockY > 1) { + const nNextPresetIdx = + nCurrentPresetsBlockRowOffset + nCurrentPresetsBlockX + (!bLineStartWithOffset ? 1 : 0) + + if (pPreset2[nNextPresetIdx] === 0) { + pPreset2[nNextPresetIdx] = JUNGLE_PRESET2_ATTACH_POINT + nJungleAttachPoints += 1 + } + } + nCurrentPresetsBlockY -= 1 + } else { + nColumnSize = 0 + bLineStartWithOffset = !bLineStartWithOffset + } + } + } + + while (nJungleAttachPoints > 3) { + let bFinished = false + let nAttachPointsFound = 0 + const nAttachPointToRemove = SEED_RollLimitedRandomNumber(pDrlg.pSeed, nJungleAttachPoints) >>> 0 + + for ( + let nCurrentPresetsBlockY = 0; + nCurrentPresetsBlockY < nSpiderForestLevelPresetsBlocksSizeY && !bFinished; + nCurrentPresetsBlockY += 1 + ) { + const nCurrentPresetsBlockOffset = + tCurrentJungle.nPresetsBlocksX + + (tCurrentJungle.nPresetsBlocksY + nCurrentPresetsBlockY) * nPresetsWidth + + for ( + let nCurrentPresetsBlockX = 0; + nCurrentPresetsBlockX < nSpiderForestLevelPresetsBlocksSizeX && !bFinished; + nCurrentPresetsBlockX += 1 + ) { + const nCurrentPresetBlockIdx = nCurrentPresetsBlockOffset + nCurrentPresetsBlockX + if (pPreset2[nCurrentPresetBlockIdx] === JUNGLE_PRESET2_ATTACH_POINT) { + if (nAttachPointsFound === nAttachPointToRemove) { + pPreset2[nCurrentPresetBlockIdx] = 0 + bFinished = true + nJungleAttachPoints -= 1 + } + nAttachPointsFound += 1 + } + } + } + } + } +} + +function DRLG_JungleComputeConnexity( + _pDrlg: D2DrlgStrc, + nPresetsWidth: number, + nPresetsHeight: number, + pPreset0: Int32Array, + pPreset1: Int32Array, + pPreset2: Int32Array, + pLevelPresetId: Int32Array, +): void { + const nMaxValue = 0x7fffffff + + for (let nCurrentPresetY = 0; nCurrentPresetY < nPresetsHeight; nCurrentPresetY += 1) { + for (let nCurrentPresetX = 0; nCurrentPresetX < nPresetsWidth; nCurrentPresetX += 1) { + let nFlags = 0 + let nLastDirPreset1Value = nMaxValue + + const nCurrentPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX + const nRightPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX + 1 + const nLeftPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX - 1 + const nTopPresetIndex = (nCurrentPresetY - 1) * nPresetsWidth + nCurrentPresetX + const nBottomPresetIndex = (nCurrentPresetY + 1) * nPresetsWidth + nCurrentPresetX + + if (pPreset2[nCurrentPresetIndex] === 1) { + const nPreset0CurrentValue = pPreset0[nCurrentPresetIndex]! + + const UpdateAccordingToPreset1 = (nPresetIndex: number, nDirectionFlag: number): void => { + const nPreset1Value = pPreset1[nPresetIndex]! + if ( + nPreset1Value !== 0 && + nPreset1Value < nLastDirPreset1Value && + pPreset0[nPresetIndex] === nPreset0CurrentValue + ) { + nFlags = nDirectionFlag + nLastDirPreset1Value = nPreset1Value + } + } + + UpdateAccordingToPreset1(nTopPresetIndex, JUNGLE_FLAG_TOP_) + UpdateAccordingToPreset1(nBottomPresetIndex, JUNGLE_FLAG_BOTTOM_) + UpdateAccordingToPreset1(nRightPresetIndex, JUNGLE_FLAG_RIGHT_) + UpdateAccordingToPreset1(nLeftPresetIndex, JUNGLE_FLAG_LEFT_) + + if (nFlags & JUNGLE_FLAG_TOP_) pLevelPresetId[nTopPresetIndex]! |= JUNGLE_FLAG_BOTTOM_ + if (nFlags & JUNGLE_FLAG_BOTTOM_) pLevelPresetId[nBottomPresetIndex]! |= JUNGLE_FLAG_TOP_ + if (nFlags & JUNGLE_FLAG_RIGHT_) pLevelPresetId[nRightPresetIndex]! |= JUNGLE_FLAG_LEFT_ + if (nFlags & JUNGLE_FLAG_LEFT_) pLevelPresetId[nLeftPresetIndex]! |= JUNGLE_FLAG_RIGHT_ + + if (pPreset2[nTopPresetIndex] === 1 && pPreset0[nTopPresetIndex] !== nPreset0CurrentValue) { + nFlags |= JUNGLE_FLAG_TOP_ + } + if (pPreset2[nBottomPresetIndex] === 1 && pPreset0[nBottomPresetIndex] !== nPreset0CurrentValue) { + nFlags |= JUNGLE_FLAG_BOTTOM_ + } + if (pPreset2[nRightPresetIndex] === 1 && pPreset0[nRightPresetIndex] !== nPreset0CurrentValue) { + nFlags |= JUNGLE_FLAG_RIGHT_ + } + if (pPreset2[nLeftPresetIndex] === 1 && pPreset0[nLeftPresetIndex] !== nPreset0CurrentValue) { + nFlags |= JUNGLE_FLAG_LEFT_ + } + } + + const nPreset1CurrentValue = pPreset1[nCurrentPresetIndex]! + if (nPreset1CurrentValue) { + if (Math.abs(pPreset1[nTopPresetIndex]! - nPreset1CurrentValue) === 1) { + nFlags |= JUNGLE_FLAG_TOP_ + } + if (Math.abs(pPreset1[nBottomPresetIndex]! - nPreset1CurrentValue) === 1) { + nFlags |= JUNGLE_FLAG_BOTTOM_ + } + if (Math.abs(pPreset1[nRightPresetIndex]! - nPreset1CurrentValue) === 1) { + nFlags |= JUNGLE_FLAG_RIGHT_ + } + if (Math.abs(pPreset1[nLeftPresetIndex]! - nPreset1CurrentValue) === 1) { + nFlags |= JUNGLE_FLAG_LEFT_ + } + } + + pLevelPresetId[nCurrentPresetIndex]! |= nFlags + } + } +} + +function DRLG_JungleUpdateAttachPointsDirections( + pDrlg: D2DrlgStrc, + nPresetsWidth: number, + nPresetsHeight: number, + pPreset0: Int32Array, + pPreset2: Int32Array, + pLevelPresetId: Int32Array, +): boolean { + for (let nCurrentPresetY = 0; nCurrentPresetY < nPresetsHeight; nCurrentPresetY += 1) { + for (let nCurrentPresetX = 0; nCurrentPresetX < nPresetsWidth; nCurrentPresetX += 1) { + const nCurrentPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX + + let nDirectionBase = SEED_RollLimitedRandomNumber(pDrlg.pSeed, DIRECTION_COUNT) | 0 + + if (pPreset2[nCurrentPresetIndex] === JUNGLE_PRESET2_ATTACH_POINT) { + const nRightPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX + 1 + const nLeftPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX - 1 + const nTopPresetIndex = (nCurrentPresetY - 1) * nPresetsWidth + nCurrentPresetX + const nBottomPresetIndex = (nCurrentPresetY + 1) * nPresetsWidth + nCurrentPresetX + + const nPreset0CurrentValue = pPreset0[nCurrentPresetIndex]! + + let nCurrentPresetLevelId = pLevelPresetId[nCurrentPresetIndex]! + const bHadNoPresetLeveld = nCurrentPresetLevelId === 0 + + let bLookAtNextDirection = true + const LookForDirection = (CheckDirectionFunction: (nPresetIndex: number, nFlagToAdd: number) => void): void => { + for (let i = 0; i < DIRECTION_COUNT && bLookAtNextDirection; i += 1) { + switch ((nDirectionBase + i) % DIRECTION_COUNT) { + case DIRECTION_SOUTHWEST: + CheckDirectionFunction(nTopPresetIndex, JUNGLE_FLAG_TOP_) + break + case DIRECTION_NORTHWEST: + CheckDirectionFunction(nBottomPresetIndex, JUNGLE_FLAG_BOTTOM_) + break + case DIRECTION_SOUTHEAST: + CheckDirectionFunction(nRightPresetIndex, JUNGLE_FLAG_RIGHT_) + break + case DIRECTION_NORTHEAST: + CheckDirectionFunction(nLeftPresetIndex, JUNGLE_FLAG_LEFT_) + break + default: + break + } + } + } + + bLookAtNextDirection = true + LookForDirection((nPresetIndex, nFlagToAdd) => { + if ( + pPreset0[nPresetIndex] === nPreset0CurrentValue && + pLevelPresetId[nPresetIndex]! !== 0 && + pLevelPresetId[nPresetIndex]! < 15 + ) { + nCurrentPresetLevelId |= nFlagToAdd << 4 + bLookAtNextDirection = false + } + }) + + if (!nCurrentPresetLevelId) { + return false + } + + if (bHadNoPresetLeveld) { + bLookAtNextDirection = true + LookForDirection((nPresetIndex, nFlagToAdd) => { + if ( + pPreset0[nPresetIndex] !== nPreset0CurrentValue && + pPreset2[nPresetIndex] === JUNGLE_PRESET2_ATTACH_POINT + ) { + nCurrentPresetLevelId |= nFlagToAdd << 4 + bLookAtNextDirection = false + } + }) + + bLookAtNextDirection = SEED_RollLimitedRandomNumber(pDrlg.pSeed, 2) !== 0 && bLookAtNextDirection + + nDirectionBase = SEED_RollLimitedRandomNumber(pDrlg.pSeed, DIRECTION_COUNT) | 0 + + LookForDirection((nPresetIndex, nFlagToAdd) => { + if ( + pLevelPresetId[nPresetIndex]! !== 0 && + pLevelPresetId[nPresetIndex]! < 15 && + (nCurrentPresetLevelId & (nFlagToAdd << 4)) === 0 + ) { + nCurrentPresetLevelId |= nFlagToAdd << 4 + bLookAtNextDirection = false + } + }) + } + + if ((nCurrentPresetLevelId & (JUNGLE_FLAG_TOP_ << 4)) !== 0) { + pLevelPresetId[nTopPresetIndex]! |= JUNGLE_FLAG_BOTTOM_ << 4 + } + if ((nCurrentPresetLevelId & (JUNGLE_FLAG_BOTTOM_ << 4)) !== 0) { + pLevelPresetId[nBottomPresetIndex]! |= JUNGLE_FLAG_TOP_ << 4 + } + if ((nCurrentPresetLevelId & (JUNGLE_FLAG_RIGHT_ << 4)) !== 0) { + pLevelPresetId[nRightPresetIndex]! |= JUNGLE_FLAG_LEFT_ << 4 + } + if ((nCurrentPresetLevelId & (JUNGLE_FLAG_LEFT_ << 4)) !== 0) { + pLevelPresetId[nLeftPresetIndex]! |= JUNGLE_FLAG_RIGHT_ << 4 + } + + pPreset2[nCurrentPresetIndex] = 0 + pLevelPresetId[nCurrentPresetIndex]! |= nCurrentPresetLevelId + } + } + } + + return true +} + +function DRLG_JungleNormalizeLevelPresetId(nLevelPresetId: number): number { + const nbDirections = 16 + let nPDef = idiv(nLevelPresetId, 1) % nbDirections + if (nPDef !== 0) { + if (nLevelPresetId < nbDirections) { + nPDef += LVLPREST_ACT3_TOWN + } else { + const nPresetDefinitionOffset = 4 * (nPDef - 1) + if ((nLevelPresetId & (JUNGLE_FLAG_LEFT_ << 4)) !== 0) nPDef = gJunglePresets[nPresetDefinitionOffset]! + if ((nLevelPresetId & (JUNGLE_FLAG_RIGHT_ << 4)) !== 0) nPDef = gJunglePresets[nPresetDefinitionOffset + 1]! + if ((nLevelPresetId & (JUNGLE_FLAG_BOTTOM_ << 4)) !== 0) nPDef = gJunglePresets[nPresetDefinitionOffset + 2]! + if ((nLevelPresetId & (JUNGLE_FLAG_TOP_ << 4)) !== 0) nPDef = gJunglePresets[nPresetDefinitionOffset + 3]! + } + } else if (nLevelPresetId >= nbDirections) { + nPDef = gSpiderForestPresets[nLevelPresetId >> 4]! + } + return nPDef +} + +//D2Common.0x6FD82820 +export function DRLG_GenerateJungles(pLevel: D2DrlgLevelStrc): D2DrlgLevelStrc { + const pDrlg = pLevel.pDrlg + const pSpiderForestLevelDef = DATATBLS_GetLevelDefRecord(pDrlg.env.tables, LEVEL_SPIDERFOREST) + const nDifficulty = pDrlg.nDifficulty + const nSpiderForestLevelSizeX = pSpiderForestLevelDef.dwSizeX[nDifficulty]! + const nSpiderForestLevelSizeY = pSpiderForestLevelDef.dwSizeY[nDifficulty]! + let nMinX = pLevel.pLevelCoords.nPosX + let nMaxX = nSpiderForestLevelSizeX + nMinX + const nMaxY = pLevel.pLevelCoords.nPosY + let nMinY = nMaxY - nSpiderForestLevelSizeY + + const tJungles: D2JungleStrc[] = [newJungleStrc(), newJungleStrc(), newJungleStrc()] + sub_6FD83970(pLevel.pLevelCoords, tJungles[0]!, 0, nSpiderForestLevelSizeX, nSpiderForestLevelSizeY) + + for (let nJungleAttachIdx = 1; nJungleAttachIdx < JUNGLE_MAX_ATTACH; nJungleAttachIdx += 1) { + const nBaseOn = SEED_RollLimitedRandomNumber(pDrlg.pSeed, nJungleAttachIdx) >>> 0 + + const pCurrentJungle = tJungles[nJungleAttachIdx]! + sub_6FD83970( + tJungles[nBaseOn]!.pDrlgCoord, + pCurrentJungle, + SEED_RollLimitedRandomNumber(pDrlg.pSeed, 5) | 0, + nSpiderForestLevelSizeX, + nSpiderForestLevelSizeY, + ) + + let nFirstOverlappingJungle = 0 + for (nFirstOverlappingJungle = 0; nFirstOverlappingJungle < nJungleAttachIdx; nFirstOverlappingJungle += 1) { + const levelsOverlaps = !DRLG_CheckNotOverlappingUsingManhattanDistance( + tJungles[nFirstOverlappingJungle]!.pDrlgCoord, + pCurrentJungle.pDrlgCoord, + 0, + ) + if (levelsOverlaps) { + break + } + } + + if (nFirstOverlappingJungle < nJungleAttachIdx) { + nJungleAttachIdx -= 1 + continue + } + + pCurrentJungle.pBasedOnJungle = tJungles[nBaseOn]! + tJungles[nBaseOn]!.pJungleBranches[tJungles[nBaseOn]!.nBranch] = pCurrentJungle + tJungles[nBaseOn]!.nBranch += 1 + + if (nMinX > pCurrentJungle.pDrlgCoord.nPosX) nMinX = pCurrentJungle.pDrlgCoord.nPosX + if (nMinY > pCurrentJungle.pDrlgCoord.nPosY) nMinY = pCurrentJungle.pDrlgCoord.nPosY + if (nMaxX < pCurrentJungle.pDrlgCoord.nWidth + pCurrentJungle.pDrlgCoord.nPosX) { + nMaxX = pCurrentJungle.pDrlgCoord.nWidth + pCurrentJungle.pDrlgCoord.nPosX + } + } + + const nPresetsWidth = idiv(nMaxX - nMinX, nPresetBlocksDimensions) + 2 + const nPresetsHeight = idiv(nMaxY - nMinY, nPresetBlocksDimensions) + 2 + const nPresets = nPresetsWidth * nPresetsHeight + + const pPreset0 = new Int32Array(nPresets) + const pPreset1 = new Int32Array(nPresets) + const pPreset2 = new Int32Array(nPresets) + const pLevelPresetId = new Int32Array(nPresets) + + do { + DRLG_GenerateJunglesAttachPoints( + pDrlg, + tJungles, + nMinX, + nMinY, + nSpiderForestLevelSizeX, + nSpiderForestLevelSizeY, + nPresetsWidth, + nPresetsHeight, + pPreset0, + pPreset1, + pPreset2, + pLevelPresetId, + ) + DRLG_JungleComputeConnexity(pDrlg, nPresetsWidth, nPresetsHeight, pPreset0, pPreset1, pPreset2, pLevelPresetId) + } while (!DRLG_JungleUpdateAttachPointsDirections(pDrlg, nPresetsWidth, nPresetsHeight, pPreset0, pPreset2, pLevelPresetId)) + + for (let nCurrentPresetY = 0; nCurrentPresetY < nPresetsHeight; nCurrentPresetY += 1) { + for (let nCurrentPresetX = 0; nCurrentPresetX < nPresetsWidth; nCurrentPresetX += 1) { + const nCurrentPresetIndex = nCurrentPresetY * nPresetsWidth + nCurrentPresetX + pLevelPresetId[nCurrentPresetIndex] = DRLG_JungleNormalizeLevelPresetId(pLevelPresetId[nCurrentPresetIndex]!) + } + } + + const nSpiderForestLevelPresetsBlocksSizeX = idiv(nSpiderForestLevelSizeX, nPresetBlocksDimensions) + const nSpiderForestLevelPresetsBlocksSizeY = idiv(nSpiderForestLevelSizeY, nPresetBlocksDimensions) + + for (let nJungleIdx = 0; nJungleIdx < JUNGLE_MAX_ATTACH; nJungleIdx += 1) { + const tCurrentJungle = tJungles[nJungleIdx]! + let nJungleDefsIndex = 0 + for (let nBlockY = 0; nBlockY < nSpiderForestLevelPresetsBlocksSizeY; nBlockY += 1) { + for (let nBlockX = 0; nBlockX < nSpiderForestLevelPresetsBlocksSizeX; nBlockX += 1) { + const nCurrentPresetIndex = + nBlockX + tCurrentJungle.nPresetsBlocksX + nPresetsWidth * (nBlockY + tCurrentJungle.nPresetsBlocksY) + const nPrestId = pLevelPresetId[nCurrentPresetIndex]! + tCurrentJungle.pJungleDefs![nJungleDefsIndex] = nPrestId + if (nPrestId > LVLPREST_ACT3_JUNGLE_TAIL) { + tCurrentJungle.nJungleDefs += 1 + } + nJungleDefsIndex += 1 + } + } + } + + tJungles.sort((lhs, rhs) => rhs.pDrlgCoord.nPosY - lhs.pDrlgCoord.nPosY) + + let pJungleLevel: D2DrlgLevelStrc | null = null + for (let i = 0; i < JUNGLE_MAX_ATTACH; i += 1) { + pJungleLevel = DRLG_GetLevel(pDrlg, i + LEVEL_SPIDERFOREST) + pJungleLevel.pJungleDefs = tJungles[i]!.pJungleDefs + pJungleLevel.nJungleDefs = tJungles[i]!.nJungleDefs + pJungleLevel.pLevelCoords.nPosX = tJungles[i]!.pDrlgCoord.nPosX + pJungleLevel.pLevelCoords.nPosY = tJungles[i]!.pDrlgCoord.nPosY + pJungleLevel.pLevelCoords.nWidth = tJungles[i]!.pDrlgCoord.nWidth + pJungleLevel.pLevelCoords.nHeight = tJungles[i]!.pDrlgCoord.nHeight + } + + return pJungleLevel! +} + +//D2Common.0x6FD83970 +function sub_6FD83970( + pDrlgCoord: D2DrlgCoordStrc, + pJungle: D2JungleStrc, + nRand: number, + nSizeX: number, + nSizeY: number, +): void { + let nX = 0 + let nY = 0 + + switch (nRand) { + case 0: + nX = 0 + nY = -nSizeY + break + + case 1: { + nX = -nSizeX + const mul = Number(BigInt.asUintN(64, 0x55555555n * BigInt.asUintN(64, BigInt(nSizeY))) >> 32n) | 0 + const nTemp = (mul - nSizeY) >> 1 + nY = ((nTemp >>> 31) + nTemp) | 0 + break + } + + case 2: { + nX = nSizeX + const mul = Number(BigInt.asUintN(64, 0x55555555n * BigInt.asUintN(64, BigInt(nSizeY))) >> 32n) | 0 + const nTemp = (mul - nSizeY) >> 1 + nY = ((nTemp >>> 31) + nTemp) | 0 + break + } + + case 3: { + nX = -nSizeX + const nTemp = Number(BigInt.asUintN(64, 0xffffffff55555554n * BigInt.asUintN(64, BigInt(nSizeY))) >> 32n) | 0 + nY = ((nTemp >>> 31) + nTemp) | 0 + break + } + + case 4: { + nX = nSizeX + const nTemp = Number(BigInt.asUintN(64, 0xffffffff55555554n * BigInt.asUintN(64, BigInt(nSizeY))) >> 32n) | 0 + nY = ((nTemp >>> 31) + nTemp) | 0 + break + } + + default: + break + } + + pJungle.pDrlgCoord.nPosX = nX + pDrlgCoord.nPosX + pJungle.pDrlgCoord.nHeight = nSizeY + pJungle.pDrlgCoord.nWidth = nSizeX + pJungle.pDrlgCoord.nPosY = nY + pDrlgCoord.nPosY + pJungle.field_10 = nRand +} diff --git a/src/game/drlg/drlg-anim.ts b/src/game/drlg/drlg-anim.ts new file mode 100644 index 0000000..066ffd2 --- /dev/null +++ b/src/game/drlg/drlg-anim.ts @@ -0,0 +1,211 @@ +/** + * D2Common DRLG animated tiles: 1:1 port of D2MOO source/D2Common/src/Drlg/DrlgDrlgAnim.cpp + * (commit 5596f5c, MIT License, Copyright (c) 2020-2025 The Phrozen Keep community). + */ + +import { + D2CMP_10079_GetTileFlags, + D2CMP_10081_GetTileRarity, + D2CMP_10088_GetTiles, + type D2TileLibraryEntryStrc, +} from './drlg-d2cmp.ts' +import { DRLGGRID_GetGridEntry } from './drlg-grid.ts' +import { + DRLGROOMTILE_InitFloorTileData, + DRLGROOMTILE_InitShadowTileData, + DRLGROOMTILE_InitWallTileData, + GetMapTileLayer, + MAPTILE_HIDDEN, + PackedTile, + TILETYPE_FLOOR, + TILETYPE_SHADOW, + tileGridOf, +} from './drlg-roomtile.ts' +import { + DRLGROOMFLAG_ANIMATED_FLOOR, + type D2DrlgAnimTileGridStrc, + type D2DrlgGridStrc, + type D2DrlgRoomStrc, + type D2DrlgTileDataStrc, +} from './drlg-types.ts' + +// D2TileMaterialFlags (D2CMP.h) +const TILE_FLAGS_LAVA = 0x100 + +//D2Common.0x6FD75560 +export function DRLGANIM_TestLoadAnimatedRoomTiles( + pDrlgRoom: D2DrlgRoomStrc, + pDrlgGrid: D2DrlgGridStrc, + pTileTypeGrid: D2DrlgGridStrc | null, + nTileType: number, + bKillEdgeX: boolean, + bKillEdgeY: boolean, +): void { + const ppTileLibraryEntry: D2TileLibraryEntryStrc[] = [] + const pTiles = tileGridOf(pDrlgRoom).pTiles + const nMaxY = (pDrlgRoom.pDrlgCoord.nHeight + (bKillEdgeY ? 0 : 1)) | 0 + const nMaxX = (pDrlgRoom.pDrlgCoord.nWidth + (bKillEdgeX ? 0 : 1)) | 0 + + for (let nY = 0; nY < nMaxY; nY += 1) { + for (let nX = 0; nX < nMaxX; nX += 1) { + const nGridEntry = DRLGGRID_GetGridEntry(pDrlgGrid, nX, nY) >>> 0 + if (PackedTile.bShadow(nGridEntry) || PackedTile.bIsWall(nGridEntry) || PackedTile.bIsFloor(nGridEntry)) { + if (pTileTypeGrid) { + nTileType = DRLGGRID_GetGridEntry(pTileTypeGrid, nX, nY) + } + + const nStyle = PackedTile.nTileStyle(nGridEntry) + const nSequence = PackedTile.nTileSequence(nGridEntry) + + const nTilesCount = D2CMP_10088_GetTiles(pDrlgRoom.pTiles, nTileType, nStyle, nSequence, ppTileLibraryEntry, 40) + if (nTilesCount && D2CMP_10079_GetTileFlags(ppTileLibraryEntry[0]!) & TILE_FLAGS_LAVA) { + if (PackedTile.bIsFloor(nGridEntry)) { + pTiles.nFloors = (pTiles.nFloors + nTilesCount - 1) | 0 + } else if (PackedTile.bIsWall(nGridEntry)) { + pTiles.nWalls = (pTiles.nWalls + nTilesCount - 1) | 0 + } else { + pTiles.nRoofs = (pTiles.nRoofs + nTilesCount - 1) | 0 + } + + pDrlgRoom.dwFlags = (pDrlgRoom.dwFlags | DRLGROOMFLAG_ANIMATED_FLOOR) >>> 0 + } + } + } + } +} + +// Helper function +function DRLGANIM_FindAnimatedTileFrame( + pTileLibraryEntries: D2TileLibraryEntryStrc[], + nbTileEntries: number, + nRarity: number, + nStyle: number, + nSeq: number, +): D2TileLibraryEntryStrc { + for (let nEntryIndex = 0; nEntryIndex < nbTileEntries; nEntryIndex += 1) { + if (D2CMP_10081_GetTileRarity(pTileLibraryEntries[nEntryIndex]!) === nRarity) { + return pTileLibraryEntries[nEntryIndex]! + } + } + throw new Error(`Animating tiles missing Rarity ${nRarity} Tile - Style ${nStyle} Seq ${nSeq}`) +} + +//D2Common.0x6FD75740 +export function DRLGANIM_AllocAnimationTileGrids( + pDrlgRoom: D2DrlgRoomStrc, + nAnimationSpeed: number, + pWallGrid: D2DrlgGridStrc[], + nWalls: number, + pFloorGrid: D2DrlgGridStrc[], + nFloors: number, + pShadowGrid: D2DrlgGridStrc, +): void { + const pTileGrid = tileGridOf(pDrlgRoom) + DRLGANIM_AllocAnimationTileGrid( + pDrlgRoom, + nAnimationSpeed, + pTileGrid.pTiles.pWallTiles, + pTileGrid.nWalls, + pWallGrid, + nWalls, + ) + DRLGANIM_AllocAnimationTileGrid( + pDrlgRoom, + nAnimationSpeed, + pTileGrid.pTiles.pFloorTiles, + pTileGrid.nFloors, + pFloorGrid, + nFloors, + ) + DRLGANIM_AllocAnimationTileGrid( + pDrlgRoom, + nAnimationSpeed, + pTileGrid.pTiles.pRoofTiles, + pTileGrid.nShadows, + [pShadowGrid], + 1, + ) +} + +//D2Common.0x6FD757B0 +export function DRLGANIM_AllocAnimationTileGrid( + pDrlgRoom: D2DrlgRoomStrc, + nAnimationSpeed: number, + pTiles: D2DrlgTileDataStrc[] | null, + nTiles: number, + pDrlgGrid: D2DrlgGridStrc[], + _nUnused: number, +): void { + if (!pTiles) { + return + } + if (nAnimationSpeed === 0) { + nAnimationSpeed = 80 + } + + const pTileGrid = tileGridOf(pDrlgRoom) + const pTileLibraryEntries: D2TileLibraryEntryStrc[] = [] + + for (let i = 0; i < nTiles; i += 1) { + const pCurrentTileData = pTiles[i]! + if (pCurrentTileData.pTile && D2CMP_10079_GetTileFlags(pCurrentTileData.pTile) & TILE_FLAGS_LAVA) { + const nGridIdx = GetMapTileLayer(pCurrentTileData.dwFlags) + const pLayerGrid = pDrlgGrid[nGridIdx < 0 ? 0 : nGridIdx]! + const nGridEntry = DRLGGRID_GetGridEntry(pLayerGrid, pCurrentTileData.nPosX, pCurrentTileData.nPosY) >>> 0 + + const nStyle = PackedTile.nTileStyle(nGridEntry) + const nSeq = PackedTile.nTileSequence(nGridEntry) + + const nFrames = D2CMP_10088_GetTiles( + pDrlgRoom.pTiles, + pCurrentTileData.nTileType, + nStyle, + nSeq, + pTileLibraryEntries, + 40, + ) + + const ppMapTileData = new Array(nFrames).fill(null) + const pDrlgAnimTileGrid: D2DrlgAnimTileGridStrc = { + ppMapTileData, + nFrames, + nCurrentFrame: 0, + nAnimationSpeed, + pNext: pTileGrid.pAnimTiles, + } + + pTileGrid.pAnimTiles = pDrlgAnimTileGrid + + // Animated tiles use rarity as frame index + pCurrentTileData.pTile = DRLGANIM_FindAnimatedTileFrame(pTileLibraryEntries, nFrames, 0, nStyle, nSeq) + pDrlgAnimTileGrid.ppMapTileData[0] = pCurrentTileData + + const nX = (pCurrentTileData.nPosX + pDrlgRoom.pDrlgCoord.nPosX) | 0 + const nY = (pCurrentTileData.nPosY + pDrlgRoom.pDrlgCoord.nPosY) | 0 + + for (let nRarity = 1; nRarity < nFrames; nRarity += 1) { + const pTileEntry = DRLGANIM_FindAnimatedTileFrame(pTileLibraryEntries, nFrames, nRarity, nStyle, nSeq) + + let pAddedTile: D2DrlgTileDataStrc + if (pCurrentTileData.nTileType === TILETYPE_FLOOR) { + pAddedTile = DRLGROOMTILE_InitFloorTileData(pDrlgRoom, null, nX, nY, nGridEntry, pTileEntry) + } else if (pCurrentTileData.nTileType === TILETYPE_SHADOW) { + pAddedTile = DRLGROOMTILE_InitShadowTileData(pDrlgRoom, null, nX, nY, nGridEntry, pTileEntry) + } else { + pAddedTile = DRLGROOMTILE_InitWallTileData( + pDrlgRoom, + null, + nX, + nY, + nGridEntry, + pTileEntry, + pCurrentTileData.nTileType, + ) + } + pDrlgAnimTileGrid.ppMapTileData[nRarity] = pAddedTile + // We start by displaying the first frame, hide all other frames tiles. + pAddedTile.dwFlags = (pAddedTile.dwFlags | MAPTILE_HIDDEN) >>> 0 + } + } + } +} diff --git a/src/game/drlg/drlg-d2cmp.ts b/src/game/drlg/drlg-d2cmp.ts index 2792ce9..2d8be72 100644 --- a/src/game/drlg/drlg-d2cmp.ts +++ b/src/game/drlg/drlg-d2cmp.ts @@ -83,18 +83,13 @@ function loadTileLibrary(env: DrlgEnv, szFileName: string): D2TileLibraryStrc { return lib } -//D2Cmp.#10087 +//D2Cmp.#10087 (1.13c D2CMP.dll 0x6fe25480) export function D2CMP_10087_LoadTileLibrarySlot( env: DrlgEnv, ppTileLibraryHash: D2TileLibraryStrc[], szFileName: string, ): void { const pLibrary = loadTileLibrary(env, szFileName) - for (let i = 0; i < ppTileLibraryHash.length; i += 1) { - if (ppTileLibraryHash[i] === pLibrary) { - throw new Error(`tile library ${pLibrary.szPath} loaded twice into the same slot array`) - } - } if (ppTileLibraryHash.length >= TILE_SLOTS) { throw new Error(`no free tile library slot for ${pLibrary.szPath}`) } diff --git a/src/game/drlg/drlg-drlg.ts b/src/game/drlg/drlg-drlg.ts index 278d8dd..a6cd293 100644 --- a/src/game/drlg/drlg-drlg.ts +++ b/src/game/drlg/drlg-drlg.ts @@ -29,6 +29,7 @@ import { LEVEL_LUTGHOLEIN, LEVEL_NONE, LEVEL_ROGUEENCAMPMENT, + LEVEL_TALRASHASTOMB1, LEVEL_THEPANDEMONIUMFORTRESS, newCoord, type D2DrlgCoordStrc, @@ -81,9 +82,23 @@ export function DRLG_AllocDrlg( switch (nActNo) { case 0: // ACT_I: D2CMP_10087_LoadTileLibrarySlot(pDrlg->pTiles, "DATA\\GLOBAL\\Tiles\\Act1\\Town\\Floor.dt1") break + case 1: { + // ACT_II + let nStaffLevelOffset = 0 + let nBossLevelOffset = 0 + do { + nStaffLevelOffset = (SEED_RollRandomNumber(pDrlg.pSeed) >>> 0) % 7 + nBossLevelOffset = (SEED_RollRandomNumber(pDrlg.pSeed) >>> 0) % 7 + } while (nStaffLevelOffset === nBossLevelOffset) + pDrlg.nStaffTombLevel = LEVEL_TALRASHASTOMB1 + nStaffLevelOffset + pDrlg.nBossTombLevel = LEVEL_TALRASHASTOMB1 + nBossLevelOffset + break + } + case 2: // ACT_III + pDrlg.bJungleInterlink = SEED_RollRandomNumber(pDrlg.pSeed) & 1 + break default: - // Acts II/III roll pDrlg->pSeed here (staff/boss tombs, jungle interlink); not ported yet. - throw new Error(`DRLG_AllocDrlg: act ${nActNo} is not ported`) + break } // DRLGACTIVATE_InitializeRoomExStatusLists(pDrlg): room status lists are not modelled. @@ -119,6 +134,8 @@ export function DRLG_AllocLevel(pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLeve nRoom_Center_Warp_X: [0, 0, 0, 0, 0, 0, 0, 0, 0], nRoom_Center_Warp_Y: [0, 0, 0, 0, 0, 0, 0, 0, 0], nRoomCoords: 0, + pJungleDefs: null, + nJungleDefs: 0, pBuild: null, pPresetMaps: null, pNextLevel: null, diff --git a/src/game/drlg/drlg-dump.ts b/src/game/drlg/drlg-dump.ts index 1cf7256..64f5f47 100644 --- a/src/game/drlg/drlg-dump.ts +++ b/src/game/drlg/drlg-dump.ts @@ -19,7 +19,11 @@ import { DRLGTYPE_MAZE, DRLGTYPE_OUTDOOR, DRLGTYPE_PRESET, + LEVEL_HARROGATH, + LEVEL_KURASTDOCKTOWN, + LEVEL_LUTGHOLEIN, LEVEL_ROGUEENCAMPMENT, + LEVEL_THEPANDEMONIUMFORTRESS, outdoorCoord, type D2DrlgCoordStrc, type D2DrlgGridStrc, @@ -574,7 +578,6 @@ function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: numb const where = `level ${nLevelId} room ${nRoom}` const pTileGrid = pRoom.pTileGrid if (!pTileGrid) throw new Error(`${where}: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles`) - if (pTileGrid.pAnimTiles) throw new Error(`${where}: animated tiles (pAnimTiles) are not dumped`) try { const t = pTileGrid.pTiles const links: DumpTileLink[] = [] @@ -603,6 +606,7 @@ function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: numb // ---- driver (TypeScript twin of tools/d2moo-oracle/src/main.cpp) ---- export interface Act1RunOptions { + readonly act?: number readonly difficulty?: number /** A fresh act per requested level (proves a level does not depend on the others). */ readonly isolated?: boolean @@ -635,19 +639,27 @@ export interface Act1Run { readonly levels: readonly D2DrlgLevelStrc[] } -function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc { +const TOWN_LEVEL_IDS = [ + LEVEL_ROGUEENCAMPMENT, + LEVEL_LUTGHOLEIN, + LEVEL_KURASTDOCKTOWN, + LEVEL_THEPANDEMONIUMFORTRESS, + LEVEL_HARROGATH, +] as const + +function allocActByNo(env: DrlgEnv, nAct: number, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc { trace?.context('drlg') // D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act // (DRLGOUTPLACE_CreateLevelConnections) and generates the town. - return DRLG_AllocDrlg(env, ACT_I, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nDifficulty) + return DRLG_AllocDrlg(env, nAct, nSeed, TOWN_LEVEL_IDS[nAct]!, 0, nDifficulty) } /** Generation stage of one level: DRLG_InitLevel, everything that exists before any room is activated. */ -function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc { +function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, nAct: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc { trace?.context(`L${nLevelId}`) const pLevel = DRLG_GetLevel(pDrlg, nLevelId) - if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== ACT_I) { - throw new Error(`level ${nLevelId} is not an Act I outdoor level`) + if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== nAct) { + throw new Error(`level ${nLevelId} is not an Act ${nAct + 1} outdoor level`) } if (pLevel.pFirstRoomEx) throw new Error(`level ${nLevelId} was already generated before its own generation stage`) DRLG_InitLevel(pLevel) @@ -684,7 +696,7 @@ function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefin } /** - * Runs the Act I generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then + * Runs the act generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then * DRLG_InitLevel of every requested level, then the activation of every room of those levels (see * activateLevel). A non-isolated run generates every requested level before activating any room, because * activating a room initialises the levels behind its warps (sub_6FD77BB0 -> DRLG_InitLevel); an isolated @@ -698,18 +710,30 @@ export function runAct1( hooks: Act1RunHooks = {}, ): Act1Run { const nDifficulty = opts.difficulty ?? 0 + let nAct: number = ACT_I + if (opts.act !== undefined) { + if (opts.act < 0 || opts.act > 4) throw new Error('act must be 0..4') + nAct = opts.act + } else if (levels.length > 0) { + nAct = DRLG_GetActNoFromLevelId(levels[0]!) + } + for (const id of levels) { + if (DRLG_GetActNoFromLevelId(id) !== nAct) { + throw new Error(`level ${id} belongs to act ${DRLG_GetActNoFromLevelId(id)}, not act ${nAct}`) + } + } const trace = opts.trace if (trace) setD2SeedTraceSink(trace) try { let nActs = 0 const allocAct = (): D2DrlgStrc => { - const pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace) + const pDrlg = allocActByNo(env, nAct, nSeed >>> 0, nDifficulty, trace) hooks.onDrlgAllocated?.(pDrlg, nActs) nActs += 1 return pDrlg } const generate = (pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLevelStrc => { - const pLevel = initLevel(pDrlg, nLevelId, trace) + const pLevel = initLevel(pDrlg, nLevelId, nAct, trace) hooks.onLevelGenerated?.(pLevel) return pLevel } @@ -733,6 +757,8 @@ export function runAct1( } } +export const runAct = runAct1 + export interface DumpAct1Options extends Act1RunOptions { /** Stop after the act stage (the `levels` array stays empty). */ readonly actOnly?: boolean @@ -806,8 +832,12 @@ export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], levelGrids.set(pLevel.nLevelId, dumpLevelGrid(pLevel)) }) let run: Act1Run + const runOpts: DumpAct1Options = + opts.act === undefined && levels.length > 0 + ? { ...opts, act: DRLG_GetActNoFromLevelId(levels[0]!) } + : opts try { - run = runAct1(env, nSeed, opts.actOnly ? [] : levels, opts, { + run = runAct1(env, nSeed, opts.actOnly ? [] : levels, runOpts, { onDrlgAllocated(pDrlg, index) { if (index !== 0) return drlg = { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) } @@ -815,7 +845,7 @@ export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], }, onLevelGenerated(pLevel) { const levelGrid = levelGrids.get(pLevel.nLevelId) - if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: InitAct1OutdoorLevel hook did not fire`) + if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: outdoor levelGrid hook did not fire`) heads.set(pLevel, dumpLevelHead(pLevel, levelGrid)) activations.set(pLevel, []) }, diff --git a/src/game/drlg/drlg-logic.ts b/src/game/drlg/drlg-logic.ts index 98fddb7..24b025a 100644 --- a/src/game/drlg/drlg-logic.ts +++ b/src/game/drlg/drlg-logic.ts @@ -203,6 +203,8 @@ const stru_6FDCE5C8: readonly { nX: number; nY: number }[] = [ { nX: 0, nY: -1 }, ] +const dword_6FDCE5EC_NEG: readonly number[] = [1, -1, 0, 0, -1] + const dword_6FDCE5EC: readonly number[] = [ 23, 0, 5, 21, 17, 15, 3, 0, 9, 7, 39, 0, 0, 5, 3, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 0, ] @@ -239,16 +241,16 @@ function DRLGLOGIC_SetTileGridFlags(a1: D2UnkDrlgLogicStrc, nX: number, nY: numb nIndex = DRLGGRID_GetGridEntry(a1.pTileTypeGrid, nPosX, nPosY) } - // dword_6FDCE650 has 20 entries and dword_6FDCE5EC 26: any other subscript reads unrelated memory (UB; - // tile types 0 and 13 map to row -1, which lands before dword_6FDCE5EC). + // dword_6FDCE650 maps tile types 0 and 13 to row -1 (nFlagIndex = a4 - 4 in -5..-1), which in + // DrlgDrlgLogic.cpp reads the trailing 5 ints of stru_6FDCE5C8 immediately preceding dword_6FDCE5EC. const nRow = nIndex >= 0 && nIndex < dword_6FDCE650.length ? dword_6FDCE650[nIndex]! : undefined - const nFlagIndex = nRow === undefined ? -1 : (a4 + 5 * nRow + 1) | 0 - if (nRow === undefined || nFlagIndex < 0 || nFlagIndex >= dword_6FDCE5EC.length) { + const nFlagIndex = nRow === undefined ? -99 : (a4 + 5 * nRow + 1) | 0 + if (nRow === undefined || nFlagIndex < -5 || nFlagIndex >= dword_6FDCE5EC.length) { throw new RangeError( `DRLGLOGIC_SetTileGridFlags: out-of-bounds table read (tile type ${nIndex}, a4 ${a4}) at room tile (${nPosX}, ${nPosY})`, ) } - const nFlags = dword_6FDCE5EC[nFlagIndex]! + const nFlags = nFlagIndex < 0 ? dword_6FDCE5EC_NEG[nFlagIndex + 5]! : dword_6FDCE5EC[nFlagIndex]! if (nFlags & 1) { DRLGGRID_AlterGridFlag(a1.field_4, nPosX, nPosY, a1.nFlags, FLAG_OPERATION_OR) } diff --git a/src/game/drlg/drlg-map.ts b/src/game/drlg/drlg-map.ts index 064d98b..b840d96 100644 --- a/src/game/drlg/drlg-map.ts +++ b/src/game/drlg/drlg-map.ts @@ -24,12 +24,23 @@ import type { Dt1 } from '../../formats/dt1.ts' import { dt1MaskAllows } from '../d2map.ts' import type { DrlgDump, DumpCoord, DumpLevel, DumpTile, DumpTileRef } from './drlg-dump.ts' import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts' +import { LVLTYPE_ACT1_WILDERNESS, LVLTYPE_ACT4_MESA } from './drlg-ids.ts' import { DRLGPRESET_GetObjectIndexFromObjPreset } from './drlg-preset.ts' import { normalizeDrlgPath } from './drlg-source.ts' import { DATATBLS_GetLevelDefRecord, DATATBLS_GetLvlTypesTxtRecord, DATATBLS_GetMonPresetTxtActSection } from './drlg-tables.ts' import type { DrlgTables } from './drlg-tables.ts' import { - ALTDIR_EAST, ALTDIR_NORTH, ALTDIR_SOUTH, ALTDIR_WEST, DRLGTYPE_OUTDOOR, GRID2_LVL_LINK, + ALTDIR_EAST, + ALTDIR_NORTH, + ALTDIR_SOUTH, + ALTDIR_WEST, + DRLGTYPE_OUTDOOR, + GRID2_LVL_LINK, + LEVEL_BLOODYFOOTHILLS, + LEVEL_CHAOSSANCTUM, + LEVEL_HARROGATH, + LEVEL_ID_ACT5_BARRICADE_1, + LEVEL_RIVEROFFLAME, } from './drlg-types.ts' /** Tiles per outdoor grid cell (the 8x8 blocks of D2DrlgOutdoorInfoStrc.pGrid). */ @@ -39,6 +50,7 @@ const SUBTILES_PER_TILE = 5 // D2MapTileFlags (D2DrlgDrlg.h) const MAPTILE_HIDDEN = 0x8 +const MAPTILE_UNWALKABLE = 0x40 const MAPTILE_LAYER_SHIFT = 14 const MAPTILE_LAYER_MASK = 0x7 @@ -58,6 +70,8 @@ const DS1_TYPE_MONSTER = 1 const DS1_TYPE_OBJECT = 2 /** Object ids below this are ObjPreset slots; `objects.txt id + 150` otherwise (DRLGPRESET_ParseDS1File, v > 5). */ const DS1_OBJPRESET_SLOTS = 150 +/** Monster ids below this are MonPreset slots; `monstats.txt hcIdx + 1000` for tile-spawned monsters (DRLGROOMTILE_AddTilePresetUnits). */ +export const DS1_MONPRESET_SLOTS = 1000 /** * Objects.txt class D2Game refuses to create from a preset unit (SUnit.cpp:1003, SUNIT_CreatePresetUnit: * `if (nClassId == 573) return`), so it never exists in game. @@ -201,6 +215,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 } @@ -247,6 +263,22 @@ export const DRLG_UNIVERSAL_DT1: readonly string[] = [ 'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1', ].map(normalizeDrlgPath) +/** + * The levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending. + * + * @param tables - the DRLG tables. + * @param act - optional 0-indexed act number (0..4). + * @returns level ids. + */ +export function outdoorLevelIds(tables: DrlgTables, act?: number): number[] { + const out: number[] = [] + for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) { + if (act !== undefined && DRLG_GetActNoFromLevelId(id) !== act) continue + if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id) + } + return out +} + /** * The Act I levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending. * @@ -254,12 +286,7 @@ export const DRLG_UNIVERSAL_DT1: readonly string[] = [ * @returns level ids. */ export function act1OutdoorLevelIds(tables: DrlgTables): number[] { - const out: number[] = [] - for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) { - if (DRLG_GetActNoFromLevelId(id) !== 0) break - if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id) - } - return out + return outdoorLevelIds(tables, 0) } const SIDE_OF_ALTDIR: Readonly> = { @@ -370,6 +397,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 +414,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 +429,50 @@ 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 + const unwalkable = (p.t.flags & MAPTILE_UNWALKABLE) !== 0 // 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, ...(unwalkable ? { unwalkable: true } : {}) } }) 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 @@ -475,7 +539,7 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D break } case UNIT_MONSTER: { - const id = monIdOf.get(u.index) + const id = monIdOf.get(u.index) ?? (u.index >= 0 && u.index < nMonStats ? u.index + DS1_MONPRESET_SLOTS : undefined) if (id === undefined) throw new Error(`level ${levelId}: monster unit ${u.index} has no act ${nAct + 1} MonPreset row`) objects.push({ type: DS1_TYPE_MONSTER, id, x: u.subX, y: u.subY, flags: u.spawned }) nMonsters += 1 @@ -634,36 +698,56 @@ function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] { throw new Error(`level ${levelId}: grid-2 ${grid2.w}x${grid2.h} does not cover ${coord.width}x${coord.height} tiles`) } const linkCellsOf = new Map() - for (let gy = 0; gy < grid2.h; gy += 1) { - for (let gx = 0; gx < grid2.w; gx += 1) { - if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue - const onW = gx === 0 - const onE = gx === grid2.w - 1 - const onN = gy === 0 - const onS = gy === grid2.h - 1 - if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) { - throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`) + if (input.levelType === LVLTYPE_ACT1_WILDERNESS || (input.levelType === LVLTYPE_ACT4_MESA && levelId !== LEVEL_CHAOSSANCTUM)) { + for (let gy = 0; gy < grid2.h; gy += 1) { + for (let gx = 0; gx < grid2.w; gx += 1) { + if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue + const onW = gx === 0 + const onE = gx === grid2.w - 1 + const onN = gy === 0 + const onS = gy === grid2.h - 1 + if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) { + throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`) + } + const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH + // World tiles covered by the cell along its side. + const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH + const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL) + const to = from + TILES_PER_GRID_CELL + const owners = input.orths.filter(o => { + if (o.dir !== dir) return false + const lo = horizontal ? o.box.x : o.box.y + const hi = lo + (horizontal ? o.box.width : o.box.height) + return from >= lo && to <= hi + }) + if (owners.length !== 1) { + throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`) + } + const list = linkCellsOf.get(owners[0]!.level) ?? [] + list.push({ gx, gy }) + linkCellsOf.set(owners[0]!.level, list) } - const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH - // World tiles covered by the cell along its side. - const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH - const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL) - const to = from + TILES_PER_GRID_CELL - const owners = input.orths.filter(o => { - if (o.dir !== dir) return false - const lo = horizontal ? o.box.x : o.box.y - const hi = lo + (horizontal ? o.box.width : o.box.height) - return from >= lo && to <= hi - }) - if (owners.length !== 1) { - throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`) - } - const list = linkCellsOf.get(owners[0]!.level) ?? [] - list.push({ gx, gy }) - linkCellsOf.set(owners[0]!.level, list) } } + const pushSeam = (toLevelId: number, side: DrlgSide, from: number, to: number): void => { + const mid = Math.floor((from + to - 1) / 2) + const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid + const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid + const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid + const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid + out.push({ + x: edgeX, + y: edgeY, + interiorX: inX, + interiorY: inY, + toLevelId, + side, + kind: 'gate', + span: { from, to }, + }) + } + for (const o of input.orths) { const side = sideOfAltDir(o.dir, `level ${levelId} link to ${o.level}`) const horizontal = side === 'north' || side === 'south' @@ -686,22 +770,22 @@ function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] { from = lo - (horizontal ? coord.x : coord.y) to = hi - (horizontal ? coord.x : coord.y) } - const mid = Math.floor((from + to - 1) / 2) - const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid - const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid - const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid - const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid - out.push({ - x: edgeX, - y: edgeY, - interiorX: inX, - interiorY: inY, - toLevelId: o.level, - side, - kind: 'gate', - span: { from, to }, - }) + pushSeam(o.level, side, from, to) } + + // Preset-adjacent seams that D2Common connects via fixed DS1 border presets rather than pOutdoors->pRoomData: + // - Chaos Sanctum (108): south entrance bridge to River of Flame (107) at x=48..72 (DiabloEntry at grid 6,12). + // - Bloody Foothills (110): east town gate to Harrogath (109, y=0..40) and west barricade transition to Frigid Highlands (111, y=0..32). + // - Frigid Highlands (111): east siege transition to Bloody Foothills (110, y=height-32..height). + if (levelId === LEVEL_CHAOSSANCTUM) { + pushSeam(LEVEL_RIVEROFFLAME, 'south', 48, 72) + } else if (levelId === LEVEL_BLOODYFOOTHILLS) { + pushSeam(LEVEL_HARROGATH, 'east', 0, Math.min(coord.height, 40)) + pushSeam(LEVEL_ID_ACT5_BARRICADE_1, 'west', 0, Math.min(coord.height, 32)) + } else if (levelId === LEVEL_ID_ACT5_BARRICADE_1) { + pushSeam(LEVEL_BLOODYFOOTHILLS, 'east', Math.max(0, coord.height - 32), coord.height) + } + return out } @@ -750,15 +834,24 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`) } - const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => ({ - key, - kind, - x: room[0] + t[2] - levelX, - y: room[1] + t[3] - levelY, - flags: t[5] >>> 0, - tileType: t[7], - tile: t[6] === null ? null : { library: normalizeDrlgPath(t[6][0]), index: t[6][1] }, - }) + const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => { + const library = t[6] === null ? null : normalizeDrlgPath(t[6][0]) + // DRLGROOMTILE_GetTileCache (DrlgRoomTile.cpp:0x6FD88860): when D2CMP_10088_GetTiles finds no + // matching tile in the room's masked DT1s (e.g. unoccupied Act 3 jungle 8x8 filler rooms whose + // dwDT1Mask = 0x04 excludes Floor.dt1), it falls back to GetTiles(pTiles, 10, 0, 0) from Warp.dt1 + // (type 10) while preserving the non-10 nTileType (0 for floor). Those void filler cells have no + // floor tile and remain unwalkable. + const isMissingTileFallback = library === DRLG_UNIVERSAL_DT1[2] && t[7] !== 10 + return { + key, + kind, + x: room[0] + t[2] - levelX, + y: room[1] + t[3] - levelY, + flags: t[5] >>> 0, + tileType: t[7], + tile: t[6] === null || isMissingTileFallback ? null : { library: library!, index: t[6][1] }, + } + } const initialised = (level: DumpLevel, room: number, array: 'w' | 'f' | 'r'): { list: readonly DumpTile[]; count: number } => { const mt = level.activation[room]!.mapTiles const [list, count] = array === 'w' ? [mt.walls, mt.nWalls] : array === 'f' ? [mt.floors, mt.nFloors] : [mt.roofs, mt.nShadows] diff --git a/src/game/drlg/drlg-maze.ts b/src/game/drlg/drlg-maze.ts index 0f7e28e..8d997a5 100644 --- a/src/game/drlg/drlg-maze.ts +++ b/src/game/drlg/drlg-maze.ts @@ -132,9 +132,139 @@ import { LVLPREST_ACT2_CORRUPT_HAREM_SW, LVLPREST_ACT2_DESERT_RUINS_ELDER, LVLPREST_ACT2_DURIELS_LAIR, + LVLPREST_ACT2_LAIR_E, + LVLPREST_ACT2_LAIR_N, + LVLPREST_ACT2_LAIR_NEXT_E, + LVLPREST_ACT2_LAIR_NEXT_N, + LVLPREST_ACT2_LAIR_NEXT_S, + LVLPREST_ACT2_LAIR_NEXT_W, + LVLPREST_ACT2_LAIR_PREV_E, + LVLPREST_ACT2_LAIR_PREV_N, + LVLPREST_ACT2_LAIR_PREV_S, + LVLPREST_ACT2_LAIR_PREV_W, + LVLPREST_ACT2_LAIR_S, LVLPREST_ACT2_LAIR_TIGHT_SPOT_S, + LVLPREST_ACT2_LAIR_TREASURE_E, + LVLPREST_ACT2_LAIR_TREASURE_N, + LVLPREST_ACT2_LAIR_TREASURE_S, + LVLPREST_ACT2_LAIR_TREASURE_W, + LVLPREST_ACT2_LAIR_W, + LVLPREST_ACT2_SEWER_CHEST_E, + LVLPREST_ACT2_SEWER_CHEST_N, + LVLPREST_ACT2_SEWER_CHEST_S, + LVLPREST_ACT2_SEWER_CHEST_W, + LVLPREST_ACT2_SEWER_E, + LVLPREST_ACT2_SEWER_N, + LVLPREST_ACT2_SEWER_NEXT_E, + LVLPREST_ACT2_SEWER_NEXT_N, + LVLPREST_ACT2_SEWER_NEXT_S, + LVLPREST_ACT2_SEWER_NEXT_W, + LVLPREST_ACT2_SEWER_PREV_E, + LVLPREST_ACT2_SEWER_PREV_N, + LVLPREST_ACT2_SEWER_PREV_NS, + LVLPREST_ACT2_SEWER_PREV_S, + LVLPREST_ACT2_SEWER_PREV_W, + LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_E, + LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_N, + LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_S, + LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_W, + LVLPREST_ACT2_SEWER_S, + LVLPREST_ACT2_SEWER_W, + LVLPREST_ACT2_SEWER_WAYPOINT_E, + LVLPREST_ACT2_SEWER_WAYPOINT_N, + LVLPREST_ACT2_SEWER_WAYPOINT_S, + LVLPREST_ACT2_SEWER_WAYPOINT_W, + LVLPREST_ACT2_TOMB_CHEST_E, + LVLPREST_ACT2_TOMB_CHEST_N, + LVLPREST_ACT2_TOMB_CHEST_S, + LVLPREST_ACT2_TOMB_CHEST_W, + LVLPREST_ACT2_TOMB_CUBE_E, + LVLPREST_ACT2_TOMB_CUBE_N, + LVLPREST_ACT2_TOMB_CUBE_S, + LVLPREST_ACT2_TOMB_CUBE_W, + LVLPREST_ACT2_TOMB_E, + LVLPREST_ACT2_TOMB_KAA_E, + LVLPREST_ACT2_TOMB_KAA_N, + LVLPREST_ACT2_TOMB_KAA_S, + LVLPREST_ACT2_TOMB_KAA_W, + LVLPREST_ACT2_TOMB_LEATHERARM_E, + LVLPREST_ACT2_TOMB_LEATHERARM_N, + LVLPREST_ACT2_TOMB_LEATHERARM_S, + LVLPREST_ACT2_TOMB_LEATHERARM_W, + LVLPREST_ACT2_TOMB_N, + LVLPREST_ACT2_TOMB_NEXT_E, + LVLPREST_ACT2_TOMB_NEXT_N, + LVLPREST_ACT2_TOMB_NEXT_S, + LVLPREST_ACT2_TOMB_NEXT_W, + LVLPREST_ACT2_TOMB_PREV_NEW, + LVLPREST_ACT2_TOMB_PREV_NSE, + LVLPREST_ACT2_TOMB_PREV_NSW, + LVLPREST_ACT2_TOMB_PREV_SEW, + LVLPREST_ACT2_TOMB_S, + LVLPREST_ACT2_TOMB_TAINTED_SUN_X, + LVLPREST_ACT2_TOMB_TALRASHA_E, + LVLPREST_ACT2_TOMB_TALRASHA_N, + LVLPREST_ACT2_TOMB_TALRASHA_S, + LVLPREST_ACT2_TOMB_TALRASHA_W, + LVLPREST_ACT2_TOMB_TREASURE_E, + LVLPREST_ACT2_TOMB_TREASURE_N, + LVLPREST_ACT2_TOMB_TREASURE_S, + LVLPREST_ACT2_TOMB_TREASURE_W, + LVLPREST_ACT2_TOMB_W, + LVLPREST_ACT2_TOMB_WAYPOINT_E, + LVLPREST_ACT2_TOMB_WAYPOINT_N, + LVLPREST_ACT2_TOMB_WAYPOINT_S, + LVLPREST_ACT2_TOMB_WAYPOINT_W, LVLPREST_ACT2_TOWN, + LVLPREST_ACT3_DUNGEON_E, + LVLPREST_ACT3_DUNGEON_N, + LVLPREST_ACT3_DUNGEON_NEXT_E, + LVLPREST_ACT3_DUNGEON_NEXT_N, + LVLPREST_ACT3_DUNGEON_NEXT_S, + LVLPREST_ACT3_DUNGEON_NEXT_W, + LVLPREST_ACT3_DUNGEON_PREV_E, + LVLPREST_ACT3_DUNGEON_PREV_N, + LVLPREST_ACT3_DUNGEON_PREV_S, + LVLPREST_ACT3_DUNGEON_PREV_W, + LVLPREST_ACT3_DUNGEON_S, LVLPREST_ACT3_DUNGEON_TREASURE_2, + LVLPREST_ACT3_DUNGEON_W, + LVLPREST_ACT3_MEPHISTO_E, + LVLPREST_ACT3_MEPHISTO_N, + LVLPREST_ACT3_MEPHISTO_NEXT_E, + LVLPREST_ACT3_MEPHISTO_NEXT_N, + LVLPREST_ACT3_MEPHISTO_NEXT_S, + LVLPREST_ACT3_MEPHISTO_NEXT_W, + LVLPREST_ACT3_MEPHISTO_PREV_E, + LVLPREST_ACT3_MEPHISTO_PREV_N, + LVLPREST_ACT3_MEPHISTO_PREV_S, + LVLPREST_ACT3_MEPHISTO_PREV_W, + LVLPREST_ACT3_MEPHISTO_S, + LVLPREST_ACT3_MEPHISTO_W, + LVLPREST_ACT3_MEPHISTO_WAYPOINT_E, + LVLPREST_ACT3_MEPHISTO_WAYPOINT_N, + LVLPREST_ACT3_MEPHISTO_WAYPOINT_S, + LVLPREST_ACT3_MEPHISTO_WAYPOINT_W, + LVLPREST_ACT3_SEWER_CHEST_E, + LVLPREST_ACT3_SEWER_CHEST_N, + LVLPREST_ACT3_SEWER_CHEST_S, + LVLPREST_ACT3_SEWER_CHEST_W, + LVLPREST_ACT3_SEWER_DRAIN_E, + LVLPREST_ACT3_SEWER_DRAIN_N, + LVLPREST_ACT3_SEWER_DRAIN_S, + LVLPREST_ACT3_SEWER_DRAIN_W, + LVLPREST_ACT3_SEWER_E, + LVLPREST_ACT3_SEWER_N, + LVLPREST_ACT3_SEWER_NE, + LVLPREST_ACT3_SEWER_NW, + LVLPREST_ACT3_SEWER_PREV_NE, + LVLPREST_ACT3_SEWER_PREV_NW, + LVLPREST_ACT3_SEWER_PREV_SE, + LVLPREST_ACT3_SEWER_PREV_SW, + LVLPREST_ACT3_SEWER_S, + LVLPREST_ACT3_SEWER_SE, + LVLPREST_ACT3_SEWER_SW, + LVLPREST_ACT3_SEWER_W, LVLPREST_ACT3_SPIDER_CHEST_NE, LVLPREST_ACT3_SPIDER_CHEST_NW, LVLPREST_ACT3_SPIDER_NE, @@ -142,8 +272,59 @@ import { LVLPREST_ACT3_SPIDER_SE, LVLPREST_ACT3_SPIDER_SW, LVLPREST_ACT3_TEMPLE_6, + LVLPREST_ACT4_BRIDGE_1, + LVLPREST_ACT4_BRIDGE_2, + LVLPREST_ACT4_LAVA_E, + LVLPREST_ACT4_LAVA_FORGE_E, + LVLPREST_ACT4_LAVA_FORGE_W, + LVLPREST_ACT4_LAVA_W, + LVLPREST_ACT4_LAVA_WARP_N, LVLPREST_ACT4_LAVA_X, + LVLPREST_ACT5_BAAL_E, + LVLPREST_ACT5_BAAL_N, + LVLPREST_ACT5_BAAL_NEXT_E, + LVLPREST_ACT5_BAAL_NEXT_N, + LVLPREST_ACT5_BAAL_NEXT_S, + LVLPREST_ACT5_BAAL_NEXT_W, + LVLPREST_ACT5_BAAL_PREV_NEW, + LVLPREST_ACT5_BAAL_PREV_NSE, + LVLPREST_ACT5_BAAL_PREV_NSW, + LVLPREST_ACT5_BAAL_PREV_SEW, + LVLPREST_ACT5_BAAL_S, + LVLPREST_ACT5_BAAL_W, + LVLPREST_ACT5_BAAL_WAYPOINT_E, + LVLPREST_ACT5_BAAL_WAYPOINT_N, + LVLPREST_ACT5_BAAL_WAYPOINT_S, + LVLPREST_ACT5_BAAL_WAYPOINT_W, LVLPREST_ACT5_BARRICADE_16_SNOW, + LVLPREST_ACT5_ICE_DOWN_E, + LVLPREST_ACT5_ICE_DOWN_N, + LVLPREST_ACT5_ICE_DOWN_S, + LVLPREST_ACT5_ICE_DOWN_W, + LVLPREST_ACT5_ICE_E, + LVLPREST_ACT5_ICE_N, + LVLPREST_ACT5_ICE_NEXT_E, + LVLPREST_ACT5_ICE_NEXT_N, + LVLPREST_ACT5_ICE_NEXT_S, + LVLPREST_ACT5_ICE_NEXT_W, + LVLPREST_ACT5_ICE_POOL_A, + LVLPREST_ACT5_ICE_POOL_B, + LVLPREST_ACT5_ICE_PREV_E, + LVLPREST_ACT5_ICE_PREV_N, + LVLPREST_ACT5_ICE_PREV_S, + LVLPREST_ACT5_ICE_PREV_W, + LVLPREST_ACT5_ICE_RIVER_A, + LVLPREST_ACT5_ICE_RIVER_B, + LVLPREST_ACT5_ICE_S, + LVLPREST_ACT5_ICE_THEME_E, + LVLPREST_ACT5_ICE_THEME_N, + LVLPREST_ACT5_ICE_THEME_S, + LVLPREST_ACT5_ICE_THEME_W, + LVLPREST_ACT5_ICE_W, + LVLPREST_ACT5_ICE_WAYPOINT_E, + LVLPREST_ACT5_ICE_WAYPOINT_N, + LVLPREST_ACT5_ICE_WAYPOINT_S, + LVLPREST_ACT5_ICE_WAYPOINT_W, LVLPREST_ACT5_LAVA_E, LVLPREST_ACT5_LAVA_EW, LVLPREST_ACT5_LAVA_N, @@ -151,9 +332,15 @@ import { LVLPREST_ACT5_LAVA_S, LVLPREST_ACT5_LAVA_W, LVLPREST_ACT5_TEMPLE_NE, + LVLPREST_ACT5_TEMPLE_NE_DOWN, + LVLPREST_ACT5_TEMPLE_NE_WAYPOINT, LVLPREST_ACT5_TEMPLE_NW, + LVLPREST_ACT5_TEMPLE_NW_DOWN, + LVLPREST_ACT5_TEMPLE_NW_WAYPOINT, LVLPREST_ACT5_TEMPLE_SE_UP, + LVLPREST_ACT5_TEMPLE_SE_WAYPOINT, LVLPREST_ACT5_TEMPLE_SW, + LVLPREST_ACT5_TEMPLE_SW_DOWN, LVLPREST_ACT5_TEMPLE_SW_WAYPOINT, LVLPREST_NONE, LVLTYPE_ACT1_BARRACKS, @@ -189,6 +376,7 @@ import { DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping, } from './drlg-room.ts' import { + ACT_V, ALTDIR_EAST, ALTDIR_NORTH, ALTDIR_SOUTH, @@ -196,11 +384,51 @@ import { DIRECTION_INVALID, DRLGPRESETROOMFLAG_HAS_MAP_DS1, DRLGTYPE_PRESET, + LEVEL_ANCIENTTUNNELS, + LEVEL_BARRACKS, + LEVEL_CATACOMBSLEV1, + LEVEL_CATACOMBSLEV2, LEVEL_CAVELEV1, + LEVEL_CELLAROFPITY, + LEVEL_CHAOSSANCTUM, + LEVEL_CLAWVIPERTEMPLELEV1, + LEVEL_CLAWVIPERTEMPLELEV2, LEVEL_CRYPT, + LEVEL_CRYSTALIZEDCAVERNLEV1, + LEVEL_CRYSTALIZEDCAVERNLEV2, LEVEL_DENOFEVIL, + LEVEL_DURANCEOFHATELEV1, + LEVEL_DURANCEOFHATELEV2, + LEVEL_ECHOCHAMBER, + LEVEL_GLACIALCAVESLEV1, + LEVEL_GLACIALCAVESLEV2, + LEVEL_HALLSOFDEATHSCALLING, + LEVEL_HALLSOFTHEDEADLEV2, + LEVEL_HALLSOFTHEDEADLEV3, + LEVEL_HALLSOFVAUGHT, + LEVEL_JAILLEV1, + LEVEL_JAILLEV2, + LEVEL_JAILLEV3, + LEVEL_MAGGOTLAIRLEV3, LEVEL_MAUSOLEUM, LEVEL_OUTERCLOISTER, + LEVEL_PALACECELLARLEV1, + LEVEL_PALACECELLARLEV3, + LEVEL_PANDEMONIUMRUN1, + LEVEL_RIVEROFFLAME, + LEVEL_SEWERSA3LEV1, + LEVEL_SEWERSLEV1, + LEVEL_SEWERSLEV2, + LEVEL_SEWERSLEV3, + LEVEL_SPIDERCAVE, + LEVEL_SPIDERCAVERN, + LEVEL_STONYTOMBLEV1, + LEVEL_STONYTOMBLEV2, + LEVEL_TALRASHASTOMB1, + LEVEL_TALRASHASTOMB7, + LEVEL_THEWORLDSTONEKEEPLEV2, + LEVEL_TOWERCELLARLEV1, + LEVEL_TOWERCELLARLEV4, LEVEL_UNDERGROUNDPASSAGELEV1, type D2DrlgBuildStrc, type D2DrlgLevelStrc, @@ -208,19 +436,6 @@ import { type D2DrlgRoomStrc, } from './drlg-types.ts' -const LEVEL_TOWERCELLARLEV1 = 21 -const LEVEL_TOWERCELLARLEV4 = 24 -const LEVEL_BARRACKS = 28 -const LEVEL_JAILLEV1 = 29 -const LEVEL_JAILLEV2 = 30 -const LEVEL_JAILLEV3 = 31 -const LEVEL_CATACOMBSLEV1 = 34 -const LEVEL_CATACOMBSLEV2 = 35 -const LEVEL_PALACECELLARLEV1 = 52 -const LEVEL_PALACECELLARLEV3 = 54 -const LEVEL_SPIDERCAVE = 84 -const LEVEL_SPIDERCAVERN = 85 - export interface D2MazeLevelIdStrc { nLevelPrestId1: number nLevelPrestId2: number @@ -296,6 +511,17 @@ export function DRLGMAZE_GetFreeLocationForRoomNorth(pLevel: D2DrlgLevelStrc): D return pResult } +// Helper function +export function DRLGMAZE_GetFreeLocationForRoomSouth(pLevel: D2DrlgLevelStrc): D2DrlgRoomStrc | null { + let pResult: D2DrlgRoomStrc | null = null + for (let pDrlgRoom = pLevel.pFirstRoomEx; pDrlgRoom; pDrlgRoom = pDrlgRoom.pDrlgRoomNext) { + if ((!pResult || pDrlgRoom.pDrlgCoord.nPosY < pResult.pDrlgCoord.nPosY) && DRLGMAZE_CheckIfMayPlaceAdjacentPresetRoom(pDrlgRoom, ALTDIR_NORTH)) { + pResult = pDrlgRoom + } + } + return pResult +} + //D2Common.0x6FD78F70 export function DRLGMAZE_PickRoomPreset(pDrlgRoom: D2DrlgRoomStrc, bResetFlag: boolean): void { const nHardcodedPresetsRemapping: readonly (readonly [number, number, number])[] = [ @@ -563,6 +789,13 @@ export function DRLGMAZE_GenerateLevel(pLevel: D2DrlgLevelStrc): void { { nLevelPrestId1: LVLPREST_ACT1_CATACOMBS_W, nLevelPrestId2: LVLPREST_ACT1_CATACOMBS_WAYPOINT_W, nPickedFile: -1, nDirection: 2 }, ] + const nAct2ArcaneSummonerIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_ARCANE_N, nLevelPrestId2: LVLPREST_ACT2_ARCANE_SUMMONER_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_ARCANE_E, nLevelPrestId2: LVLPREST_ACT2_ARCANE_SUMMONER_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_ARCANE_S, nLevelPrestId2: LVLPREST_ACT2_ARCANE_SUMMONER_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_ARCANE_W, nLevelPrestId2: LVLPREST_ACT2_ARCANE_SUMMONER_W, nPickedFile: -1, nDirection: 2 }, + ] + const pDrlgRoom = DRLGROOM_AllocRoomEx(pLevel, DRLGTYPE_PRESET) pDrlgRoom.pDrlgCoord.nWidth = pDrlgRoom.pLevel.pMaze!.dwSizeX pDrlgRoom.pDrlgCoord.nHeight = pDrlgRoom.pLevel.pMaze!.dwSizeY @@ -632,7 +865,7 @@ export function DRLGMAZE_GenerateLevel(pLevel: D2DrlgLevelStrc): void { nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct1CryptSpecialIds[nRand]!, nRand) } - if (pLevel.nLevelId === LEVEL_MAUSOLEUM) { + if (pLevel.nLevelId === LEVEL_MAUSOLEUM || pLevel.nLevelId === LEVEL_PANDEMONIUMRUN1) { nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct1CryptChestIds[nRand]!, nRand) } @@ -732,12 +965,138 @@ export function DRLGMAZE_GenerateLevel(pLevel: D2DrlgLevelStrc): void { } break + case LVLTYPE_ACT2_SEWER: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + + nRooms = pLevel.pMaze!.dwRooms[pLevel.pDrlg.nDifficulty]! + if (pLevel.nLevelId === pLevel.pDrlg.nStaffTombLevel) { + nRooms *= 3 + } else if (pLevel.nLevelId === pLevel.pDrlg.nBossTombLevel) { + nRooms *= 2 + } + + while (pLevel.nRooms < nRooms) { + const pRandomRoomEx = DRLGMAZE_GetRandomRoomExFromLevel(pLevel) + const nDirection = SEED_RollRandomNumber(pRandomRoomEx.pSeed) & 3 + if (!DRLGMAZE_HasMapDS1(pRandomRoomEx)) { + DRLGMAZE_AddAdjacentMazeRoom(pRandomRoomEx, nDirection, true) + } + } + + DRLGMAZE_ScanReplaceSpecialAct2SewersPresets(pLevel) + break + + case LVLTYPE_ACT2_TOMB: + if (pLevel.nLevelId === LEVEL_CLAWVIPERTEMPLELEV2) { + pDrlgRoom.pMaze!.nLevelPrest = LVLPREST_ACT2_TOMB_TAINTED_SUN_X + pDrlgRoom.pMaze!.nPickedFile = -1 + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + } else { + DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev(pLevel) + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct2TombStuff(pLevel) + } + break + + case LVLTYPE_ACT2_LAIR: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct2LairStuff(pLevel) + break + + case LVLTYPE_ACT2_ARCANE: + DRLGMAZE_PlaceArcaneSanctuary(pLevel) + + nRand = SEED_RollRandomNumber(pLevel.pSeed) & 3 + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2ArcaneSummonerIds[nRand]!, nRand) + break + + case LVLTYPE_ACT2_HAREM: + case LVLTYPE_ACT2_BASEMENT: + case LVLTYPE_ACT3_SPIDER: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + break + + case LVLTYPE_ACT3_KURAST: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct3MephistoStuff(pLevel) + break + + case LVLTYPE_ACT3_DUNGEON: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct3DungeonStuff(pLevel) + break + + case LVLTYPE_ACT3_SEWER: + if (pLevel.nLevelId === LEVEL_SEWERSA3LEV1) { + DRLGMAZE_InitBasicMazeLayout(pLevel, 5) + DRLGMAZE_ReplaceRoomPreset(pLevel, LVLPREST_ACT3_SEWER_SW, LVLPREST_ACT3_SEWER_PREV_SW, -1, false) + DRLGMAZE_ReplaceRoomPreset(pLevel, LVLPREST_ACT3_SEWER_SE, LVLPREST_ACT3_SEWER_PREV_SE, -1, false) + DRLGMAZE_ReplaceRoomPreset(pLevel, LVLPREST_ACT3_SEWER_NW, LVLPREST_ACT3_SEWER_PREV_NW, -1, false) + DRLGMAZE_ReplaceRoomPreset(pLevel, LVLPREST_ACT3_SEWER_NE, LVLPREST_ACT3_SEWER_PREV_NE, -1, false) + } else { + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + } + + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct3SewerStuff(pLevel) + break + + case LVLTYPE_ACT4_LAVA: + DRLGMAZE_BuildBasicMaze(pLevel) + break + + case LVLTYPE_ACT5_ICE_CAVES: + switch (pLevel.nLevelId) { + case LEVEL_CELLAROFPITY: + if (SEED_RollLimitedRandomNumber(pDrlgRoom.pLevel.pSeed, 2) !== 0) { + DRLGMAZE_SetPickedFileAndPresetId(pLevelFirstRoomEx, LVLPREST_ACT5_ICE_RIVER_A, -1, false) + } else { + DRLGMAZE_SetPickedFileAndPresetId(pLevelFirstRoomEx, LVLPREST_ACT5_ICE_RIVER_B, -1, false) + } + break + + case LEVEL_ECHOCHAMBER: + DRLGMAZE_SetPickedFileAndPresetId(pLevelFirstRoomEx, LVLPREST_ACT5_ICE_POOL_A, -1, false) + break + + case LEVEL_GLACIALCAVESLEV2: + DRLGMAZE_SetPickedFileAndPresetId(pLevelFirstRoomEx, LVLPREST_ACT5_ICE_POOL_B, -1, false) + break + + default: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct5IceStuff(pLevel) + break + } + break + + case LVLTYPE_ACT5_TEMPLE: + DRLGMAZE_InitBasicMazeLayout(pLevel, 2) + DRLGMAZE_PlaceAct5TempleStuff(pLevel) + break + + case LVLTYPE_ACT5_BAAL: + DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev(pLevel) + DRLGMAZE_BuildBasicMaze(pLevel) + DRLGMAZE_PlaceAct5BaalStuff(pLevel) + break + + case LVLTYPE_ACT5_LAVA: + DRLGMAZE_PlaceAct5LavaPresets(pLevel) + break + default: - throw new Error(`MazeLevelGenerate(${pLevel.nLevelId}, type=${pLevel.nLevelType}) - not ported`) + throw new Error(`MazeLevelGenerate(${pLevel.nLevelId}, type=${pLevel.nLevelType}) - Some really bad voodoo here!`) } if (pLevel.nLevelId === LEVEL_BARRACKS) { DRLGMAZE_PlaceAct1Barracks(pLevel) + } else if (pLevel.nLevelId === LEVEL_RIVEROFFLAME) { + DRLGMAZE_PlaceAct4Lava(pLevel) } else { DRLG_UpdateRoomExCoordinates(pLevel) } @@ -1043,6 +1402,252 @@ export function DRLGMAZE_GetRandomRoomExFromLevel(pLevel: D2DrlgLevelStrc): D2Dr return pDrlgRoom } +//D2Common.0x6FD7A5D0 +export function DRLGMAZE_BuildBasicMaze(pLevel: D2DrlgLevelStrc): void { + let nRooms = pLevel.pMaze!.dwRooms[pLevel.pDrlg.nDifficulty]! + if (pLevel.nLevelId === pLevel.pDrlg.nStaffTombLevel) { + nRooms *= 3 + } + if (pLevel.nLevelId === pLevel.pDrlg.nBossTombLevel) { + nRooms *= 2 + } + + for (let i = pLevel.nRooms; i < nRooms; i = pLevel.nRooms) { + const pRandomRoomEx = DRLGMAZE_GetRandomRoomExFromLevel(pLevel) + const nDirection = SEED_RollRandomNumber(pRandomRoomEx.pSeed) & 3 + + if (!DRLGMAZE_HasMapDS1(pRandomRoomEx)) { + const pNewRoomEx = DRLGROOM_AllocRoomEx(pRandomRoomEx.pLevel, DRLGTYPE_PRESET) + pNewRoomEx.pDrlgCoord.nWidth = pNewRoomEx.pLevel.pMaze!.dwSizeX + pNewRoomEx.pDrlgCoord.nHeight = pNewRoomEx.pLevel.pMaze!.dwSizeY + + switch (nDirection) { + case 0: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX - pRandomRoomEx.pDrlgCoord.nWidth + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY + break + case 1: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY - pRandomRoomEx.pDrlgCoord.nHeight + break + case 2: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX + pRandomRoomEx.pDrlgCoord.nWidth + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY + break + case 3: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY + pRandomRoomEx.pDrlgCoord.nHeight + break + case 4: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX - pRandomRoomEx.pDrlgCoord.nWidth + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY - pRandomRoomEx.pDrlgCoord.nHeight + break + case 5: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX + pRandomRoomEx.pDrlgCoord.nWidth + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY - pRandomRoomEx.pDrlgCoord.nHeight + break + case 6: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX + pRandomRoomEx.pDrlgCoord.nWidth + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY + pRandomRoomEx.pDrlgCoord.nHeight + break + case 7: + pNewRoomEx.pDrlgCoord.nPosX = pRandomRoomEx.pDrlgCoord.nPosX - pRandomRoomEx.pDrlgCoord.nWidth + pNewRoomEx.pDrlgCoord.nPosY = pRandomRoomEx.pDrlgCoord.nPosY + pRandomRoomEx.pDrlgCoord.nHeight + break + default: + break + } + + let pDrlgOrth = pRandomRoomEx.pDrlgOrth + while (pDrlgOrth && DRLG_CheckNotOverlappingUsingManhattanDistance(pNewRoomEx.pDrlgCoord, pDrlgOrth.pBox!, 0)) { + pDrlgOrth = pDrlgOrth.pNext + } + + if (!pDrlgOrth) { + if (DRLGMAZE_CheckRoomNotOverlaping(pNewRoomEx.pLevel, pNewRoomEx, pRandomRoomEx, 0)) { + DRLGROOM_AllocDrlgOrthsForRooms(pRandomRoomEx, pNewRoomEx, nDirection) + DRLGMAZE_MergeMazeRooms(pNewRoomEx) + DRLGROOM_AddRoomExToLevel(pRandomRoomEx.pLevel, pNewRoomEx) + DRLGMAZE_PickRoomPreset(pRandomRoomEx, true) + DRLGMAZE_PickRoomPreset(pNewRoomEx, true) + } else { + DRLGROOM_FreeRoomEx(pNewRoomEx) + } + } else { + DRLGROOM_FreeRoomEx(pNewRoomEx) + } + } + } +} + +const dword_6FDCE850: readonly (readonly [number, number, number])[] = [ + [LVLPREST_ACT5_LAVA_S, 1, 0], + [LVLPREST_ACT5_LAVA_N, 3, 1], + [LVLPREST_ACT5_LAVA_S, 1, 1], + [LVLPREST_ACT5_LAVA_N, 3, 0], + [LVLPREST_ACT5_LAVA_E, 0, 1], + [LVLPREST_ACT5_LAVA_W, 2, 0], + [LVLPREST_ACT5_LAVA_E, 0, 0], + [LVLPREST_ACT5_LAVA_W, 2, 1], +] + +function PlaceLavaPreset(pFirstRoomEx: D2DrlgRoomStrc, nSet: number): void { + const nDirection = dword_6FDCE850[nSet]![1] + + const pNewRoomEx = DRLGROOM_AllocRoomEx(pFirstRoomEx.pLevel, DRLGTYPE_PRESET) + pNewRoomEx.pDrlgCoord.nWidth = pNewRoomEx.pLevel.pMaze!.dwSizeX + pNewRoomEx.pDrlgCoord.nHeight = pNewRoomEx.pLevel.pMaze!.dwSizeY + + if (DRLGMAZE_LinkMazeRooms(pNewRoomEx, pFirstRoomEx, nDirection)) { + DRLGROOM_AllocDrlgOrthsForRooms(pFirstRoomEx, pNewRoomEx, nDirection) + DRLGROOM_AddRoomExToLevel(pFirstRoomEx.pLevel, pNewRoomEx) + DRLGMAZE_PickRoomPreset(pFirstRoomEx, true) + + pNewRoomEx.pMaze!.nLevelPrest = dword_6FDCE850[nSet]![0] + pNewRoomEx.pMaze!.nPickedFile = dword_6FDCE850[nSet]![2] + pNewRoomEx.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + } else { + DRLGROOM_FreeRoomEx(pNewRoomEx) + } +} + +//D2Common.0x6FD7A830 +export function DRLGMAZE_PlaceAct5LavaPresets(pLevel: D2DrlgLevelStrc): void { + const nSet = 2 * (SEED_RollRandomNumber(pLevel.pFirstRoomEx!.pSeed) & 3) + const pLevelFirstRoom = pLevel.pFirstRoomEx! + PlaceLavaPreset(pLevelFirstRoom, nSet) + PlaceLavaPreset(pLevelFirstRoom, nSet + 1) + DRLGMAZE_FillBlankMazeSpaces(pLevel, LVLPREST_ACT4_LAVA_X, null) +} + +//D2Common.0x6FD7A9B0 +export function DRLGMAZE_FillBlankMazeSpaces(pLevel: D2DrlgLevelStrc, nLevelPrest: number, pIgnoreRoomEx: D2DrlgRoomStrc | null): void { + const nRooms = pLevel.nRooms + if (nRooms > 0) { + const pDrlgRoomArray: D2DrlgRoomStrc[] = [] + let pTemp = pLevel.pFirstRoomEx + for (let i = 0; i < nRooms; i += 1) { + pDrlgRoomArray.push(pTemp!) + pTemp = pTemp!.pDrlgRoomNext + } + + for (let i = 0; i < nRooms; i += 1) { + const pCurrentRoomEx = pDrlgRoomArray[i]! + if (pCurrentRoomEx !== pIgnoreRoomEx) { + for (let j = 0; j < 8; j += 1) { + const pNewRoomEx = DRLGROOM_AllocRoomEx(pCurrentRoomEx.pLevel, DRLGTYPE_PRESET) + pNewRoomEx.pDrlgCoord.nWidth = pNewRoomEx.pLevel.pMaze!.dwSizeX + pNewRoomEx.pDrlgCoord.nHeight = pNewRoomEx.pLevel.pMaze!.dwSizeY + + if (DRLGMAZE_LinkMazeRooms(pNewRoomEx, pCurrentRoomEx, j)) { + DRLGROOM_AllocDrlgOrthsForRooms(pCurrentRoomEx, pNewRoomEx, j) + DRLGROOM_AddRoomExToLevel(pCurrentRoomEx.pLevel, pNewRoomEx) + DRLGMAZE_SetPickedFileAndPresetId(pNewRoomEx, nLevelPrest, -1, false) + } else { + DRLGROOM_FreeRoomEx(pNewRoomEx) + } + } + } + } + } +} + +//D2Common.0x6FD7AAC0 +export function DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev(pLevel: D2DrlgLevelStrc): void { + const dword_6FDCE8B4: readonly (readonly [number, number])[] = [ + [LVLPREST_ACT2_TOMB_PREV_NSE, LVLPREST_ACT5_BAAL_PREV_NSE], + [LVLPREST_ACT2_TOMB_PREV_SEW, LVLPREST_ACT5_BAAL_PREV_SEW], + [LVLPREST_ACT2_TOMB_PREV_NSW, LVLPREST_ACT5_BAAL_PREV_NSW], + [LVLPREST_ACT2_TOMB_PREV_NEW, LVLPREST_ACT5_BAAL_PREV_NEW], + ] + + const pLevelFirstRoomEx = pLevel.pFirstRoomEx! + let nDirection = SEED_RollRandomNumber(pLevel.pSeed) & 3 + for (let i = 0; i < 3; i += 1) { + const pDrlgRoom = DRLGROOM_AllocRoomEx(pLevelFirstRoomEx.pLevel, DRLGTYPE_PRESET) + DRLGMAZE_SetRoomSize(pDrlgRoom) + + if (DRLGMAZE_LinkMazeRooms(pDrlgRoom, pLevelFirstRoomEx, nDirection)) { + DRLGROOM_AllocDrlgOrthsForRooms(pLevelFirstRoomEx, pDrlgRoom, nDirection) + DRLGMAZE_MergeMazeRooms(pDrlgRoom) + DRLGROOM_AddRoomExToLevel(pLevelFirstRoomEx.pLevel, pDrlgRoom) + DRLGMAZE_PickRoomPreset(pLevelFirstRoomEx, true) + DRLGMAZE_PickRoomPreset(pDrlgRoom, true) + } else { + DRLGROOM_FreeRoomEx(pDrlgRoom) + } + + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelType === LVLTYPE_ACT2_TOMB) { + pLevelFirstRoomEx.pMaze!.nLevelPrest = dword_6FDCE8B4[nDirection]![0] + pLevelFirstRoomEx.pMaze!.nPickedFile = -1 + pLevelFirstRoomEx.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + } else if (pLevel.nLevelType === LVLTYPE_ACT5_BAAL) { + pLevelFirstRoomEx.pMaze!.nLevelPrest = dword_6FDCE8B4[nDirection]![1] + pLevelFirstRoomEx.pMaze!.nPickedFile = -1 + pLevelFirstRoomEx.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + } +} + +function DRLGMAZE_PlaceRoomForArcaneBranch(pParentRoom: D2DrlgRoomStrc, nArcaneMapBranch: number): D2DrlgRoomStrc | null { + const pDrlgRoom = DRLGMAZE_PlaceAdjacentPresetRoom(pParentRoom, nArcaneMapBranch % 4, true) + if (pDrlgRoom) { + DRLGMAZE_PickRoomPreset(pParentRoom, true) + DRLGMAZE_PickRoomPreset(pDrlgRoom, true) + return pDrlgRoom + } + return null +} + +function DRLGMAZE_ArcaneSanctuaryDirectionFromRoomIdx(nBranchDirection: number, nBranchRoomIdx: number): number { + switch (nBranchRoomIdx) { + default: + return nBranchDirection + case 2: + case 12: + return nBranchDirection + 3 + case 7: + case 9: + return nBranchDirection + 1 + case 10: + case 11: + case 13: + case 14: + return nBranchDirection + 2 + } +} + +//D2Common.0x6FD7ABC0 +export function DRLGMAZE_PlaceArcaneSanctuary(pLevel: D2DrlgLevelStrc): void { + const pLevelFirstRoomEx = pLevel.pFirstRoomEx! + const nRand = SEED_RollRandomNumber(pLevel.pSeed) & 3 + const nRoomsPerBranch = 15 + const nBranchDirections = 4 + const pDrlgRoomArray: (D2DrlgRoomStrc | null)[] = new Array(nRoomsPerBranch * nBranchDirections).fill(null) + + for (let nBranchDirection = 0; nBranchDirection < nBranchDirections; nBranchDirection += 1) { + let pParentRoom: D2DrlgRoomStrc | null = pLevelFirstRoomEx + for (let nBranchRoomIdx = 0; nBranchRoomIdx < nRoomsPerBranch; nBranchRoomIdx += 1) { + const nRoomDirection = DRLGMAZE_ArcaneSanctuaryDirectionFromRoomIdx(nBranchDirection, nBranchRoomIdx) + const pNewRoom = DRLGMAZE_PlaceRoomForArcaneBranch(pParentRoom!, nRoomDirection) + if (nBranchRoomIdx !== 8 && nBranchRoomIdx !== 12) { + pDrlgRoomArray[nBranchRoomIdx + nBranchDirection * nRoomsPerBranch] = pNewRoom + pParentRoom = pNewRoom + } + } + } + + for (let i = 0; i < pDrlgRoomArray.length; i += 1) { + if (pDrlgRoomArray[i]) { + pDrlgRoomArray[i]!.pMaze!.nPickedFile = (nRand + Math.trunc(i / 15)) % 4 + } + } + + pLevelFirstRoomEx.pMaze!.nPickedFile = 4 +} + //D2Common.0x6FD7AFD0 export function DRLGMAZE_PlaceAdjacentPresetRoom( pParentRoomEx: D2DrlgRoomStrc, @@ -1145,6 +1750,137 @@ export function DRLGMAZE_ScanReplaceSpecialPreset( return nRand !== null ? (nRand + 1) % 4 : 0 } +//D2Common.0x6FD7B330 +export function DRLGMAZE_ScanReplaceSpecialAct2SewersPresets(pLevel: D2DrlgLevelStrc): void { + const nAct2SewerPrevIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_SEWER_N, nLevelPrestId2: LVLPREST_ACT2_SEWER_PREV_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_E, nLevelPrestId2: LVLPREST_ACT2_SEWER_PREV_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_S, nLevelPrestId2: LVLPREST_ACT2_SEWER_PREV_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_W, nLevelPrestId2: LVLPREST_ACT2_SEWER_PREV_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2SewerNextIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_SEWER_N, nLevelPrestId2: LVLPREST_ACT2_SEWER_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_E, nLevelPrestId2: LVLPREST_ACT2_SEWER_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_S, nLevelPrestId2: LVLPREST_ACT2_SEWER_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_W, nLevelPrestId2: LVLPREST_ACT2_SEWER_NEXT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2SewerWaypointIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_SEWER_N, nLevelPrestId2: LVLPREST_ACT2_SEWER_WAYPOINT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_E, nLevelPrestId2: LVLPREST_ACT2_SEWER_WAYPOINT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_S, nLevelPrestId2: LVLPREST_ACT2_SEWER_WAYPOINT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_W, nLevelPrestId2: LVLPREST_ACT2_SEWER_WAYPOINT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2SewerRadamentIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_SEWER_N, nLevelPrestId2: LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_E, nLevelPrestId2: LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_S, nLevelPrestId2: LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_W, nLevelPrestId2: LVLPREST_ACT2_SEWER_RADAMENTS_LAIR_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2SewerChestIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_SEWER_N, nLevelPrestId2: LVLPREST_ACT2_SEWER_CHEST_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_E, nLevelPrestId2: LVLPREST_ACT2_SEWER_CHEST_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_S, nLevelPrestId2: LVLPREST_ACT2_SEWER_CHEST_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_SEWER_W, nLevelPrestId2: LVLPREST_ACT2_SEWER_CHEST_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nDirection = (2 * (SEED_RollRandomNumber(pLevel.pSeed) & 1)) | 1 + let nRand = SEED_RollRandomNumber(pLevel.pSeed) & 3 + + switch (pLevel.nLevelId) { + case LEVEL_SEWERSLEV1: { + let pDrlgRoom1: D2DrlgRoomStrc | null = null + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if ((!pDrlgRoom1 || i.pDrlgCoord.nPosY < pDrlgRoom1.pDrlgCoord.nPosY) && DRLGMAZE_CheckIfMayPlaceAdjacentPresetRoom(i, ALTDIR_NORTH)) { + pDrlgRoom1 = i + } + } + + let pDrlgRoom2 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom1!, ALTDIR_NORTH, true) + if (pDrlgRoom2) { + DRLGMAZE_PickRoomPreset(pDrlgRoom1!, true) + DRLGMAZE_PickRoomPreset(pDrlgRoom2, true) + } + + let pDrlgRoom3 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom2!, ALTDIR_NORTH, true) + if (pDrlgRoom3) { + DRLGMAZE_PickRoomPreset(pDrlgRoom2!, true) + DRLGMAZE_PickRoomPreset(pDrlgRoom3, true) + } + + let pDrlgRoom4 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom3!, ALTDIR_WEST, false) + if (pDrlgRoom4) { + DRLGMAZE_PickRoomPreset(pDrlgRoom3!, true) + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom4, LVLPREST_ACT2_SEWER_PREV_E, 0, false) + } + + pDrlgRoom1 = null + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if ((!pDrlgRoom1 || i.pDrlgCoord.nPosX > pDrlgRoom1.pDrlgCoord.nPosX) && DRLGMAZE_CheckIfMayPlaceAdjacentPresetRoom(i, ALTDIR_EAST)) { + pDrlgRoom1 = i + } + } + + pDrlgRoom2 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom1!, ALTDIR_EAST, true) + if (pDrlgRoom2) { + DRLGMAZE_PickRoomPreset(pDrlgRoom1!, true) + DRLGMAZE_PickRoomPreset(pDrlgRoom2, true) + } + + pDrlgRoom3 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom2!, ALTDIR_EAST, true) + if (pDrlgRoom3) { + DRLGMAZE_PickRoomPreset(pDrlgRoom2!, true) + DRLGMAZE_PickRoomPreset(pDrlgRoom3, true) + } + + pDrlgRoom4 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom3!, nDirection, false) + if (pDrlgRoom4) { + DRLGMAZE_PickRoomPreset(pDrlgRoom3!, true) + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom4, LVLPREST_ACT2_SEWER_PREV_NS, 0, false) + } + + const pDrlgRoom5 = DRLGMAZE_PlaceAdjacentPresetRoom(pDrlgRoom4!, nDirection, false) + if (pDrlgRoom5) { + DRLGMAZE_PickRoomPreset(pDrlgRoom5, true) + } + + const pMazeLevelIds = nAct2SewerNextIds[nRand]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + break + } + + case LEVEL_SEWERSLEV2: { + const pMazeLevelIds = nAct2SewerPrevIds[nRand]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + nRand = (nRand + 1) % 4 + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2SewerWaypointIds[nRand]!, nRand) + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2SewerNextIds[nRand]!, nRand) + break + } + + case LEVEL_SEWERSLEV3: + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2SewerPrevIds[nRand]!, nRand) + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2SewerRadamentIds[nRand]!, nRand) + break + + case LEVEL_ANCIENTTUNNELS: + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2SewerPrevIds[nRand]!, nRand) + nRand = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2SewerChestIds[nRand]!, nRand) + break + + default: + return + } +} + //D2Common.0x6FD7B660 export function DRLGMAZE_AddSpecialPreset(pLevel: D2DrlgLevelStrc, nDirection: number, nLvlPrestId: number, nFile: number): void { for (let pDrlgRoom = pLevel.pFirstRoomEx; pDrlgRoom; pDrlgRoom = pDrlgRoom.pDrlgRoomNext) { @@ -1170,6 +1906,163 @@ export function DRLGMAZE_CheckIfMayPlaceAdjacentPresetRoom(pDrlgRoom: D2DrlgRoom return pNewRoomEx !== null } +//D2Common.0x6FD7B8B0 +export function DRLGMAZE_PlaceAct2TombStuff(pLevel: D2DrlgLevelStrc): void { + const nAct2TombNextIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_NEXT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombWaypointIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_WAYPOINT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_WAYPOINT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_WAYPOINT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_WAYPOINT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombChestIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_CHEST_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_CHEST_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_CHEST_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_CHEST_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombLeatherarmIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_LEATHERARM_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_LEATHERARM_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_LEATHERARM_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_LEATHERARM_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombCubeIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_CUBE_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_CUBE_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_CUBE_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_CUBE_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombTreasureIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_TREASURE_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_TREASURE_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_TREASURE_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_TREASURE_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombTalRashaIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_TALRASHA_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_TALRASHA_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_TALRASHA_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_TALRASHA_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2TombKaaIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_TOMB_N, nLevelPrestId2: LVLPREST_ACT2_TOMB_KAA_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_E, nLevelPrestId2: LVLPREST_ACT2_TOMB_KAA_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_S, nLevelPrestId2: LVLPREST_ACT2_TOMB_KAA_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_TOMB_W, nLevelPrestId2: LVLPREST_ACT2_TOMB_KAA_W, nPickedFile: -1, nDirection: 2 }, + ] + + let pPresetRoom = pLevel.pFirstRoomEx!.pMaze! + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + pPresetRoom = i.pMaze! + if (pPresetRoom.nLevelPrest > 428) { + break + } + } + + let nDirection = 0 + switch (pPresetRoom.nLevelPrest) { + case LVLPREST_ACT2_TOMB_PREV_NSW: + nDirection = 0 + break + case LVLPREST_ACT2_TOMB_PREV_NEW: + nDirection = 1 + break + case LVLPREST_ACT2_TOMB_PREV_NSE: + nDirection = 2 + break + case LVLPREST_ACT2_TOMB_PREV_SEW: + nDirection = 3 + break + default: + throw new Error("sPlaceTombStuff() - Why isn't the first room the preset warp room?") + } + + if (pLevel.nLevelId >= LEVEL_STONYTOMBLEV1 && pLevel.nLevelId <= LEVEL_CLAWVIPERTEMPLELEV1) { + const pMazeLevelIds = nAct2TombNextIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i) && DRLGMAZE_InitRoomFixedPreset(i, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, true)) { + break + } + } + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_HALLSOFTHEDEADLEV2) { + const pMazeLevelIds = nAct2TombWaypointIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_STONYTOMBLEV2 || pLevel.nLevelId === LEVEL_CLAWVIPERTEMPLELEV2) { + const pMazeLevelIds = nAct2TombChestIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId >= LEVEL_TALRASHASTOMB1 && pLevel.nLevelId <= LEVEL_TALRASHASTOMB7 && pLevel.nLevelId !== pLevel.pDrlg.nStaffTombLevel) { + const pMazeLevelIds = nAct2TombChestIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_STONYTOMBLEV2) { + const pMazeLevelIds = nAct2TombLeatherarmIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_HALLSOFTHEDEADLEV3) { + const pMazeLevelIds = nAct2TombCubeIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_STONYTOMBLEV2 || pLevel.nLevelId === LEVEL_CLAWVIPERTEMPLELEV2) { + const pMazeLevelIds = nAct2TombTreasureIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === pLevel.pDrlg.nStaffTombLevel) { + const pMazeLevelIds = nAct2TombTalRashaIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === pLevel.pDrlg.nBossTombLevel) { + nDirection = DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct2TombKaaIds[nDirection]!, nDirection) + } +} + //D2Common.0x6FD7BC40 export function DRLGMAZE_InitRoomFixedPreset( pDrlgRoom: D2DrlgRoomStrc, @@ -1199,6 +2092,432 @@ export function DRLGMAZE_InitRoomFixedPreset( } } +//D2Common.0x6FD7BCD0 +export function DRLGMAZE_PlaceAct2LairStuff(pLevel: D2DrlgLevelStrc): void { + const nAct2LairPrevIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_LAIR_N, nLevelPrestId2: LVLPREST_ACT2_LAIR_PREV_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_E, nLevelPrestId2: LVLPREST_ACT2_LAIR_PREV_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_S, nLevelPrestId2: LVLPREST_ACT2_LAIR_PREV_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_W, nLevelPrestId2: LVLPREST_ACT2_LAIR_PREV_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct2LairSpecialIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT2_LAIR_N, nLevelPrestId2: LVLPREST_ACT2_LAIR_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_E, nLevelPrestId2: LVLPREST_ACT2_LAIR_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_S, nLevelPrestId2: LVLPREST_ACT2_LAIR_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_W, nLevelPrestId2: LVLPREST_ACT2_LAIR_NEXT_W, nPickedFile: -1, nDirection: 2 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_N, nLevelPrestId2: LVLPREST_ACT2_LAIR_TREASURE_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_E, nLevelPrestId2: LVLPREST_ACT2_LAIR_TREASURE_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_S, nLevelPrestId2: LVLPREST_ACT2_LAIR_TREASURE_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_W, nLevelPrestId2: LVLPREST_ACT2_LAIR_TREASURE_W, nPickedFile: -1, nDirection: 2 }, + { nLevelPrestId1: LVLPREST_ACT2_LAIR_S, nLevelPrestId2: LVLPREST_ACT2_LAIR_TIGHT_SPOT_S, nPickedFile: -1, nDirection: 1 }, + ] + + const nRand = SEED_RollRandomNumber(pLevel.pSeed) & 3 + let nIndex = 0 + + if (pLevel.nLevelId === LEVEL_MAGGOTLAIRLEV3) { + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === LVLPREST_ACT2_LAIR_S) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, LVLPREST_ACT2_LAIR_TIGHT_SPOT_S, -1, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && DRLGMAZE_InitRoomFixedPreset(pDrlgRoom, 1, LVLPREST_ACT2_LAIR_TIGHT_SPOT_S, -1, true)) { + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + } + + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === LVLPREST_ACT2_LAIR_W) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, LVLPREST_ACT2_LAIR_TREASURE_W, -1, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, 2, LVLPREST_ACT2_LAIR_TREASURE_W, -1) + } + + nIndex = 0 + } else { + const pMazeLevelIds = nAct2LairSpecialIds[nRand]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + nIndex = (nRand + 1) % 4 + } + + const pMazeLevelIds = nAct2LairPrevIds[nIndex]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } +} + +//D2Common.0x6FD7BE60 +export function DRLGMAZE_PlaceAct3DungeonStuff(pLevel: D2DrlgLevelStrc): void { + const nAct3DungeonPrevIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_N, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_PREV_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_E, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_PREV_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_S, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_PREV_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_W, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_PREV_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct3DungeonNextIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_N, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_E, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_S, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_DUNGEON_W, nLevelPrestId2: LVLPREST_ACT3_DUNGEON_NEXT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nDirection = SEED_RollRandomNumber(pLevel.pSeed) & 3 + + let pMazeLevelIds = nAct3DungeonPrevIds[nDirection]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i)) { + pDrlgRoom = DRLGMAZE_PlaceAdjacentPresetRoom(i, pMazeLevelIds.nDirection, false) + if (pDrlgRoom) { + DRLGMAZE_PickRoomPreset(i, true) + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + } + } + } + + pMazeLevelIds = nAct3DungeonNextIds[(nDirection + 1) % 4]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i)) { + pDrlgRoom = DRLGMAZE_PlaceAdjacentPresetRoom(i, pMazeLevelIds.nDirection, false) + if (pDrlgRoom) { + DRLGMAZE_PickRoomPreset(i, true) + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + } + } + } +} + +//D2Common.0x6FD7C000 +export function DRLGMAZE_PlaceAct3SewerStuff(pLevel: D2DrlgLevelStrc): void { + const nAct3SewerDrainIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_SEWER_N, nLevelPrestId2: LVLPREST_ACT3_SEWER_DRAIN_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_SEWER_E, nLevelPrestId2: LVLPREST_ACT3_SEWER_DRAIN_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_SEWER_S, nLevelPrestId2: LVLPREST_ACT3_SEWER_DRAIN_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_SEWER_W, nLevelPrestId2: LVLPREST_ACT3_SEWER_DRAIN_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct3SewerChestIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_SEWER_N, nLevelPrestId2: LVLPREST_ACT3_SEWER_CHEST_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_SEWER_E, nLevelPrestId2: LVLPREST_ACT3_SEWER_CHEST_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_SEWER_S, nLevelPrestId2: LVLPREST_ACT3_SEWER_CHEST_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_SEWER_W, nLevelPrestId2: LVLPREST_ACT3_SEWER_CHEST_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nDirection = SEED_RollRandomNumber(pLevel.pSeed) & 3 + + let pMazeLevelIds = nAct3SewerDrainIds[nDirection]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i)) { + const pTemp = DRLGMAZE_PlaceAdjacentPresetRoom(i, pMazeLevelIds.nDirection, false) + if (pTemp) { + DRLGMAZE_PickRoomPreset(i, true) + DRLGMAZE_SetPickedFileAndPresetId(pTemp, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + } + } + } + + pMazeLevelIds = nAct3SewerChestIds[(nDirection + 1) % 4]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i)) { + const pTemp = DRLGMAZE_PlaceAdjacentPresetRoom(i, pMazeLevelIds.nDirection, false) + if (pTemp) { + DRLGMAZE_PickRoomPreset(i, true) + DRLGMAZE_SetPickedFileAndPresetId(pTemp, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + } + } + } +} + +//D2Common.0x6FD7C1A0 +export function DRLGMAZE_PlaceAct3MephistoStuff(pLevel: D2DrlgLevelStrc): void { + const nAct3MephistoPrevIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_N, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_PREV_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_E, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_PREV_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_S, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_PREV_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_W, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_PREV_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct3MephistoWaypointIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_N, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_WAYPOINT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_E, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_WAYPOINT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_S, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_WAYPOINT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_W, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_WAYPOINT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct3MephistoNextIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_N, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_E, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_S, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT3_MEPHISTO_W, nLevelPrestId2: LVLPREST_ACT3_MEPHISTO_NEXT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nRand = SEED_RollRandomNumber(pLevel.pSeed) & 3 + + let pMazeLevelIds = nAct3MephistoPrevIds[nRand]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i) && DRLGMAZE_InitRoomFixedPreset(i, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, true)) { + break + } + } + } + + let v9 = (nRand + 1) % 4 + if (pLevel.nLevelId === LEVEL_DURANCEOFHATELEV2) { + pMazeLevelIds = nAct3MephistoWaypointIds[v9]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + const v14 = (v9 + 1) % 4 + pMazeLevelIds = nAct3MephistoNextIds[v14]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + v9 = (v14 + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_DURANCEOFHATELEV1) { + pMazeLevelIds = nAct3MephistoNextIds[v9]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + } +} + +//D2Common.0x6FD7C380 +export function DRLGMAZE_PlaceAct5TempleStuff(pLevel: D2DrlgLevelStrc): void { + const nAct5TempleSpecialIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_NE, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_NE_DOWN, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_NW, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_NW_DOWN, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_SW, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_SW_DOWN, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_NE, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_NE_WAYPOINT, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_NW, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_NW_WAYPOINT, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_SW, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_SW_WAYPOINT, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_TEMPLE_SE_UP, nLevelPrestId2: LVLPREST_ACT5_TEMPLE_SE_WAYPOINT, nPickedFile: -1, nDirection: 2 }, + ] + + let nDirection = (SEED_RollRandomNumber(pLevel.pSeed) >>> 0) % 3 + + if (pLevel.nLevelId !== LEVEL_HALLSOFVAUGHT) { + const pMazeLevelIds = nAct5TempleSpecialIds[nDirection]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i) && DRLGMAZE_InitRoomFixedPreset(i, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, true)) { + break + } + } + } + + nDirection = (nDirection + 1) % 4 + } + + if (pLevel.nLevelId === LEVEL_HALLSOFDEATHSCALLING) { + const pMazeLevelIds = nAct5TempleSpecialIds[nDirection + 3]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i) && DRLGMAZE_InitRoomFixedPreset(i, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, true)) { + break + } + } + } + } +} + +//D2Common.0x6FD7C500 +export function DRLGMAZE_PlaceAct5BaalStuff(pLevel: D2DrlgLevelStrc): void { + const nAct5BaalNextIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_BAAL_N, nLevelPrestId2: LVLPREST_ACT5_BAAL_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_BAAL_E, nLevelPrestId2: LVLPREST_ACT5_BAAL_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_BAAL_S, nLevelPrestId2: LVLPREST_ACT5_BAAL_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_BAAL_W, nLevelPrestId2: LVLPREST_ACT5_BAAL_NEXT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct5BaalWaypointIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_BAAL_N, nLevelPrestId2: LVLPREST_ACT5_BAAL_WAYPOINT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_BAAL_E, nLevelPrestId2: LVLPREST_ACT5_BAAL_WAYPOINT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_BAAL_S, nLevelPrestId2: LVLPREST_ACT5_BAAL_WAYPOINT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_BAAL_W, nLevelPrestId2: LVLPREST_ACT5_BAAL_WAYPOINT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nDirection = SEED_RollRandomNumber(pLevel.pSeed) & 3 + let pMazeLevelIds = nAct5BaalNextIds[nDirection]! + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i) && DRLGMAZE_InitRoomFixedPreset(i, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, true)) { + break + } + } + } + + if (pLevel.nLevelId === LEVEL_THEWORLDSTONEKEEPLEV2) { + pMazeLevelIds = nAct5BaalWaypointIds[(nDirection + 1) % 4]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + pDrlgRoom.pMaze!.nPickedFile = pMazeLevelIds.nPickedFile + pDrlgRoom.pMaze!.nLevelPrest = pMazeLevelIds.nLevelPrestId2 + pDrlgRoom.pMaze!.dwFlags |= DRLGPRESETROOMFLAG_HAS_MAP_DS1 + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + if (!DRLGMAZE_HasMapDS1(i) && DRLGMAZE_InitRoomFixedPreset(i, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, true)) { + break + } + } + } + } +} + //D2Common.0x6FD7C660 export function DRLGMAZE_PlaceAct1Barracks(pLevel: D2DrlgLevelStrc): void { const pfGetBarracksEntryRoom = [ @@ -1330,6 +2649,172 @@ export function DRLGMAZE_SetRoomSize(pDrlgRoom: D2DrlgRoomStrc): void { pDrlgRoom.pDrlgCoord.nHeight = pDrlgRoom.pLevel.pMaze!.dwSizeY } +//D2Common.0x6FD7CA40 +export function DRLGMAZE_PlaceAct4Lava(pLevel: D2DrlgLevelStrc): void { + const nAct4LavaForgeIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT4_LAVA_W, nLevelPrestId2: LVLPREST_ACT4_LAVA_FORGE_W, nPickedFile: -1, nDirection: 2 }, + { nLevelPrestId1: LVLPREST_ACT4_LAVA_E, nLevelPrestId2: LVLPREST_ACT4_LAVA_FORGE_E, nPickedFile: -1, nDirection: 0 }, + ] + + if (pLevel.pDrlg.nAct !== ACT_V) { + const pChaosSanctum = DRLG_GetLevel(pLevel.pDrlg, LEVEL_CHAOSSANCTUM) + let pParentRoom = DRLGMAZE_GetFreeLocationForRoomNorth(pLevel)! + + const pNewRoomEx = DRLGROOM_AllocRoomEx(pParentRoom.pLevel, DRLGTYPE_PRESET) + DRLGMAZE_SetRoomSize(pNewRoomEx) + + if (DRLGMAZE_LinkMazeRooms(pNewRoomEx, pParentRoom, ALTDIR_SOUTH)) { + DRLGROOM_AllocDrlgOrthsForRooms(pParentRoom, pNewRoomEx, ALTDIR_SOUTH) + DRLGROOM_AddRoomExToLevel(pParentRoom.pLevel, pNewRoomEx) + if (pNewRoomEx) { + DRLGMAZE_PickRoomPreset(pParentRoom, true) + DRLGMAZE_SetPickedFileAndPresetId(pNewRoomEx, LVLPREST_ACT4_LAVA_WARP_N, -1, false) + } + } else { + DRLGROOM_FreeRoomEx(pNewRoomEx) + } + + pParentRoom = DRLGMAZE_GetFreeLocationForRoomSouth(pLevel)! + + const pBridgeRoomEx1 = DRLGMAZE_PlaceAdjacentPresetRoom(pParentRoom, ALTDIR_NORTH, false) + if (pBridgeRoomEx1) { + DRLGMAZE_PickRoomPreset(pParentRoom, true) + DRLGMAZE_SetPickedFileAndPresetId(pBridgeRoomEx1, LVLPREST_ACT4_BRIDGE_1, -1, false) + } + + const pBridgeRoomEx2 = DRLGMAZE_PlaceAdjacentPresetRoom(pBridgeRoomEx1!, ALTDIR_NORTH, false) + if (pBridgeRoomEx2) { + DRLGMAZE_SetPickedFileAndPresetId(pBridgeRoomEx2, LVLPREST_ACT4_BRIDGE_2, -1, false) + } + + const pBridgeRoomEx3 = DRLGMAZE_PlaceAdjacentPresetRoom(pBridgeRoomEx2!, ALTDIR_NORTH, false)! + if (pBridgeRoomEx3) { + DRLGMAZE_SetPickedFileAndPresetId(pBridgeRoomEx3, LVLPREST_ACT4_BRIDGE_2, -1, false) + } + + pBridgeRoomEx3.pDrlgOrth = DRLGROOM_AddOrth(pBridgeRoomEx3.pDrlgOrth, pChaosSanctum, ALTDIR_NORTH, false) + + const nX = pChaosSanctum.pLevelCoords.nPosX + 2 * pBridgeRoomEx3.pDrlgCoord.nWidth - pBridgeRoomEx3.pDrlgCoord.nPosX + const nY = pChaosSanctum.pLevelCoords.nPosY + pChaosSanctum.pLevelCoords.nHeight - pBridgeRoomEx3.pDrlgCoord.nPosY + + DRLGMAZE_ScanReplaceSpecialPreset(pLevel, nAct4LavaForgeIds[SEED_RollRandomNumber(pLevel.pSeed) & 1]!, null) + DRLGMAZE_FillBlankMazeSpaces(pLevel, LVLPREST_ACT4_LAVA_X, pBridgeRoomEx3) + + for (let i = pLevel.pFirstRoomEx; i; i = i.pDrlgRoomNext) { + i.pDrlgCoord.nPosX += nX + i.pDrlgCoord.nPosY += nY + } + + const { nTileMinX, nTileMinY, nTileMaxX, nTileMaxY } = DRLG_GetMinAndMaxCoordinatesFromLevel(pLevel) + pLevel.pLevelCoords.nPosX = nTileMinX + pLevel.pLevelCoords.nPosY = nTileMinY + pLevel.pLevelCoords.nWidth = nTileMaxX - nTileMinX + pLevel.pLevelCoords.nHeight = nTileMaxY - nTileMinY + } +} + +//D2Common.0x6FD7CCB0 +export function DRLGMAZE_PlaceAct5IceStuff(pLevel: D2DrlgLevelStrc): void { + const nAct5IcePrevIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_ICE_N, nLevelPrestId2: LVLPREST_ACT5_ICE_PREV_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_E, nLevelPrestId2: LVLPREST_ACT5_ICE_PREV_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_S, nLevelPrestId2: LVLPREST_ACT5_ICE_PREV_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_W, nLevelPrestId2: LVLPREST_ACT5_ICE_PREV_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct5IceNextIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_ICE_N, nLevelPrestId2: LVLPREST_ACT5_ICE_NEXT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_E, nLevelPrestId2: LVLPREST_ACT5_ICE_NEXT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_S, nLevelPrestId2: LVLPREST_ACT5_ICE_NEXT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_W, nLevelPrestId2: LVLPREST_ACT5_ICE_NEXT_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct5IceDownIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_ICE_N, nLevelPrestId2: LVLPREST_ACT5_ICE_DOWN_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_E, nLevelPrestId2: LVLPREST_ACT5_ICE_DOWN_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_S, nLevelPrestId2: LVLPREST_ACT5_ICE_DOWN_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_W, nLevelPrestId2: LVLPREST_ACT5_ICE_DOWN_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct5IceThemeIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_ICE_N, nLevelPrestId2: LVLPREST_ACT5_ICE_THEME_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_E, nLevelPrestId2: LVLPREST_ACT5_ICE_THEME_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_S, nLevelPrestId2: LVLPREST_ACT5_ICE_THEME_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_W, nLevelPrestId2: LVLPREST_ACT5_ICE_THEME_W, nPickedFile: -1, nDirection: 2 }, + ] + + const nAct5IceWaypointIds: readonly D2MazeLevelIdStrc[] = [ + { nLevelPrestId1: LVLPREST_ACT5_ICE_N, nLevelPrestId2: LVLPREST_ACT5_ICE_WAYPOINT_N, nPickedFile: -1, nDirection: 3 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_E, nLevelPrestId2: LVLPREST_ACT5_ICE_WAYPOINT_E, nPickedFile: -1, nDirection: 0 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_S, nLevelPrestId2: LVLPREST_ACT5_ICE_WAYPOINT_S, nPickedFile: -1, nDirection: 1 }, + { nLevelPrestId1: LVLPREST_ACT5_ICE_W, nLevelPrestId2: LVLPREST_ACT5_ICE_WAYPOINT_W, nPickedFile: -1, nDirection: 2 }, + ] + + let nDirection = SEED_RollRandomNumber(pLevel.pSeed) & 3 + let pMazeLevelIds = nAct5IcePrevIds[nDirection]! + + let pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + nDirection = (nDirection + 1) % 4 + pMazeLevelIds = nAct5IceNextIds[nDirection]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + nDirection = (nDirection + 1) % 4 + pMazeLevelIds = nAct5IceDownIds[nDirection]! + pDrlgRoom = pLevel.pFirstRoomEx + while (pDrlgRoom) { + if (!DRLGMAZE_HasMapDS1(pDrlgRoom) && pDrlgRoom.pMaze!.nLevelPrest === pMazeLevelIds.nLevelPrestId1) { + DRLGMAZE_SetPickedFileAndPresetId(pDrlgRoom, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false) + break + } + pDrlgRoom = pDrlgRoom.pDrlgRoomNext + } + + if (!pDrlgRoom) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + + nDirection = (nDirection + 1) % 4 + if (pLevel.nLevelId !== LEVEL_CRYSTALIZEDCAVERNLEV1) { + if (pLevel.nLevelId === LEVEL_CRYSTALIZEDCAVERNLEV2) { + pMazeLevelIds = nAct5IceThemeIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } + nDirection = (nDirection + 1) % 4 + } else if (pLevel.nLevelId !== LEVEL_GLACIALCAVESLEV1) { + return + } + } + + pMazeLevelIds = nAct5IceWaypointIds[nDirection]! + if (!DRLGMAZE_ReplaceRoomPreset(pLevel, pMazeLevelIds.nLevelPrestId1, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile, false)) { + DRLGMAZE_AddSpecialPreset(pLevel, pMazeLevelIds.nDirection, pMazeLevelIds.nLevelPrestId2, pMazeLevelIds.nPickedFile) + } +} + //D2Common.0x6FD7CEA0 export function DRLGMAZE_RollAct_1_2_3_BasicPresets(pLevel: D2DrlgLevelStrc): void { const pOffsets = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] diff --git a/src/game/drlg/drlg-outdesr.ts b/src/game/drlg/drlg-outdesr.ts new file mode 100644 index 0000000..fd4358c --- /dev/null +++ b/src/game/drlg/drlg-outdesr.ts @@ -0,0 +1,413 @@ +/** + * D2Common Act II outdoor desert DRLG: 1:1 port of D2MOO + * `source/D2Common/src/Drlg/DrlgOutDesr.cpp`. + */ + +import { SEED_RollLimitedRandomNumber, SEED_RollRandomNumber } from '../d2-rng.ts' +import { + LVLPREST_ACT2_DESERT_BORDER_1, + LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, + LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_ENDS, + LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, + LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, + LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, + LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, + LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_ENDS, + LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, + LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, + LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, + LVLPREST_ACT2_DESERT_CLIFF_TOP, + LVLPREST_ACT2_DESERT_CLIFF_TOP_KING_TOMB, + LVLPREST_ACT2_DESERT_FILL_BERMS_1, + LVLPREST_ACT2_DESERT_FILL_BERMS_2, + LVLPREST_ACT2_DESERT_FILL_BERMS_3, + LVLPREST_ACT2_DESERT_FILL_BERMS_4, + LVLPREST_ACT2_DESERT_FILL_BONE_1, + LVLPREST_ACT2_DESERT_FILL_BONE_2, + LVLPREST_ACT2_DESERT_FILL_HEAD_1, + LVLPREST_ACT2_DESERT_FILL_HEAD_2, + LVLPREST_ACT2_DESERT_FILL_MESA_1, + LVLPREST_ACT2_DESERT_FILL_WAGON_1, + LVLPREST_ACT2_DESERT_LAIR_1, + LVLPREST_ACT2_DESERT_OASIS_1, + LVLPREST_ACT2_DESERT_OASIS_2, + LVLPREST_ACT2_DESERT_OASIS_3, + LVLPREST_ACT2_DESERT_RUINS_08X08, + LVLPREST_ACT2_DESERT_RUINS_08X16, + LVLPREST_ACT2_DESERT_RUINS_16X08, + LVLPREST_ACT2_DESERT_RUINS_16X16, + LVLPREST_ACT2_DESERT_RUINS_ELDER, + LVLPREST_ACT2_DESERT_RUINS_SEWER, + LVLPREST_ACT2_DESERT_TOMB_1, + LVLPREST_ACT2_DESERT_TOMB_2, + LVLPREST_ACT2_DESERT_TRANSITION_N, + LVLPREST_ACT2_DESERT_TRANSITION_W, + LVLPREST_ACT2_DESERT_VALLEY_RUIN_1, + LVLPREST_ACT2_DESERT_VALLEY_RUIN_2, + LVLPREST_ACT2_DESERT_VALLEY_WARP, +} from './drlg-ids.ts' +import { + DRLGOUTDOORS_AddAct124SecondaryBorder, + DRLGOUTDOORS_SpawnAct12Shrines, + DRLGOUTDOORS_SpawnAct12Waypoint, + DRLGOUTDOORS_SpawnOutdoorLevelPreset, + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx, +} from './drlg-outdoors.ts' +import { DRLGOUTPLACE_PlaceAct1245OutdoorBorders, DRLGOUTPLACE_SetOutGridLinkFlags } from './drlg-outplace.ts' +import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts' +import { + type D2DrlgLevelStrc, + LEVEL_CANYONOFTHEMAGI, + LEVEL_DRYHILLS, + LEVEL_FAROASIS, + LEVEL_LOSTCITY, + LEVEL_LUTGHOLEIN, + LEVEL_PANDEMONIUMRUN2, + LEVEL_ROCKYWASTE, + LEVEL_VALLEYOFSNAKES, +} from './drlg-types.ts' + +interface D2DrlgOutDesertInitStrc { + readonly nLvlPrestId: number + readonly nRand: number + readonly nX: number + readonly nY: number +} + +// Inlined in D2Common.0x6FD7D430 +export function DRLGOUTDESR_PlaceDesertTransitionToTown(pLevel: D2DrlgLevelStrc): void { + let pTownRoomData = pLevel.pOutdoors!.pRoomData + + while (pTownRoomData && pTownRoomData.pLevel!.nLevelId !== LEVEL_LUTGHOLEIN) { + pTownRoomData = pTownRoomData.pNext + } + + if (pTownRoomData) { + if (pTownRoomData.nDirection === 3) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + 0, + pLevel.pOutdoors!.nGridHeight - 1, + LVLPREST_ACT2_DESERT_TRANSITION_N, + -1, + false, + ) + } else { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + pLevel.pOutdoors!.nGridWidth - 1, + 0, + LVLPREST_ACT2_DESERT_TRANSITION_W, + -1, + false, + ) + } + } +} + +// 1.10f: D2Common.0x6FD7D430 +// 1.11 : D2Common.0x6FDD58A0 +export function DRLGOUTDESR_InitAct2OutdoorLevel(pLevel: D2DrlgLevelStrc): void { + const nLevelPrestIds1: readonly number[] = [ + LVLPREST_ACT2_DESERT_OASIS_1, + LVLPREST_ACT2_DESERT_RUINS_08X08, + LVLPREST_ACT2_DESERT_FILL_BONE_1, + LVLPREST_ACT2_DESERT_FILL_BONE_2, + LVLPREST_ACT2_DESERT_FILL_HEAD_1, + LVLPREST_ACT2_DESERT_FILL_MESA_1, + LVLPREST_ACT2_DESERT_FILL_WAGON_1, + ] + const nLevelPrestIds2: readonly number[] = [ + LVLPREST_ACT2_DESERT_OASIS_1, + LVLPREST_ACT2_DESERT_RUINS_08X08, + LVLPREST_ACT2_DESERT_FILL_HEAD_2, + LVLPREST_ACT2_DESERT_FILL_MESA_1, + ] + const nLevelPrestIds3: readonly number[] = [ + LVLPREST_ACT2_DESERT_FILL_BERMS_1, + LVLPREST_ACT2_DESERT_FILL_BERMS_2, + LVLPREST_ACT2_DESERT_FILL_BERMS_3, + LVLPREST_ACT2_DESERT_FILL_BERMS_4, + ] + const nLevelPrestIds4: readonly number[] = [LVLPREST_ACT2_DESERT_OASIS_2, LVLPREST_ACT2_DESERT_OASIS_3] + + DRLGOUTPLACE_SetOutGridLinkFlags(pLevel) + DRLGOUTPLACE_PlaceAct1245OutdoorBorders(pLevel) + + switch (pLevel.nLevelId) { + case LEVEL_ROCKYWASTE: + DRLGOUTDESR_PlaceDesertTransitionToTown(pLevel) + DRLGOUTDESR_PlaceBorders(pLevel) + DRLGOUTDESR_AddExits(pLevel) + DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false) + break + + case LEVEL_DRYHILLS: + DRLGOUTDESR_PlaceCliffs(pLevel) + DRLGOUTDESR_PlaceBorders(pLevel) + DRLGOUTDESR_AddExits(pLevel) + DRLGOUTDOORS_SpawnAct12Waypoint(pLevel) + DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, false) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds3, nLevelPrestIds3.length, true) + break + + case LEVEL_FAROASIS: + DRLGOUTDESR_PlaceCliffs(pLevel) + DRLGOUTDESR_PlaceBorders(pLevel) + DRLGOUTDESR_AddExits(pLevel) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds4, nLevelPrestIds4.length, false) + DRLGOUTDOORS_SpawnAct12Waypoint(pLevel) + DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5) + DRLGOUTDESR_PlaceFillsInFarOasis(pLevel) + break + + case LEVEL_LOSTCITY: + DRLGOUTDESR_PlaceCliffs(pLevel) + DRLGOUTDESR_PlaceBorders(pLevel) + DRLGOUTDESR_AddExits(pLevel) + DRLGOUTDESR_PlaceRuinsInLostCity(pLevel) + DRLGOUTDOORS_SpawnAct12Waypoint(pLevel) + DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5) + DRLGOUTDESR_PlaceFillsInLostCity(pLevel) + break + + case LEVEL_VALLEYOFSNAKES: + DRLGOUTDESR_AddExits(pLevel) + break + + case LEVEL_CANYONOFTHEMAGI: + DRLGOUTDESR_PlaceTombEntriesInCanyon(pLevel) + DRLGOUTDESR_PlaceBorders(pLevel) + DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5) + DRLGOUTDESR_PlaceFillsInCanyon(pLevel) + break + + case LEVEL_PANDEMONIUMRUN2: + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 4, 4, LVLPREST_ACT2_DESERT_VALLEY_WARP, -1, false) + DRLGOUTDESR_PlaceBorders(pLevel) + DRLGOUTDESR_PlaceFillsInCanyon(pLevel) + DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5) + break + + default: + return + } +} + +// D2Common.0x6FD7D870 +export function DRLGOUTDESR_PlacePresetVariants( + pLevel: D2DrlgLevelStrc, + pLevelPrestIds: readonly number[], + nVariants: number, + bIterateFiles: boolean, +): void { + let nRand = SEED_RollLimitedRandomNumber(pLevel.pSeed, nVariants) >>> 0 + + for (let i = 0; i < nVariants; i += 1) { + const nPrestId = pLevelPrestIds[nRand]! + if (bIterateFiles) { + const dwFiles = DATATBLS_GetLvlPrestTxtRecord(pLevel.pDrlg.env.tables, nPrestId).dwFiles >>> 0 + for (let nFile = 0; nFile < dwFiles; nFile += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nPrestId, nFile, 0, 15) + } + } else { + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nPrestId, -1, 0, 15) + } + + nRand = (nRand + 1) % nVariants + } +} + +const pOutDesertCliffsInit: readonly (readonly D2DrlgOutDesertInitStrc[])[] = [ + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 1, nX: 0, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 2, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 6, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 1, nX: 0, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 2, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 6, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 1, nX: 0, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 2, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 6, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 6, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_TOP, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 6 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 6, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_TOP, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 6 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 1, nX: 4, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 2 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 6 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 1, nX: 4, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 2 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 6 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 }, + ], + [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 1, nX: 4, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 2 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 6 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 }, + ], +] + +// D2Common.0x6FD7D950 +export function DRLGOUTDESR_PlaceCliffs(pLevel: D2DrlgLevelStrc): void { + const nRand = SEED_RollRandomNumber(pLevel.pSeed) & 7 + const row = pOutDesertCliffsInit[nRand]! + + for (let i = 0; i < 5; i += 1) { + const entry = row[i]! + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, entry.nX, entry.nY, entry.nLvlPrestId, entry.nRand, false) + } +} + +// D2Common.0x6FD7D9B0 +export function DRLGOUTDESR_PlaceBorders(pLevel: D2DrlgLevelStrc): void { + DRLGOUTDOORS_AddAct124SecondaryBorder(pLevel, 2, LVLPREST_ACT2_DESERT_BORDER_1) + DRLGOUTDOORS_AddAct124SecondaryBorder(pLevel, 1, LVLPREST_ACT2_DESERT_BORDER_1) + DRLGOUTDOORS_AddAct124SecondaryBorder(pLevel, 3, LVLPREST_ACT2_DESERT_BORDER_1) +} + +// D2Common.0x6FD7D9F0 +export function DRLGOUTDESR_AddExits(pLevel: D2DrlgLevelStrc): void { + let nLevelPrestId = 0 + + switch (pLevel.nLevelId) { + case LEVEL_ROCKYWASTE: + case LEVEL_DRYHILLS: + nLevelPrestId = LVLPREST_ACT2_DESERT_TOMB_1 + break + + case LEVEL_FAROASIS: + nLevelPrestId = LVLPREST_ACT2_DESERT_LAIR_1 + break + + case LEVEL_LOSTCITY: + nLevelPrestId = LVLPREST_ACT2_DESERT_RUINS_SEWER + break + + case LEVEL_VALLEYOFSNAKES: + nLevelPrestId = LVLPREST_ACT2_DESERT_TOMB_2 + break + + default: + throw new Error('sAddTheExitsAndStuff() - Could not add the exit!') + } + + if (!DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nLevelPrestId, -1, 0, 15)) { + throw new Error('sAddTheExitsAndStuff() - Could not add the exit!') + } +} + +// D2Common.0x6FD7DA60 +export function DRLGOUTDESR_PlaceFillsInFarOasis(pLevel: D2DrlgLevelStrc): void { + const nLevelPrestIds1: readonly number[] = [ + LVLPREST_ACT2_DESERT_RUINS_08X08, + LVLPREST_ACT2_DESERT_FILL_HEAD_1, + LVLPREST_ACT2_DESERT_FILL_MESA_1, + LVLPREST_ACT2_DESERT_FILL_WAGON_1, + ] + const nLevelPrestIds2: readonly number[] = [LVLPREST_ACT2_DESERT_OASIS_1] + + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true) +} + +// D2Common.0x6FD7DAC0 +export function DRLGOUTDESR_PlaceRuinsInLostCity(pLevel: D2DrlgLevelStrc): void { + const nLevelPrestIds: readonly number[] = [ + LVLPREST_ACT2_DESERT_RUINS_ELDER, + LVLPREST_ACT2_DESERT_RUINS_16X16, + LVLPREST_ACT2_DESERT_RUINS_16X08, + LVLPREST_ACT2_DESERT_RUINS_08X16, + ] + + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds, nLevelPrestIds.length, false) +} + +// D2Common.0x6FD7DB00 +export function DRLGOUTDESR_PlaceFillsInLostCity(pLevel: D2DrlgLevelStrc): void { + const nLevelPrestIds1: readonly number[] = [ + LVLPREST_ACT2_DESERT_OASIS_1, + LVLPREST_ACT2_DESERT_FILL_HEAD_2, + LVLPREST_ACT2_DESERT_FILL_MESA_1, + LVLPREST_ACT2_DESERT_FILL_BERMS_1, + LVLPREST_ACT2_DESERT_FILL_BERMS_2, + ] + const nLevelPrestIds2: readonly number[] = [LVLPREST_ACT2_DESERT_RUINS_08X08] + + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true) +} + +const pOutDesertCanyonInit: readonly D2DrlgOutDesertInitStrc[] = [ + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_ENDS, nRand: 0, nX: 8, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, nRand: 2, nX: 6, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, nRand: 1, nX: 4, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, nRand: 0, nX: 2, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_TOP_KING_TOMB, nRand: 0, nX: 0, nY: 0 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, nRand: 0, nX: 0, nY: 2 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, nRand: 1, nX: 0, nY: 4 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, nRand: 2, nX: 0, nY: 6 }, + { nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_ENDS, nRand: 0, nX: 0, nY: 8 }, +] + +// D2Common.0x6FD7DB70 +export function DRLGOUTDESR_PlaceTombEntriesInCanyon(pLevel: D2DrlgLevelStrc): void { + for (let i = 0; i < pOutDesertCanyonInit.length; i += 1) { + const entry = pOutDesertCanyonInit[i]! + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, entry.nX, entry.nY, entry.nLvlPrestId, entry.nRand, false) + } + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 4, 4, LVLPREST_ACT2_DESERT_VALLEY_WARP, -1, false) +} + +// 1.10f: D2Common.0x6FD7DBC0 +// 1.11 : D2Common.0x6FDD5600 +export function DRLGOUTDESR_PlaceFillsInCanyon(pLevel: D2DrlgLevelStrc): void { + const nLevelPrestIds1: readonly number[] = [ + LVLPREST_ACT2_DESERT_FILL_BONE_1, + LVLPREST_ACT2_DESERT_FILL_BONE_2, + LVLPREST_ACT2_DESERT_FILL_BERMS_3, + LVLPREST_ACT2_DESERT_FILL_BERMS_4, + LVLPREST_ACT2_DESERT_FILL_WAGON_1, + ] + const nLevelPrestIds2: readonly number[] = [ + LVLPREST_ACT2_DESERT_VALLEY_RUIN_1, + LVLPREST_ACT2_DESERT_VALLEY_RUIN_2, + ] + + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false) + DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true) +} diff --git a/src/game/drlg/drlg-outdoors.ts b/src/game/drlg/drlg-outdoors.ts index 1f1dce9..102d256 100644 --- a/src/game/drlg/drlg-outdoors.ts +++ b/src/game/drlg/drlg-outdoors.ts @@ -36,6 +36,7 @@ import { LEVEL_BLOODMOOR, LEVEL_COLDPLAINS, LEVEL_MONASTERYGATE, + LEVEL_PANDEMONIUMRUN2, LEVEL_ROGUEENCAMPMENT, grid2PickedFile, grid2PickedFileBits, @@ -96,13 +97,16 @@ import { DRLGROOM_AreXYInsideCoordinates, DRLGROOM_GetVisArrayFromLevelId } from import { DRLGPRESET_AllocDrlgMap, DRLGPRESET_BuildArea, DRLGPRESET_SetPickedFileInDrlgMap } from './drlg-preset.ts' import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts' import { notifyLevelGrid } from './drlg-hooks.ts' +import { DRLGOUTDESR_InitAct2OutdoorLevel } from './drlg-outdesr.ts' import { DRLGOUTWILD_GetBridgeCoords, DRLGOUTWILD_InitAct1OutdoorLevel } from './drlg-outwild.ts' import { DRLGOUTPLACE_CreateOutdoorRoomEx, + DRLGOUTPLACE_InitAct3OutdoorLevel, DRLGOUTPLACE_PlaceAct1245OutdoorBorders, DRLGOUTPLACE_SetOutGridLinkFlags, sub_6FD80750, } from './drlg-outplace.ts' +import { DRLGOUTSIEGE_InitAct5OutdoorLevel } from './drlg-outsiege.ts' import { DRLGTILESUB_AddSecondaryBorder } from './drlg-tilesub.ts' // D2C_Levels (DataTbls/LevelsIds.h) used only by the Act IV code of this file. @@ -794,34 +798,36 @@ export function DRLGOUTDOORS_GenerateLevel(pLevel: D2DrlgLevelStrc): void { pVertex = pVertex.pNext! } while (pVertex !== pOutdoorInfo.pVertex) - // #ifdef D2_VERSION_HAS_UBERS: LEVEL_PANDEMONIUMRUN2 -> ACT_II. Not compiled in the oracle, which - // builds D2MOO with the default D2_VERSION_110F of D2BuildInformation.h (either way not Act I). - const nAct = DRLG_GetActNoFromLevelId(pLevel.nLevelId) + const nAct = + pLevel.nLevelId === LEVEL_PANDEMONIUMRUN2 + ? ACT_II + : DRLG_GetActNoFromLevelId(pLevel.nLevelId) switch (nAct) { case ACT_I: DRLGOUTWILD_InitAct1OutdoorLevel(pLevel) - // Mirrors the oracle's link-time `--wrap=DRLGOUTWILD_InitAct1OutdoorLevel` - // (tools/d2moo-oracle/src/main.cpp ~line 241), which captures the `levelGrid` stage right after - // the real DRLGOUTWILD_InitAct1OutdoorLevel returns and before any room of the level exists. notifyLevelGrid(pLevel) break case ACT_II: - // DRLGOUTDESR_InitAct2OutdoorLevel(pLevel); - throw new Error('DRLGOUTDOORS_GenerateLevel: act 2 is not ported') + DRLGOUTDESR_InitAct2OutdoorLevel(pLevel) + notifyLevelGrid(pLevel) + break case ACT_III: - // DRLGOUTPLACE_InitAct3OutdoorLevel(pLevel); - throw new Error('DRLGOUTDOORS_GenerateLevel: act 3 is not ported') + DRLGOUTPLACE_InitAct3OutdoorLevel(pLevel) + notifyLevelGrid(pLevel) + break case ACT_IV: - // DRLGOUTDOORS_InitAct4OutdoorLevel(pLevel); (ported below, but the Act IV pipeline is out of scope) - throw new Error('DRLGOUTDOORS_GenerateLevel: act 4 is not ported') + DRLGOUTDOORS_InitAct4OutdoorLevel(pLevel) + notifyLevelGrid(pLevel) + break case ACT_V: - // DRLGOUTSIEGE_InitAct5OutdoorLevel(pLevel); - throw new Error('DRLGOUTDOORS_GenerateLevel: act 5 is not ported') + DRLGOUTSIEGE_InitAct5OutdoorLevel(pLevel) + notifyLevelGrid(pLevel) + break default: break diff --git a/src/game/drlg/drlg-outjung.ts b/src/game/drlg/drlg-outjung.ts new file mode 100644 index 0000000..7accf4d --- /dev/null +++ b/src/game/drlg/drlg-outjung.ts @@ -0,0 +1,280 @@ +/** + * D2Common Act III outdoor jungle/Kurast DRLG: 1:1 port of D2MOO + * `source/D2Common/src/Drlg/DrlgOutJung.cpp`. + */ + +import { SEED_RollLimitedRandomNumber } from '../d2-rng.ts' +import { + LVLPREST_ACT3_BURBS_BORDER_E, + LVLPREST_ACT3_BURBS_BORDER_N, + LVLPREST_ACT3_BURBS_BORDER_NE, + LVLPREST_ACT3_BURBS_BORDER_NW, + LVLPREST_ACT3_BURBS_BORDER_S, + LVLPREST_ACT3_BURBS_BORDER_SE, + LVLPREST_ACT3_BURBS_BORDER_SW, + LVLPREST_ACT3_BURBS_BORDER_W, + LVLPREST_ACT3_JUNGLE_HEAD, + LVLPREST_ACT3_JUNGLE_TAIL, + LVLPREST_ACT3_METRO_BORDER_E, + LVLPREST_ACT3_METRO_BORDER_N, + LVLPREST_ACT3_METRO_BORDER_NE, + LVLPREST_ACT3_METRO_BORDER_NW, + LVLPREST_ACT3_METRO_BORDER_S, + LVLPREST_ACT3_METRO_BORDER_SE, + LVLPREST_ACT3_METRO_BORDER_SW, + LVLPREST_ACT3_METRO_BORDER_W, + LVLPREST_ACT3_SLUMS_BORDER_E, + LVLPREST_ACT3_SLUMS_BORDER_N, + LVLPREST_ACT3_SLUMS_BORDER_NE, + LVLPREST_ACT3_SLUMS_BORDER_NW, + LVLPREST_ACT3_SLUMS_BORDER_S, + LVLPREST_ACT3_SLUMS_BORDER_SE, + LVLPREST_ACT3_SLUMS_BORDER_SW, + LVLPREST_ACT3_SLUMS_BORDER_W, +} from './drlg-ids.ts' +import { DRLGOUTDOORS_SpawnOutdoorLevelPresetEx, DRLGOUTDOORS_TestOutdoorLevelPreset } from './drlg-outdoors.ts' +import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts' +import { type D2DrlgLevelStrc, idiv, imod, LEVEL_FLAYERJUNGLE, LEVEL_SPIDERFOREST } from './drlg-types.ts' + +const dword_6FDCFB18: readonly number[] = [0, 10, 20, 0] + +const dword_6FDCFB28: readonly number[] = [0, 1, 2, 1, 0, 2, 0, 2, 1, 1, 2, 0, 2, 0, 1, 2, 1, 0] + +// D2Common.0x6FD7FC20 +export function DRLGOUTJUNG_BuildJungle(pLevel: D2DrlgLevelStrc): void { + if (pLevel.nLevelId >= LEVEL_SPIDERFOREST && pLevel.nLevelId <= LEVEL_FLAYERJUNGLE) { + const pLevelDefBin = DATATBLS_GetLevelDefRecord(pLevel.pDrlg.env.tables, LEVEL_SPIDERFOREST) + const nSizeX = pLevelDefBin.dwSizeX[pLevel.pDrlg.nDifficulty]! >> 5 + const nSizeY = pLevelDefBin.dwSizeY[pLevel.pDrlg.nDifficulty]! >> 5 + + const nRand = + SEED_RollLimitedRandomNumber(pLevel.pSeed, 4 * (pLevel.nJungleDefs === 3 ? 1 : 0) + 2) | 0 + + const pJungleDefs = pLevel.pJungleDefs! + let nDefId = 0 + let nFileIndex = 0 + + for (let i = 0; i < nSizeY; i += 1) { + if (pLevel.nLevelId === LEVEL_SPIDERFOREST && i === nSizeY - 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + 0, + 4 * i, + LVLPREST_ACT3_JUNGLE_HEAD, + pJungleDefs[nSizeX * nSizeY - 1] === 0 ? 1 : 0, + false, + ) + nDefId += 2 + } else if (pLevel.nLevelId === LEVEL_FLAYERJUNGLE && i === 0) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + 0, + 0, + LVLPREST_ACT3_JUNGLE_TAIL, + pJungleDefs[1] === 0 ? 1 : 0, + false, + ) + nDefId += 2 + } else { + for (let j = 0; j < nSizeX; j += 1) { + nDefId += 1 + let nJungleDef = pJungleDefs[nDefId - 1]! + let v19 = 0 + if (nJungleDef > LVLPREST_ACT3_JUNGLE_TAIL) { + nJungleDef += dword_6FDCFB18[pLevel.nLevelId - 76]! + v19 = dword_6FDCFB28[nFileIndex + 3 * nRand]! + nFileIndex += 1 + } else { + v19 = -1 + } + + if (nJungleDef) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 4 * j, 4 * i, nJungleDef, v19, false) + } + } + } + } + } +} + +// D2Common.0x6FD7FE50 +export function DRLGOUTJUNG_BuildLowerKurast(pLevel: D2DrlgLevelStrc): void { + const nWidth = idiv(pLevel.pLevelCoords.nWidth, 8) - 1 + const nHeight = idiv(pLevel.pLevelCoords.nHeight, 8) - 1 + const v5 = idiv(idiv(pLevel.pLevelCoords.nWidth, 8) - 1, 2) + const v10 = pLevel.pDrlg.bJungleInterlink ? 1 : nWidth - 1 + + for (let i = 1; i < nWidth; i += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + 0, + 8 * (i === v10 ? 1 : 0) + LVLPREST_ACT3_SLUMS_BORDER_N, + -1, + false, + ) + } + + for (let i = 1; i < nWidth; i += (i === v5 ? 1 : 0) + 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + nHeight, + 8 * (i === v5 ? 1 : 0) + LVLPREST_ACT3_SLUMS_BORDER_S, + -1, + false, + ) + } + + for (let i = 1; i < nHeight; i += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, i, LVLPREST_ACT3_SLUMS_BORDER_E, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT3_SLUMS_BORDER_W, -1, false) + } + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_SLUMS_BORDER_NW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, 0, LVLPREST_ACT3_SLUMS_BORDER_NE, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, nHeight, LVLPREST_ACT3_SLUMS_BORDER_SW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, nHeight, LVLPREST_ACT3_SLUMS_BORDER_SE, -1, false) +} + +// D2Common.0x6FD7FFA0 +export function DRLGOUTJUNG_BuildKurastBazaar(pLevel: D2DrlgLevelStrc): void { + const nWidth = idiv(pLevel.pLevelCoords.nWidth, 8) - 1 + const nHeight = idiv(pLevel.pLevelCoords.nHeight, 8) - 1 + let v7 = 0 + let v8 = 0 + + if (pLevel.pDrlg.bJungleInterlink) { + v7 = idiv(pLevel.pLevelCoords.nWidth, 8) - 2 + v8 = 1 + } else { + v7 = 1 + v8 = idiv(pLevel.pLevelCoords.nWidth, 8) - 2 + } + + for (let i = 1; i < nWidth; i += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + 0, + 8 * (i === v7 ? 1 : 0) + LVLPREST_ACT3_BURBS_BORDER_N, + -1, + false, + ) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + nHeight, + 8 * (i === v8 ? 1 : 0) + LVLPREST_ACT3_BURBS_BORDER_S, + -1, + false, + ) + } + + for (let i = 1; i < nHeight; i += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, i, LVLPREST_ACT3_BURBS_BORDER_E, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT3_BURBS_BORDER_W, -1, false) + } + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_BURBS_BORDER_NW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, 0, LVLPREST_ACT3_BURBS_BORDER_NE, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, nHeight, LVLPREST_ACT3_BURBS_BORDER_SW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, nHeight, LVLPREST_ACT3_BURBS_BORDER_SE, -1, false) +} + +// D2Common.0x6FD800E0 +export function DRLGOUTJUNG_BuildUpperKurast(pLevel: D2DrlgLevelStrc): void { + const nWidth = idiv(pLevel.pLevelCoords.nWidth, 8) - 1 + const nHeight = idiv(pLevel.pLevelCoords.nHeight, 8) - 1 + const v8 = pLevel.pDrlg.bJungleInterlink ? nWidth - 1 : 1 + const nHalfWidth = idiv(nWidth, 2) + + for (let i = 1; i < nWidth; i += (i === nHalfWidth ? 1 : 0) + 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + 0, + 8 * (i === nHalfWidth ? 1 : 0) + LVLPREST_ACT3_METRO_BORDER_N, + -1, + false, + ) + } + + for (let i = 1; i < nWidth; i += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + nHeight, + 8 * (i === v8 ? 1 : 0) + LVLPREST_ACT3_METRO_BORDER_S, + -1, + false, + ) + } + + for (let i = 1; i < nHeight; i += 1) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, i, LVLPREST_ACT3_METRO_BORDER_E, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT3_METRO_BORDER_W, -1, false) + } + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_METRO_BORDER_NW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, 0, LVLPREST_ACT3_METRO_BORDER_NE, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, nHeight, LVLPREST_ACT3_METRO_BORDER_SW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, nHeight, LVLPREST_ACT3_METRO_BORDER_SE, -1, false) +} + +// D2Common.0x6FD80230 +export function DRLGOUTJUNG_SpawnRandomPreset( + pLevel: D2DrlgLevelStrc, + nLevelPrestId1: number, + nLevelPrestId2: number, + a4: number, +): void { + const nVariants = (nLevelPrestId2 - nLevelPrestId1 + 1) >>> 0 + let v34 = 0 + + const pOutdoors = pLevel.pOutdoors! + const v8 = Math.imul(pOutdoors.nGridWidth, pOutdoors.nGridHeight) + + if (v8) { + const v11 = new Int32Array(2 * v8) + + for (let i = 0; i < v8; i += 1) { + const v12 = idiv(i, pOutdoors.nGridWidth) + const v13 = imod(i, pOutdoors.nGridWidth) + + v11[2 * i] = v13 + v11[2 * i + 1] = v12 + } + + for (let i = 0; i < v8; i += 1) { + const v14 = SEED_RollLimitedRandomNumber(pLevel.pSeed, v8) >>> 0 + const v19 = SEED_RollLimitedRandomNumber(pLevel.pSeed, v8) >>> 0 + + const v23 = v11[2 * v14]! + const v24 = v11[2 * v14 + 1]! + + v11[2 * v14] = v11[2 * v19]! + v11[2 * v14 + 1] = v11[2 * v19 + 1]! + + v11[2 * v19] = v23 + v11[2 * v19 + 1] = v24 + } + + for (let i = 0; i < v8; i += 1) { + const a2b = v11[2 * i]! + const a3a = v11[2 * i + 1]! + + const v26 = SEED_RollLimitedRandomNumber(pLevel.pSeed, nVariants) >>> 0 + + if (DRLGOUTDOORS_TestOutdoorLevelPreset(pLevel, a2b, a3a, v26 + nLevelPrestId1, 0, 15)) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, a2b, a3a, v26 + nLevelPrestId1, -1, false) + + v34 += 1 + + if (a4 > 0 && v34 >= a4) { + break + } + } + } + } +} diff --git a/src/game/drlg/drlg-outplace.ts b/src/game/drlg/drlg-outplace.ts index d3494f3..ce44065 100644 --- a/src/game/drlg/drlg-outplace.ts +++ b/src/game/drlg/drlg-outplace.ts @@ -46,6 +46,30 @@ import { LVLPREST_ACT2_DESERT_BORDER_10, LVLPREST_ACT2_DESERT_BORDER_11, LVLPREST_ACT2_DESERT_BORDER_12, + LVLPREST_ACT3_BRIDGE, + LVLPREST_ACT3_BURBS_08X08, + LVLPREST_ACT3_BURBS_08X16, + LVLPREST_ACT3_BURBS_16X08, + LVLPREST_ACT3_BURBS_16X16, + LVLPREST_ACT3_BURBS_SEWER, + LVLPREST_ACT3_BURBS_TEMPLE, + LVLPREST_ACT3_BURBS_WAYPOINT, + LVLPREST_ACT3_METRO_08X08, + LVLPREST_ACT3_METRO_08X16, + LVLPREST_ACT3_METRO_16X08, + LVLPREST_ACT3_METRO_16X16, + LVLPREST_ACT3_METRO_SEWER, + LVLPREST_ACT3_METROTEMPLE, + LVLPREST_ACT3_SLUMS_08X08, + LVLPREST_ACT3_SLUMS_08X16, + LVLPREST_ACT3_SLUMS_16X08, + LVLPREST_ACT3_SLUMS_16X16, + LVLPREST_ACT3_TRAVINCAL_N, + LVLPREST_ACT3_TRAVINCAL_NE, + LVLPREST_ACT3_TRAVINCAL_NW, + LVLPREST_ACT3_TRAVINCAL_S, + LVLPREST_ACT3_TRAVINCAL_SE, + LVLPREST_ACT3_TRAVINCAL_SW, LVLPREST_ACT4_MESA_BORDER_1, LVLPREST_ACT4_MESA_BORDER_2, LVLPREST_ACT4_MESA_BORDER_3, @@ -95,8 +119,17 @@ import { DRLG_OUTDOORS_GenerateDirtPath, DRLGOUTDOORS_GetOutLinkVisFlag, DRLGOUTDOORS_GetPackedGrid2Info, + DRLGOUTDOORS_SpawnOutdoorLevelPreset, DRLGOUTDOORS_SpawnOutdoorLevelPresetEx, } from './drlg-outdoors.ts' +import { + DRLGOUTJUNG_BuildJungle, + DRLGOUTJUNG_BuildKurastBazaar, + DRLGOUTJUNG_BuildLowerKurast, + DRLGOUTJUNG_BuildUpperKurast, + DRLGOUTJUNG_SpawnRandomPreset, +} from './drlg-outjung.ts' +import { sub_6FD84100 } from './drlg-outsiege.ts' import { DRLGROOM_AddRoomExToLevel, DRLGROOM_AllocRoomEx, DRLGROOM_AreXYInsideCoordinates } from './drlg-room.ts' import { DRLGROOMTILE_AllocTileGrid } from './drlg-roomtile.ts' import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts' @@ -129,12 +162,16 @@ import { i32, idiv, LEVEL_BURIALGROUNDS, + LEVEL_KURASTBAZAAR, + LEVEL_KURASTCAUSEWAY, + LEVEL_LOWERKURAST, + LEVEL_TRAVINCAL, + LEVEL_TUNDRAWASTELANDS, + LEVEL_UPPERKURAST, } from './drlg-types.ts' import { DRLGVER_AllocVertex, DRLGVER_GetCoordDiff } from './drlg-vertex.ts' import { sub_6FDAB750 } from './path-dir.ts' -const LEVEL_TUNDRAWASTELANDS = 112 - const gnBarricadeCliffBorderIds: readonly (readonly [number, number])[] = [ [LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1, LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW], [LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_2, LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_2_SNOW], @@ -152,6 +189,101 @@ const gnBarricadeCliffBorderIds: readonly (readonly [number, number])[] = [ [-1, 0], ] +//D2Common.0x6FD80480 +export function DRLGOUTPLACE_BuildKurast(pLevel: D2DrlgLevelStrc): void { + switch (pLevel.nLevelId) { + case LEVEL_LOWERKURAST: + DRLGOUTJUNG_BuildLowerKurast(pLevel) + break + + case LEVEL_KURASTBAZAAR: + DRLGOUTJUNG_BuildKurastBazaar(pLevel) + break + + case LEVEL_UPPERKURAST: + DRLGOUTJUNG_BuildUpperKurast(pLevel) + break + + default: + break + } + + switch (pLevel.nLevelId) { + case LEVEL_LOWERKURAST: + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_WAYPOINT, 0, 0, 15) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_SLUMS_16X16, LVLPREST_ACT3_SLUMS_16X16, 4) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_SLUMS_08X16, LVLPREST_ACT3_SLUMS_16X08, 0) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_SLUMS_08X08, LVLPREST_ACT3_SLUMS_08X08, 0) + break + + case LEVEL_KURASTBAZAAR: + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 3, 3, LVLPREST_ACT3_BURBS_SEWER, 0, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + idiv(pLevel.pLevelCoords.nWidth, 8) - 4, + 3, + LVLPREST_ACT3_BURBS_SEWER, + 1, + false, + ) + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_TEMPLE, 0, 0, 15) + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_TEMPLE, 1, 0, 15) + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_WAYPOINT, 0, 0, 15) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_BURBS_16X16, LVLPREST_ACT3_BURBS_16X16, 4) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_BURBS_08X16, LVLPREST_ACT3_BURBS_16X08, 0) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_BURBS_08X08, LVLPREST_ACT3_BURBS_08X08, 0) + break + + case LEVEL_UPPERKURAST: + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + 3, + idiv(pLevel.pLevelCoords.nHeight, 8) - 4, + LVLPREST_ACT3_METRO_SEWER, + 0, + false, + ) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + idiv(pLevel.pLevelCoords.nWidth, 8) - 4, + idiv(pLevel.pLevelCoords.nHeight, 8) - 4, + LVLPREST_ACT3_METRO_SEWER, + 1, + false, + ) + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_METROTEMPLE, 0, 0, 15) + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_METROTEMPLE, 1, 0, 15) + DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_WAYPOINT, 0, 0, 15) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_METRO_16X16, LVLPREST_ACT3_METRO_16X16, 4) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_METRO_08X16, LVLPREST_ACT3_METRO_16X08, 0) + DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_METRO_08X08, LVLPREST_ACT3_METRO_08X08, 0) + break + + case LEVEL_KURASTCAUSEWAY: + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_BRIDGE, 0, false) + break + + default: + return + } +} + +//D2Common.0x6FD806A0 +export function DRLGOUTPLACE_InitAct3OutdoorLevel(pLevel: D2DrlgLevelStrc): void { + DRLGOUTPLACE_SetOutGridLinkFlags(pLevel) + DRLGOUTJUNG_BuildJungle(pLevel) + DRLGOUTPLACE_BuildKurast(pLevel) + + if (pLevel.nLevelId === LEVEL_TRAVINCAL) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_TRAVINCAL_NW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 2, 0, LVLPREST_ACT3_TRAVINCAL_N, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 6, 0, LVLPREST_ACT3_TRAVINCAL_NE, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 4, LVLPREST_ACT3_TRAVINCAL_SW, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 2, 4, LVLPREST_ACT3_TRAVINCAL_S, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 6, 4, LVLPREST_ACT3_TRAVINCAL_SE, -1, false) + } +} + interface D2UnkOutPlaceStrc12 { field_0: number field_4: number @@ -655,7 +787,8 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(pLevel: D2DrlgLevelStrc) break case LVLTYPE_ACT5_BARRICADE: - throw new Error('DRLGOUTPLACE_PlaceAct1245OutdoorBorders: act 5 is not ported') + nLevelPrestId = sub_6FD80BE0(nCurrentDiffX, nCurrentDiffY, sub_6FD84100(pLevel)) + break default: nLevelPrestId = sub_6FD80BE0(nCurrentDiffX, nCurrentDiffY, -1) @@ -750,7 +883,8 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(pLevel: D2DrlgLevelStrc) break case LVLTYPE_ACT5_BARRICADE: - throw new Error('DRLGOUTPLACE_PlaceAct1245OutdoorBorders: act 5 is not ported') + v41 = sub_6FD84100(pLevel) + break default: v41 = -1 diff --git a/src/game/drlg/drlg-outsiege.ts b/src/game/drlg/drlg-outsiege.ts new file mode 100644 index 0000000..dcf39e2 --- /dev/null +++ b/src/game/drlg/drlg-outsiege.ts @@ -0,0 +1,714 @@ +/** + * D2Common Act V outdoor siege/barricade DRLG: 1:1 port of D2MOO + * `source/D2Common/src/Drlg/DrlgOutSiege.cpp`. + */ + +import { SEED_RollLimitedRandomNumber } from '../d2-rng.ts' +import { DRLGGRID_AlterGridFlag, DRLGGRID_GetGridEntry } from './drlg-grid.ts' +import { + LVLPREST_ACT5_BARRICADE_1, + LVLPREST_ACT5_BARRICADE_1_SNOW, + LVLPREST_ACT5_BARRICADE_8, + LVLPREST_ACT5_BARRICADE_BUILDING, + LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1, + LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW, + LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3, + LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3_SNOW, + LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4, + LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4_SNOW, + LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7, + LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7_SNOW, + LVLPREST_ACT5_BARRICADE_ENTRANCE_16X32, + LVLPREST_ACT5_BARRICADE_ENTRANCE_32X16, + LVLPREST_ACT5_BARRICADE_EXIT_16X32, + LVLPREST_ACT5_BARRICADE_EXIT_32X16, + LVLPREST_ACT5_BARRICADE_FILLER, + LVLPREST_ACT5_BARRICADE_FILLER_TREASURE, + LVLPREST_ACT5_BARRICADE_FROM_CAVE_16X32_SNOW, + LVLPREST_ACT5_BARRICADE_FROM_CAVE_32X16_SNOW, + LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N, + LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W, + LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1, + LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1_SNOW, + LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3, + LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3_SNOW, + LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4, + LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4_SNOW, + LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5, + LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5_SNOW, + LVLPREST_ACT5_BARRICADE_RUINS_N_1, + LVLPREST_ACT5_BARRICADE_RUINS_N_2, + LVLPREST_ACT5_BARRICADE_RUINS_N_TREASURE, + LVLPREST_ACT5_BARRICADE_RUINS_W_1, + LVLPREST_ACT5_BARRICADE_RUINS_W_2, + LVLPREST_ACT5_BARRICADE_RUINS_W_TREASURE, + LVLPREST_ACT5_BARRICADE_SNOW_LAKE_1, + LVLPREST_ACT5_BARRICADE_SNOW_LAKE_2, + LVLPREST_ACT5_BARRICADE_SNOW_OTHER, + LVLPREST_ACT5_BARRICADE_SNOW_TREASURE, + LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32, + LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32_SNOW, + LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16, + LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16_SNOW, + LVLPREST_ACT5_BARRICADE_TO_SIEGE, + LVLPREST_ACT5_BARRICADE_WAYPOINT_DIRT, + LVLPREST_ACT5_BARRICADE_WAYPOINT_SNOW, + LVLPREST_ACT5_SIEGE_TO_TOWN, + LVLSUB_ACT5_BARRICADE, +} from './drlg-ids.ts' +import { + DRLGOUTDOORS_AlterAdjacentPresetGridCells, + DRLGOUTDOORS_GetPackedGrid2Info, + DRLGOUTDOORS_GetPresetIndexFromGridCell, + DRLGOUTDOORS_SetBlankGridCell, + DRLGOUTDOORS_SpawnOutdoorLevelPreset, + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx, + DRLGOUTDOORS_TestGridCellNonLvlLink, +} from './drlg-outdoors.ts' +import { DRLGOUTPLACE_SetOutGridLinkFlags, sub_6FD80BE0, sub_6FD80C10 } from './drlg-outplace.ts' +import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts' +import { DRLGTILESUB_AddSecondaryBorder } from './drlg-tilesub.ts' +import { + type D2DrlgLevelStrc, + FLAG_OPERATION_OR, + GRID2_LVL_LINK, + GRID2_UNKB00, + idiv, + imod, + LEVEL_ARREATPLATEAU, + LEVEL_BLOODYFOOTHILLS, + LEVEL_ID_ACT5_BARRICADE_1, + LEVEL_TUNDRAWASTELANDS, + outdoorCoord, +} from './drlg-types.ts' +import { DRLGVER_GetCoordDiff } from './drlg-vertex.ts' + +// D2Common.0x6FD84100 +export function sub_6FD84100(pLevel: D2DrlgLevelStrc): number { + return (pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS ? 1 : 0) + 4 +} + +const stru_6FDD09C8: readonly (readonly [number, number])[] = [ + [-1, 0], + [0, -1], + [1, 0], + [0, 1], + [0, -1], + [1, 0], + [0, 1], + [-1, 0], + [-1, 0], + [0, -1], + [1, 0], + [0, 1], +] + +// D2Common.0x6FD84110 +export function DRLGOUTSIEGE_InitAct5OutdoorLevel(pLevel: D2DrlgLevelStrc): void { + const pOutdoors = pLevel.pOutdoors! + if (pLevel.nLevelId === LEVEL_BLOODYFOOTHILLS) { + const pLvlPrestTxtRecord = DATATBLS_GetLvlPrestTxtRecord(pLevel.pDrlg.env.tables, LVLPREST_ACT5_SIEGE_TO_TOWN) + let nSize = pOutdoors.nGridWidth - idiv(pLvlPrestTxtRecord.nSizeX, 8) + for (let i = 0; i < 15; i += 1) { + if (nSize < 0) { + throw new Error('Siege Level is the wrong size') + } + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nSize, 0, i + LVLPREST_ACT5_SIEGE_TO_TOWN, 0, false) + nSize -= idiv(pLvlPrestTxtRecord.nSizeX, 8) + } + } else { + DRLGOUTPLACE_SetOutGridLinkFlags(pLevel) + + let pPreviousVertex = pOutdoors.pVertex! + let pDrlgVertex = pPreviousVertex.pNext! + + const nLookupId = sub_6FD84100(pLevel) + do { + const prevDiff = DRLGVER_GetCoordDiff(pPreviousVertex) + const curDiff = DRLGVER_GetCoordDiff(pDrlgVertex) + const nPreviousDiffX = prevDiff.nDiffX + const nPreviousDiffY = prevDiff.nDiffY + const nCurrentDiffX = curDiff.nDiffX + const nCurrentDiffY = curDiff.nDiffY + + const nPreviousDiffXAbs = Math.abs(nPreviousDiffX) + const nPreviousDiffYAbs = Math.abs(nPreviousDiffY) + + let nPreviousX = pPreviousVertex.nPosX & ~1 + let nPreviousY = pPreviousVertex.nPosY & ~1 + + const nCurrentX = pDrlgVertex.nPosX & ~1 + const nCurrentY = pDrlgVertex.nPosY & ~1 + + const nLevelPrestId = sub_6FD80BE0(nPreviousDiffX, nPreviousDiffY, nLookupId) + + if ((pPreviousVertex.dwFlags & 2) === 0) { + while (nPreviousX !== nCurrentX || nPreviousY !== nCurrentY) { + nPreviousX += 2 * nPreviousDiffX + nPreviousY += 2 * nPreviousDiffY + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nPreviousX, nPreviousY, nLevelPrestId, -1, false) + DRLGGRID_AlterGridFlag(pOutdoors.pGrid[2], nPreviousX, nPreviousY, GRID2_UNKB00, FLAG_OPERATION_OR) + } + } + + if (pPreviousVertex.dwFlags & 1) { + const nX = Math.max(pPreviousVertex.nPosX, pDrlgVertex.nPosX) + const nXCapped = (nX - 4 * nPreviousDiffXAbs) & ~1 + const nY = Math.max(pPreviousVertex.nPosY, pDrlgVertex.nPosY) + const nYCapped = (nY - 4 * nPreviousDiffYAbs) & ~1 + + DRLGGRID_AlterGridFlag(pOutdoors.pGrid[2], nXCapped, nYCapped, GRID2_LVL_LINK, FLAG_OPERATION_OR) + DRLGGRID_AlterGridFlag( + pOutdoors.pGrid[2], + nXCapped + 2 * nPreviousDiffXAbs, + nYCapped + 2 * nPreviousDiffYAbs, + GRID2_LVL_LINK, + FLAG_OPERATION_OR, + ) + } + + const nRand = sub_6FD80C10( + 2 * nPreviousDiffX, + 2 * nPreviousDiffY, + 2 * nCurrentDiffX, + 2 * nCurrentDiffY, + nLookupId, + ) + if (nRand) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nCurrentX, nCurrentY, nRand, -1, false) + DRLGGRID_AlterGridFlag(pOutdoors.pGrid[2], nCurrentX, nCurrentY, GRID2_UNKB00, FLAG_OPERATION_OR) + } + + pPreviousVertex = pDrlgVertex + pDrlgVertex = pDrlgVertex.pNext! + } while (pPreviousVertex !== pOutdoors.pVertex) + + if (pLevel.nLevelId === LEVEL_ID_ACT5_BARRICADE_1) { + sub_6FD846C0(pLevel) + } + + const nLastX = pOutdoors.nGridWidth - 2 + const nLastY = pOutdoors.nGridHeight - 2 + let nX = nLastX + let nY = 0 + + const nLevelPrestIdCliff = + pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS + ? LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW + : LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1 + const nLevelPrestIdRavine = + pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS + ? LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1_SNOW + : LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1 + + while (nX !== 0 || nY !== nLastY) { + const nIndex = DRLGGRID_GetGridEntry(pOutdoors.pGrid[0], nX, nY) - nLevelPrestIdCliff + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, nIndex + nLevelPrestIdRavine, -1, false) + + nX += 2 * stru_6FDD09C8[nIndex]![0] + nY += 2 * stru_6FDD09C8[nIndex]![1] + } + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + pOutdoors.nGridWidth - 2, + 0, + pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS + ? LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7_SNOW + : LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7, + -1, + false, + ) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + 0, + pOutdoors.nGridHeight - 2, + pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS + ? LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5_SNOW + : LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5, + -1, + false, + ) + + DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits(pLevel) + DRLGOUTSIEGE_PlaceCaves(pLevel) + + if (pLevel.nLevelId === LEVEL_ID_ACT5_BARRICADE_1) { + DRLGOUTSIEGE_ConnectBarricadeAndSiege(pLevel) + } + + DRLGOUTSIEGE_AddACt5SecondaryBorder(pLevel) + DRLGOUTSIEGE_PlacePrisons(pLevel) + DRLGOUTSIEGE_PlaceSpecialPresets(pLevel) + } +} + +interface D2DrlgOutSiegeInitStrc { + readonly nLevelId: number + readonly field_4: number + readonly field_8: number + readonly nLevelPrestId1: number + readonly nLevelPrestId2: number +} + +const stru_6FDD0988: readonly D2DrlgOutSiegeInitStrc[] = [ + { + nLevelId: LEVEL_ARREATPLATEAU, + field_4: 0, + field_8: 0, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + field_4: 0, + field_8: 1, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_FROM_CAVE_32X16_SNOW, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_FROM_CAVE_16X32_SNOW, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + field_4: 0, + field_8: 0, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16_SNOW, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32_SNOW, + }, +] + +// D2Common.0x6FD844F0 +export function DRLGOUTSIEGE_PlaceCaves(pLevel: D2DrlgLevelStrc): void { + let nLevelPrestId = 0 + let nX = 0 + let nY = 0 + + for (let i = 0; i < stru_6FDD0988.length; i += 1) { + const entry = stru_6FDD0988[i]! + if (pLevel.nLevelId === entry.nLevelId) { + if (pLevel.pLevelCoords.nWidth <= pLevel.pLevelCoords.nHeight) { + nLevelPrestId = entry.nLevelPrestId1 + nX = 2 + if (entry.field_8) { + nY = pLevel.pOutdoors!.nGridHeight - 2 + } else { + nY = 0 + } + } else { + nLevelPrestId = entry.nLevelPrestId2 + nY = 2 + if (entry.field_8) { + nX = pLevel.pOutdoors!.nGridWidth - 2 + } else { + nX = 0 + } + } + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, nLevelPrestId, entry.field_4, false) + } + } +} + +// D2Common.0x6FD84580 +export function DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits(pLevel: D2DrlgLevelStrc): void { + const pOutdoors = pLevel.pOutdoors! + const nPickedFile = 2 * (pLevel.nLevelId === LEVEL_BLOODYFOOTHILLS ? 1 : 0) - 1 + + for (let i = 0; i < pOutdoors.nGridWidth; i += 1) { + if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, i, 0) & GRID2_LVL_LINK) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, i, 0, LVLPREST_ACT5_BARRICADE_EXIT_32X16, nPickedFile, false) + break + } + } + + for (let i = 0; i < pOutdoors.nGridWidth; i += 1) { + if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, i, pOutdoors.nGridHeight - 2) & GRID2_LVL_LINK) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + i, + pOutdoors.nGridHeight - 2, + LVLPREST_ACT5_BARRICADE_ENTRANCE_32X16, + nPickedFile, + false, + ) + break + } + } + + for (let i = 0; i < pOutdoors.nGridHeight; i += 1) { + if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, 0, i) & GRID2_LVL_LINK) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT5_BARRICADE_EXIT_16X32, nPickedFile, false) + break + } + } + + for (let i = 0; i < pOutdoors.nGridHeight; i += 1) { + if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, pOutdoors.nGridWidth - 2, i) & GRID2_LVL_LINK) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx( + pLevel, + pOutdoors.nGridWidth - 2, + i, + LVLPREST_ACT5_BARRICADE_ENTRANCE_16X32, + nPickedFile, + false, + ) + break + } + } +} + +// D2Common.0x6FD846C0 +export function sub_6FD846C0(pLevel: D2DrlgLevelStrc): void { + const pOutdoors = pLevel.pOutdoors! + DRLGGRID_AlterGridFlag( + pOutdoors.pGrid[2], + pOutdoors.nGridWidth - 2, + pOutdoors.nGridHeight - 4, + GRID2_LVL_LINK, + FLAG_OPERATION_OR, + ) + DRLGGRID_AlterGridFlag( + pOutdoors.pGrid[2], + pOutdoors.nGridWidth - 2, + pOutdoors.nGridHeight - 3, + GRID2_LVL_LINK, + FLAG_OPERATION_OR, + ) +} + +// D2Common.0x6FD84700 +export function DRLGOUTSIEGE_AddACt5SecondaryBorder(pLevel: D2DrlgLevelStrc): void { + if (pLevel.nLevelId !== LEVEL_BLOODYFOOTHILLS) { + const a1 = { + pLevel, + field_4: outdoorCoord(pLevel.pOutdoors!), + pGrid1: pLevel.pOutdoors!.pGrid[0], + pGrid2: pLevel.pOutdoors!.pGrid[2], + nLevelPrestId: 0, + field_14: -1, + nLvlSubId: LVLSUB_ACT5_BARRICADE, + field_1C: null, + field_20: null, + field_24: sub_6FD84820, + field_28: sub_6FD84780, + field_2C: DRLGOUTDOORS_AlterAdjacentPresetGridCells, + field_30: DRLGOUTDOORS_SetBlankGridCell, + field_34: DRLGOUTDOORS_SpawnOutdoorLevelPresetEx, + } + + DRLGTILESUB_AddSecondaryBorder(a1) + } +} + +interface D2DrlgOutSiegeInitStrc2 { + readonly nStyle: number + readonly field_4: number + readonly field_8: number + readonly nLevelPrestId1: number + readonly nLevelPrestId2: number +} + +const stru_6FDD0A28: readonly D2DrlgOutSiegeInitStrc2[] = [ + { nStyle: 49, field_4: 1, field_8: 16, nLevelPrestId1: LVLPREST_ACT5_BARRICADE_1, nLevelPrestId2: LVLPREST_ACT5_BARRICADE_1_SNOW }, + { nStyle: 49, field_4: 31, field_8: 46, nLevelPrestId1: LVLPREST_ACT5_BARRICADE_1, nLevelPrestId2: LVLPREST_ACT5_BARRICADE_1_SNOW }, + { + nStyle: 48, + field_4: 1, + field_8: 1, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3_SNOW, + }, + { + nStyle: 48, + field_4: 2, + field_8: 3, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW, + }, + { + nStyle: 48, + field_4: 4, + field_8: 4, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4_SNOW, + }, + { + nStyle: 48, + field_4: 5, + field_8: 5, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3_SNOW, + }, + { + nStyle: 48, + field_4: 6, + field_8: 7, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1_SNOW, + }, + { + nStyle: 48, + field_4: 8, + field_8: 8, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4_SNOW, + }, + { nStyle: 48, field_4: 30, field_8: 30, nLevelPrestId1: 0, nLevelPrestId2: 0 }, + { nStyle: 48, field_4: 31, field_8: 31, nLevelPrestId1: -5, nLevelPrestId2: -5 }, +] + +// D2Common.0x6FD84780 +export function sub_6FD84780(pLevel: D2DrlgLevelStrc, nStyle: number, a3: number): number { + if (nStyle !== 48 && nStyle !== 49) { + throw new Error(`sub_6FD84780: unexpected nStyle=${nStyle}`) + } + + for (let i = 0; i < stru_6FDD0A28.length; i += 1) { + const entry = stru_6FDD0A28[i]! + if (nStyle === entry.nStyle && a3 >= entry.field_4 && a3 <= entry.field_8) { + if (pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS) { + return a3 + entry.nLevelPrestId2 - entry.field_4 + } else { + return a3 + entry.nLevelPrestId1 - entry.field_4 + } + } + } + + throw new Error(`sub_6FD84780: unreachable nStyle=${nStyle} a3=${a3}`) +} + +// D2Common.0x6FD84820 +export function sub_6FD84820( + pLevel: D2DrlgLevelStrc, + nX: number, + nY: number, + a4: number, + _a5: number, + a6: number, +): boolean { + const v8 = sub_6FD84780(pLevel, (a6 >>> 20) & 0x3f, (a6 >>> 8) & 0xff) + + if (v8 === -5) { + return true + } else if (v8 === a4) { + return DRLGOUTDOORS_TestGridCellNonLvlLink(pLevel, nX, nY) !== 0 + } + + return false +} + +interface D2DrlgOutSiegeInitStrc3 { + readonly nLevelId: number + readonly nLevelPrestId1: number + readonly nLevelPrestId2: number + readonly field_C: number + readonly field_10: number + readonly field_14: number + readonly bMustHave: boolean +} + +const stru_6FDD0AF8: readonly D2DrlgOutSiegeInitStrc3[] = [ + { + nLevelId: LEVEL_ID_ACT5_BARRICADE_1, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W, + field_C: 0, + field_10: 25, + field_14: 1, + bMustHave: true, + }, + { + nLevelId: LEVEL_ARREATPLATEAU, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W, + field_C: 0, + field_10: 25, + field_14: 1, + bMustHave: true, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W, + field_C: 1, + field_10: 25, + field_14: 1, + bMustHave: true, + }, + { + nLevelId: LEVEL_ARREATPLATEAU, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_WAYPOINT_DIRT, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_WAYPOINT_DIRT, + field_C: -1, + field_10: 25, + field_14: 1, + bMustHave: true, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_WAYPOINT_SNOW, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_WAYPOINT_SNOW, + field_C: -1, + field_10: 25, + field_14: 1, + bMustHave: true, + }, + { + nLevelId: LEVEL_ID_ACT5_BARRICADE_1, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RUINS_N_TREASURE, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RUINS_W_TREASURE, + field_C: -1, + field_10: 10, + field_14: 1, + bMustHave: false, + }, + { + nLevelId: LEVEL_ID_ACT5_BARRICADE_1, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RUINS_N_1, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RUINS_W_1, + field_C: -1, + field_10: 20, + field_14: 4, + bMustHave: false, + }, + { + nLevelId: LEVEL_ID_ACT5_BARRICADE_1, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RUINS_N_2, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RUINS_W_2, + field_C: -1, + field_10: 20, + field_14: 4, + bMustHave: false, + }, + { + nLevelId: LEVEL_ARREATPLATEAU, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_FILLER_TREASURE, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_FILLER_TREASURE, + field_C: -1, + field_10: 10, + field_14: 1, + bMustHave: false, + }, + { + nLevelId: LEVEL_ARREATPLATEAU, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_BUILDING, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_BUILDING, + field_C: -1, + field_10: 8, + field_14: 1, + bMustHave: false, + }, + { + nLevelId: LEVEL_ARREATPLATEAU, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_FILLER, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_FILLER, + field_C: -1, + field_10: 15, + field_14: 5, + bMustHave: false, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_1, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_1, + field_C: -1, + field_10: 9, + field_14: 4, + bMustHave: false, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_2, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_2, + field_C: -1, + field_10: 9, + field_14: 4, + bMustHave: false, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_OTHER, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_OTHER, + field_C: -1, + field_10: 9, + field_14: 4, + bMustHave: false, + }, + { + nLevelId: LEVEL_TUNDRAWASTELANDS, + nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_TREASURE, + nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_TREASURE, + field_C: -1, + field_10: 5, + field_14: 3, + bMustHave: false, + }, +] + +// D2Common.0x6FD84870 +export function DRLGOUTSIEGE_PlaceSpecialPresets(pLevel: D2DrlgLevelStrc): void { + for (let i = 0; i < stru_6FDD0AF8.length; i += 1) { + const entry = stru_6FDD0AF8[i]! + if (pLevel.nLevelId === entry.nLevelId) { + const nLevelPrestId = + pLevel.pLevelCoords.nWidth < pLevel.pLevelCoords.nHeight ? entry.nLevelPrestId1 : entry.nLevelPrestId2 + + let bAdded = false + for (let j = 0; j < entry.field_14; j += 1) { + bAdded = DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nLevelPrestId, entry.field_C, 0, 15) + } + + if (!bAdded && entry.bMustHave) { + throw new Error(`DRLGOUTSIEGE_PlaceSpecialPresets: failed to place required preset ${nLevelPrestId}`) + } + } + } +} + +// D2Common.0x6FD84910 +export function DRLGOUTSIEGE_PlacePrisons(pLevel: D2DrlgLevelStrc): void { + let nPrisonsPlaced = 0 + const nPrisonsToPlace = 3 + + if (pLevel.nLevelId === LEVEL_ID_ACT5_BARRICADE_1) { + const pOutdoors = pLevel.pOutdoors! + + for (let nAttemptNumber = 0; nAttemptNumber < 90 && nPrisonsPlaced < nPrisonsToPlace; nAttemptNumber += 1) { + const nRandX = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridWidth, 2)) | 0) + const nRandY = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridHeight, 2)) | 0) + + const nLevelPrestId = DRLGOUTDOORS_GetPresetIndexFromGridCell(pLevel, nRandX, nRandY) + if (nLevelPrestId >= LVLPREST_ACT5_BARRICADE_1 && nLevelPrestId <= LVLPREST_ACT5_BARRICADE_8) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nRandX, nRandY, nLevelPrestId + 16, -1, false) + nPrisonsPlaced += 1 + } + } + + const nRandX = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridWidth, 2)) | 0) + const nRandY = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridHeight, 2)) | 0) + + for (let j = 0; j < pOutdoors.nGridHeight && nPrisonsPlaced < nPrisonsToPlace; j += 1) { + for (let i = 0; i < pOutdoors.nGridWidth && nPrisonsPlaced < nPrisonsToPlace; i += 1) { + const nX = imod(i + nRandX, pOutdoors.nGridWidth) + const nY = imod(j + nRandY, pOutdoors.nGridHeight) + + const nLevelPrestId = DRLGOUTDOORS_GetPresetIndexFromGridCell(pLevel, nX, nY) + if (nLevelPrestId >= LVLPREST_ACT5_BARRICADE_1 && nLevelPrestId <= LVLPREST_ACT5_BARRICADE_8) { + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, nLevelPrestId + 16, -1, false) + nPrisonsPlaced += 1 + } + } + } + } +} + +// D2Common.0x6FD84BB0 +export function DRLGOUTSIEGE_ConnectBarricadeAndSiege(pLevel: D2DrlgLevelStrc): void { + const pOutdoors = pLevel.pOutdoors! + const pLvlPrestTxtRecord = DATATBLS_GetLvlPrestTxtRecord(pLevel.pDrlg.env.tables, LVLPREST_ACT5_BARRICADE_TO_SIEGE) + + const nX = pOutdoors.nGridWidth - idiv(pLvlPrestTxtRecord.nSizeX, 8) + const nY = pOutdoors.nGridHeight - idiv(pLvlPrestTxtRecord.nSizeY, 8) + + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, LVLPREST_ACT5_BARRICADE_TO_SIEGE, -1, false) + DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY - 2, LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4, -1, false) +} diff --git a/src/game/drlg/drlg-preset.ts b/src/game/drlg/drlg-preset.ts index bd6b867..c26d838 100644 --- a/src/game/drlg/drlg-preset.ts +++ b/src/game/drlg/drlg-preset.ts @@ -11,6 +11,7 @@ */ import { SEED_RollLimitedRandomNumber, SEED_RollRandomNumber, type D2SeedStrc } from '../d2-rng.ts' +import { DRLGANIM_AllocAnimationTileGrids, DRLGANIM_TestLoadAnimatedRoomTiles } from './drlg-anim.ts' import { DATATBLS_GetLvlPrestTxtRecord, DATATBLS_GetLvlPrestTxtRecordFromLevelId, @@ -1142,11 +1143,6 @@ export function DRLGPRESET_InitPresetRoomGrids(pDrlgRoom: D2DrlgRoomStrc): void } } -// DrlgDrlgAnim.cpp is not ported: no Act I LvlPrest.txt row sets Animate (the call sites throw instead). -function DRLGANIM_NotPorted(szFunction: string): never { - throw new Error(`${szFunction} is not ported (LvlPrest.txt Animate is set)`) -} - //D2Common.0x6FD87130 export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): void { DRLGROOMTILE_AllocTileGrid(pDrlgRoom) @@ -1198,7 +1194,7 @@ export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): voi DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pFloorGrid[i]!, bCheckCoordinatesValidity, bKillEdgeX, bKillEdgeY) if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) { - DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles') + DRLGANIM_TestLoadAnimatedRoomTiles(pDrlgRoom, pMaze.pFloorGrid[i]!, null, 0, bKillEdgeX, bKillEdgeY) } } @@ -1207,14 +1203,21 @@ export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): voi DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pWallGrid[i]!, false, bKillEdgeX, bKillEdgeY) if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) { - DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles') + DRLGANIM_TestLoadAnimatedRoomTiles( + pDrlgRoom, + pMaze.pWallGrid[i]!, + pMaze.pTileTypeGrid[i]!, + TILETYPE_FLOOR, + bKillEdgeX, + bKillEdgeY, + ) } } DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pCellGrid, false, bKillEdgeX, bKillEdgeY) if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) { - DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles') + DRLGANIM_TestLoadAnimatedRoomTiles(pDrlgRoom, pMaze.pCellGrid, null, TILETYPE_SHADOW, bKillEdgeX, bKillEdgeY) } if (bTownOrGraveyard) { @@ -1239,7 +1242,15 @@ export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): voi } if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) { - DRLGANIM_NotPorted('DRLGANIM_AllocAnimationTileGrids') + DRLGANIM_AllocAnimationTileGrids( + pDrlgRoom, + pMazeMap.pLvlPrestTxtRecord.nAnimSpeed, + pMaze.pWallGrid, + pFile.nWallLayers, + pMaze.pFloorGrid, + pFile.nFloorLayers, + pMaze.pCellGrid, + ) } const pTileGrid = tileGridOf(pDrlgRoom) diff --git a/src/game/drlg/drlg-room.ts b/src/game/drlg/drlg-room.ts index d4d59a8..edcd78a 100644 --- a/src/game/drlg/drlg-room.ts +++ b/src/game/drlg/drlg-room.ts @@ -470,40 +470,12 @@ export function sub_6FD77BB0(pDrlgRoom: D2DrlgRoomStrc): void { pDrlgRoom.ppRoomsNear = ppRoomList pDrlgRoom.nRoomsNear = nNearRooms - for (let pOrth = pDrlgRoom.pDrlgOrth; pOrth; pOrth = pOrth.pNext) { - if (pOrth.bPreset) { - const pLevel = pOrth.pLevel - if (pLevel) { - for (let pNearRoom = pLevel.pFirstRoomEx; pNearRoom; pNearRoom = pNearRoom.pDrlgRoomNext) { - let nX = pDrlgRoom.pDrlgCoord.nPosX - pNearRoom.pDrlgCoord.nWidth - pNearRoom.pDrlgCoord.nPosX - if (nX < 0) { - nX = pNearRoom.pDrlgCoord.nPosX - (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth) - } - - let nY = pDrlgRoom.pDrlgCoord.nPosY - pNearRoom.pDrlgCoord.nHeight - pNearRoom.pDrlgCoord.nPosY - if (nY < 0) { - nY = pNearRoom.pDrlgCoord.nPosY - (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight) - } - - if (nX < 0) nX = 0 - if (nY < 0) nY = 0 - - if (nX < 6 && nY < 6) { - pDrlgRoom.ppRoomsNear.push(pNearRoom) - pDrlgRoom.nRoomsNear += 1 - DRLGROOM_SortRoomListByPosition(pDrlgRoom.ppRoomsNear, pDrlgRoom.nRoomsNear) - } - } - } - } - } - if (pDrlgRoom.dwFlags & DRLGROOMFLAG_HAS_WARP_MASK) { let nFlags = DRLGROOMFLAG_HAS_WARP_0 let nWarpId = 0 do { - if (nFlags & pDrlgRoom.dwFlags) { + if (nFlags & pDrlgRoom.dwFlags && pDrlgRoom.pLevel.nLevelId !== 133) { const pSourceVisArray = DRLGROOM_GetVisArrayFromLevelId(pDrlg, pDrlgRoom.pLevel.nLevelId) const pLevel = DRLG_GetLevel(pDrlg, pSourceVisArray[nWarpId]!) const pDestinationVisArray = DRLGROOM_GetVisArrayFromLevelId(pDrlg, pSourceVisArray[nWarpId]!) @@ -513,11 +485,22 @@ export function sub_6FD77BB0(pDrlgRoom: D2DrlgRoomStrc): void { } if (nWarpDestination !== -1) { + let nSourceVisCount = 0 + for (let i = 0; i <= nWarpId; i += 1) { + if (pSourceVisArray[i] === pLevel.nLevelId) { + nSourceVisCount += 1 + } + } + + let nDestinationVisCount = 0 for (let i = 0; i < 8; i += 1) { if (pDestinationVisArray[i] === pDrlgRoom.pLevel.nLevelId) { + nDestinationVisCount += 1 + } + + if (nSourceVisCount === nDestinationVisCount) { const pNearRoom = pLevel.pFirstRoomEx - const nWarpDest = DRLGWARP_GetWarpDestinationFromArray(pLevel, i) - if (nWarpDest === nWarpId && sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDestination)) { + if (sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDestination)) { bSkip = true break } @@ -529,8 +512,7 @@ export function sub_6FD77BB0(pDrlgRoom: D2DrlgRoomStrc): void { for (let i = 0; i < 8; i += 1) { if (pDestinationVisArray[i] === pDrlgRoom.pLevel.nLevelId) { const pNearRoom = pLevel.pFirstRoomEx - const nWarpDest = DRLGWARP_GetWarpDestinationFromArray(pLevel, i) - if (sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDest)) { + if (sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDestination)) { break } } diff --git a/src/game/drlg/drlg-roomtile.ts b/src/game/drlg/drlg-roomtile.ts index 8223f90..3fbd252 100644 --- a/src/game/drlg/drlg-roomtile.ts +++ b/src/game/drlg/drlg-roomtile.ts @@ -274,10 +274,9 @@ function DUNGEON_GameTileToClientCoords(nX: number, nY: number): { nX: number; n return { nX: Math.imul(80, (nX - nY) | 0), nY: (Math.imul(80, (nX + nY) | 0) / 2) | 0 } } -// MONSTERS_ValidateMonsterId (D2Common Monsters.cpp) needs the MonStats table logic, which the DRLG port -// does not include; the native oracle stubs it as a hard failure too. Only reached for Act V levels. -function MONSTERS_ValidateMonsterId(nMonsterId: number): number { - throw new Error(`MONSTERS_ValidateMonsterId(${nMonsterId}) is not ported (Act V tile preset units)`) +// MONSTERS_ValidateMonsterId (D2Common Monsters.cpp) +function MONSTERS_ValidateMonsterId(pDrlgRoom: D2DrlgRoomStrc, nMonsterId: number): number { + return nMonsterId >= 0 && nMonsterId < pDrlgRoom.pLevel.pDrlg.env.tables.nMonStatsTxtRecordCount ? nMonsterId : -1 } // --------------------------------------------------------------------------------------------- @@ -649,7 +648,7 @@ export function DRLGROOMTILE_AddTilePresetUnits( DRLGROOM_AllocPresetUnit( pDrlgRoom, UNIT_MONSTER, - MONSTERS_ValidateMonsterId(nUnitId), + MONSTERS_ValidateMonsterId(pDrlgRoom, nUnitId), MONMODE_NEUTRAL, nPosX, nPosY, @@ -936,8 +935,10 @@ export function DRLGROOMTILE_LoadInitRoomTiles( pTileCache, nTileType, ) - // D2_VERSION_HAS_UBERS is not defined for 1.13c. - if (nTileType === TILETYPE_WALL_RIGHT_EXIT || nTileType === TILETYPE_WALL_LEFT_EXIT) { + if ( + (nTileType === TILETYPE_WALL_RIGHT_EXIT || nTileType === TILETYPE_WALL_LEFT_EXIT) && + pDrlgRoom.pLevel.nLevelId !== 133 + ) { DRLGROOMTILE_LoadWallWarpTiles(pDrlgRoom, pWallTileData, nTileInformation, nTileType) } } @@ -1243,7 +1244,7 @@ const nRemapIndices: readonly number[] = [ 3, // [TILETYPE_WALL_TOP_RIGHT ] 4, // [TILETYPE_WALL_BOTTOM_LEFT ] 5, // [TILETYPE_WALL_BOTTOM_RIGHT ] - -2, -2, -1, -1, -1, -2, -1, -1, -1, -1, -1, + -2, -2, -1, -1, -1, -2, -1, -1, -1, -1, -1, 0, 0, ] //D2Common.0x6FDD1390 @@ -1270,7 +1271,7 @@ export function DRLGROOMTILE_LinkedTileDataManager( ): void { const nTileInformation = nPackedTileInformation >>> 0 if (nTileType < 0 || nTileType >= nRemapIndices.length) { - throw new RangeError(`DRLGROOMTILE_LinkedTileDataManager: nRemapIndices[${nTileType}] is outside the 19-entry table`) + throw new RangeError(`DRLGROOMTILE_LinkedTileDataManager: nRemapIndices[${nTileType}] is outside the table`) } const v10 = nRemapIndices[nTileType]! diff --git a/src/game/drlg/drlg-types.ts b/src/game/drlg/drlg-types.ts index 281ba0d..8298e03 100644 --- a/src/game/drlg/drlg-types.ts +++ b/src/game/drlg/drlg-types.ts @@ -144,7 +144,7 @@ export const FLAG_OPERATION_OVERWRITE_IF_ZERO = 4 export const FLAG_OPERATION_AND_NEGATED = 5 export type FlagOperation = 0 | 1 | 2 | 3 | 4 | 5 -/** D2C_Levels (DataTbls/LevelsIds.h), the ids the Act I DRLG code refers to. */ +/** D2C_Levels (DataTbls/LevelsIds.h). */ export const LEVEL_NONE = 0 export const LEVEL_ROGUEENCAMPMENT = 1 export const LEVEL_BLOODMOOR = 2 @@ -166,14 +166,122 @@ export const LEVEL_BURIALGROUNDS = 17 export const LEVEL_CRYPT = 18 export const LEVEL_MAUSOLEUM = 19 export const LEVEL_FORGOTTENTOWER = 20 +export const LEVEL_TOWERCELLARLEV1 = 21 +export const LEVEL_TOWERCELLARLEV2 = 22 +export const LEVEL_TOWERCELLARLEV3 = 23 +export const LEVEL_TOWERCELLARLEV4 = 24 +export const LEVEL_TOWERCELLARLEV5 = 25 export const LEVEL_MONASTERYGATE = 26 export const LEVEL_OUTERCLOISTER = 27 +export const LEVEL_BARRACKS = 28 +export const LEVEL_JAILLEV1 = 29 +export const LEVEL_JAILLEV2 = 30 +export const LEVEL_JAILLEV3 = 31 +export const LEVEL_INNERCLOISTER = 32 +export const LEVEL_CATHEDRAL = 33 +export const LEVEL_CATACOMBSLEV1 = 34 +export const LEVEL_CATACOMBSLEV2 = 35 +export const LEVEL_CATACOMBSLEV3 = 36 +export const LEVEL_CATACOMBSLEV4 = 37 export const LEVEL_TRISTRAM = 38 export const LEVEL_MOOMOOFARM = 39 export const LEVEL_LUTGHOLEIN = 40 +export const LEVEL_ROCKYWASTE = 41 +export const LEVEL_DRYHILLS = 42 +export const LEVEL_FAROASIS = 43 +export const LEVEL_LOSTCITY = 44 +export const LEVEL_VALLEYOFSNAKES = 45 +export const LEVEL_CANYONOFTHEMAGI = 46 +export const LEVEL_SEWERSLEV1 = 47 +export const LEVEL_SEWERSLEV2 = 48 +export const LEVEL_SEWERSLEV3 = 49 +export const LEVEL_HAREMLEV1 = 50 +export const LEVEL_HAREMLEV2 = 51 +export const LEVEL_PALACECELLARLEV1 = 52 +export const LEVEL_PALACECELLARLEV2 = 53 +export const LEVEL_PALACECELLARLEV3 = 54 +export const LEVEL_STONYTOMBLEV1 = 55 +export const LEVEL_HALLSOFTHEDEADLEV1 = 56 +export const LEVEL_HALLSOFTHEDEADLEV2 = 57 +export const LEVEL_CLAWVIPERTEMPLELEV1 = 58 +export const LEVEL_STONYTOMBLEV2 = 59 +export const LEVEL_HALLSOFTHEDEADLEV3 = 60 +export const LEVEL_CLAWVIPERTEMPLELEV2 = 61 +export const LEVEL_MAGGOTLAIRLEV1 = 62 +export const LEVEL_MAGGOTLAIRLEV2 = 63 +export const LEVEL_MAGGOTLAIRLEV3 = 64 +export const LEVEL_ANCIENTTUNNELS = 65 +export const LEVEL_TALRASHASTOMB1 = 66 +export const LEVEL_TALRASHASTOMB2 = 67 +export const LEVEL_TALRASHASTOMB3 = 68 +export const LEVEL_TALRASHASTOMB4 = 69 +export const LEVEL_TALRASHASTOMB5 = 70 +export const LEVEL_TALRASHASTOMB6 = 71 +export const LEVEL_TALRASHASTOMB7 = 72 +export const LEVEL_DURIELSLAIR = 73 +export const LEVEL_ARCANESANCTUARY = 74 export const LEVEL_KURASTDOCKTOWN = 75 +export const LEVEL_SPIDERFOREST = 76 +export const LEVEL_GREATMARSH = 77 +export const LEVEL_FLAYERJUNGLE = 78 +export const LEVEL_LOWERKURAST = 79 +export const LEVEL_KURASTBAZAAR = 80 +export const LEVEL_UPPERKURAST = 81 +export const LEVEL_KURASTCAUSEWAY = 82 +export const LEVEL_TRAVINCAL = 83 +export const LEVEL_SPIDERCAVE = 84 +export const LEVEL_SPIDERCAVERN = 85 +export const LEVEL_SWAMPYPITLEV1 = 86 +export const LEVEL_SWAMPYPITLEV2 = 87 +export const LEVEL_FLAYERDUNGEONLEV1 = 88 +export const LEVEL_FLAYERDUNGEONLEV2 = 89 +export const LEVEL_SWAMPYPITLEV3 = 90 +export const LEVEL_FLAYERDUNGEONLEV3 = 91 +export const LEVEL_SEWERSA3LEV1 = 92 +export const LEVEL_SEWERSA3LEV2 = 93 +export const LEVEL_RUINEDTEMPLE = 94 +export const LEVEL_DISUSEDFANE = 95 +export const LEVEL_FORGOTTENRELIQUARY = 96 +export const LEVEL_FORGOTTENTEMPLE = 97 +export const LEVEL_RUINEDFANE = 98 +export const LEVEL_DISUSEDRELIQUARY = 99 +export const LEVEL_DURANCEOFHATELEV1 = 100 +export const LEVEL_DURANCEOFHATELEV2 = 101 +export const LEVEL_DURANCEOFHATELEV3 = 102 export const LEVEL_THEPANDEMONIUMFORTRESS = 103 +export const LEVEL_OUTERSTEPPES = 104 +export const LEVEL_PLAINSOFDESPAIR = 105 +export const LEVEL_CITYOFTHEDAMNED = 106 +export const LEVEL_RIVEROFFLAME = 107 +export const LEVEL_CHAOSSANCTUM = 108 export const LEVEL_HARROGATH = 109 +export const LEVEL_BLOODYFOOTHILLS = 110 +export const LEVEL_ID_ACT5_BARRICADE_1 = 111 +export const LEVEL_ARREATPLATEAU = 112 +export const LEVEL_CRYSTALIZEDCAVERNLEV1 = 113 +export const LEVEL_CELLAROFPITY = 114 +export const LEVEL_CRYSTALIZEDCAVERNLEV2 = 115 +export const LEVEL_ECHOCHAMBER = 116 +export const LEVEL_TUNDRAWASTELANDS = 117 +export const LEVEL_GLACIALCAVESLEV1 = 118 +export const LEVEL_GLACIALCAVESLEV2 = 119 +export const LEVEL_ROCKYSUMMIT = 120 +export const LEVEL_NIHLATHAKSTEMPLE = 121 +export const LEVEL_HALLSOFANGUISH = 122 +export const LEVEL_HALLSOFDEATHSCALLING = 123 +export const LEVEL_HALLSOFVAUGHT = 124 +export const LEVEL_HELL1 = 125 +export const LEVEL_HELL2 = 126 +export const LEVEL_HELL3 = 127 +export const LEVEL_THEWORLDSTONEKEEPLEV1 = 128 +export const LEVEL_THEWORLDSTONEKEEPLEV2 = 129 +export const LEVEL_THEWORLDSTONEKEEPLEV3 = 130 +export const LEVEL_THRONEOFDESTRUCTION = 131 +export const LEVEL_THEWORLDSTONECHAMBER = 132 +export const LEVEL_PANDEMONIUMRUN1 = 133 +export const LEVEL_PANDEMONIUMRUN2 = 134 +export const LEVEL_PANDEMONIUMRUN3 = 135 +export const LEVEL_PANDEMONIUMFINALE = 136 // --------------------------------------------------------------------------------------------- // Structures @@ -591,6 +699,8 @@ export interface D2DrlgLevelStrc { /** 0x21C: file round robin of DRLGOUTDOORS_SpawnOutdoorLevelPresetEx (nPickedFile == -1). */ pBuild: D2DrlgBuildStrc | null pPresetMaps: Int32Array | null + pJungleDefs: Int32Array | null + nJungleDefs: number pNextLevel: D2DrlgLevelStrc | null } diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts index 2ec486c..ef42c8b 100644 --- a/src/game/maze-special-passes.ts +++ b/src/game/maze-special-passes.ts @@ -254,11 +254,12 @@ export function placeNamedPiece( const distMap = bfsDistances(ctx.level) // 2. Phase 1: In-place replacement - const wantedMask = plainSides !== undefined && plainSides !== '' ? sideMaskFromToken(plainSides) : null + const effectivePlainSides = plainSides !== undefined && plainSides !== '' ? plainSides : token + const wantedMask = effectivePlainSides !== '' ? sideMaskFromToken(effectivePlainSides) : null const candidates = ctx.level.rooms.filter(r => { if (r.hasDs1) return false - if (wantedMask !== null && roomSideMask(r) !== wantedMask) return false - return true + if (wantedMask !== null) return roomSideMask(r) === wantedMask + return r.orths.length <= 1 }) if (candidates.length > 0) { @@ -288,19 +289,50 @@ export function placeNamedPiece( return isExit ? db - da : da - db }) - const primaryDir = options.direction ?? DIRECTION_NORTH + const GROW_DIR_FOR_SIDE: Readonly> = { + N: DIRECTION_SOUTH, + E: DIRECTION_WEST, + S: DIRECTION_NORTH, + W: DIRECTION_EAST, + NS: DIRECTION_NORTH, + EW: DIRECTION_WEST, + } + const SIDE_FOR_GROW_DIR: Readonly> = { + [DIRECTION_SOUTH]: 'N', + [DIRECTION_WEST]: 'E', + [DIRECTION_NORTH]: 'S', + [DIRECTION_EAST]: 'W', + } + + const primaryDir = options.direction ?? GROW_DIR_FOR_SIDE[token] ?? DIRECTION_NORTH const tryDirs = [primaryDir, (primaryDir + 1) % 4, (primaryDir + 2) % 4, (primaryDir + 3) % 4] - for (const parent of parents) { - for (const dir of tryDirs) { + const resolvePieceForSide = (dirSide: string): MazePiece | undefined => { + if (dirSide === token) return piece + if (namePrefix !== undefined) { + const fullName = `${namePrefix} ${dirSide}` + const byPrefix = ctx.pieces.find(p => p.name === fullName) + ?? ctx.pieces.find(p => p.name.toLowerCase() === fullName.toLowerCase()) + if (byPrefix !== undefined) return byPrefix + } + const bySpecial = findSpecial(ctx.pieces, kind, dirSide) + if (bySpecial !== undefined) return bySpecial + if (token === '' || (token.length > 1 && token.includes(dirSide))) return piece + return undefined + } + + for (const dir of tryDirs) { + const dirSide = SIDE_FOR_GROW_DIR[dir] ?? token + const dirPiece = resolvePieceForSide(dirSide) ?? piece + for (const parent of parents) { const wasHasDs1 = parent.hasDs1 parent.hasDs1 = false const created = addAdjacentMazeRoom(ctx.level, parent, dir, false, true, ctx.index, ctx.stats) if (created !== null) { - created.piece = piece + created.piece = dirPiece created.variant = 0 created.hasDs1 = true - noteSpecial(created, piece, token, ctx.stats) + noteSpecial(created, dirPiece, dirPiece.sides || dirSide, ctx.stats) return created } parent.hasDs1 = wasHasDs1 @@ -765,8 +797,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 +813,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/maze.ts b/src/game/maze.ts index 4cc7d8a..b2cb92a 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -1838,11 +1838,12 @@ function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceI room.variant = variant // The table's direction is the direction *from the new room to the entrance*, // so the new room sits on the opposite side. - const { dx, dy } = step(direction, level.first.width, level.first.height) + const opposite = (direction - 2) & 3 + const { dx, dy } = step(opposite, level.first.width, level.first.height) room.x = level.first.x + dx room.y = level.first.y + dy if (!checkRoomNotOverlapping(level, room, level.first)) continue - linkRooms(level.first, room, direction) + linkRooms(level.first, room, opposite) level.rooms.unshift(room) pickRoomPreset(level.first, index, stats) const piece = pieces.find(candidate => candidate.name === name) @@ -2386,6 +2387,19 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean, effectiveA for (let x = 0; x < spanX; x += 1) { const cell = source[x] if (cell === undefined) continue + if (x >= room.width || y >= room.height) { + const isOverhangLavaOrBlank = + cell.walls.every(w => w.prop1 === 0) + && cell.floors.every(f => f.hidden || f.prop1 === 0 || (f.style === 20 && (f.unknown1 & 8) !== 0)) + const existing = target[originX + x] + if ( + isOverhangLavaOrBlank + && existing !== undefined + && (existing.floors.some(f => !f.hidden && f.prop1 !== 0) || existing.walls.some(w => w.prop1 !== 0)) + ) { + continue + } + } // 1.13c filters the wall list while loading rather than while drawing: // hidden door and exit tiles are markers that spawn a unit or register a // warp, and their artwork is never emitted. 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/game/objects.ts b/src/game/objects.ts index ff3ac26..89a6e27 100644 --- a/src/game/objects.ts +++ b/src/game/objects.ts @@ -506,9 +506,16 @@ export function resolveDs1Object( if (objectType !== OBJECT_TYPE_OBJECT) { if (objectType === OBJECT_TYPE_MONSTER && monsters !== undefined) { const presets = monsters.presetPlaceByAct.get(String(act)) - if (presets !== undefined && objectId >= 0 && objectId < presets.length) { - const place = presets[objectId]! - const statRow = monsters.statsById.get(place) + let place = presets !== undefined && objectId >= 0 && objectId < presets.length ? presets[objectId] : undefined + let statRow = place !== undefined ? monsters.statsById.get(place) : undefined + if (statRow === undefined && objectId >= 1000) { + const targetHcIdx = String(objectId - 1000) + statRow = monsters.stats.rows.find(r => cell(monsters.stats, r, 'hcIdx').trim() === targetHcIdx) + if (statRow !== undefined && place === undefined) { + place = cell(monsters.stats, statRow, 'Id').trim() + } + } + if (place !== undefined) { // `Code` is spelled inconsistently in the shipped table — `K9` on one row, // `k9` on another, `ja` lower-cased — while the art directories are indexed // upper-cased. Canonicalise here so one monster is never emitted under two diff --git a/src/game/wilderness-acts.ts b/src/game/wilderness-acts.ts deleted file mode 100644 index faf6e41..0000000 --- a/src/game/wilderness-acts.ts +++ /dev/null @@ -1,1139 +0,0 @@ -/** - * Wilderness outdoor topography initializers for Acts 2, 3, 4, and 5. - * - * blizzard Drlg outdoor passes: - * - `DRLGOUTDESR_InitAct2OutdoorLevel`: Desert canyons, sand trails, Far Oasis pools, - * Valley of Snakes canyon gorges, Canyon of the Magi 7 Tal Rasha tomb entrances. - * - `DRLGOUTPLACE_InitAct3OutdoorLevel` & `DRLG_GenerateJungles`: Jungle waterways, - * wooden river bridges spanning channels, causeways, Kurast city avenues, canals & bridges. - * - `DRLGOUTDOORS_InitAct4OutdoorLevel`: Molten lava chasms and fissures, natural rock - * bridges spanning chasms, volcanic mesa plateaus. - * - `DRLGOUTSIEGE_InitAct5OutdoorLevel`: Transverse siege barricade palisades, choke-point - * gates, military dugout trenches, prisoner cages, and snow crater frozen lakes. - */ - -import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' -import { Rng } from './rng.ts' -import type { - Canvas, - WildernessRequest, - WildernessStats, - WildernessPiece, - PlacedPresetRect, -} from './wilderness.ts' -import { stampDs1, TILES_PER_BLOCK, emptyFloor, emptyWall } from './wilderness.ts' -import { SUB_TILES_PER_TILE } from './map.ts' - -/* ------------------------------------------------------------------------- * - * Statistics interfaces - * ------------------------------------------------------------------------- */ - -export interface Act2TopographyStats { - readonly canyonWalls: number - readonly sandTrailCells: number - readonly oasisPoolCells: number - readonly tombEntrances: number -} - -export interface Act3TopographyStats { - readonly causewayCells: number - readonly riverBridges: number - readonly canalCells: number - readonly junglePathCells: number -} - -export interface Act4TopographyStats { - readonly lavaChasmCells: number - readonly rockyBridges: number - readonly plateauCells: number -} - -export interface Act5TopographyStats { - readonly barricadeWalls: number - readonly trenchCells: number - readonly frozenLakeCells: number -} - -/* ------------------------------------------------------------------------- * - * Geometry & Canvas Helpers - * ------------------------------------------------------------------------- */ - -/** - * Draw a bounded fractional jitter value in [base - delta, base + delta], clamped to [0.01, 0.99]. - */ -export function jitterFrac(rng: Rng, base: number, delta = 0.05): number { - return rng.range(Math.max(0.01, base - delta), Math.min(0.99, base + delta)) -} - -/** - * Act 2 Oasis harmonic radial lobe perturbation: - * r(theta) = R * (1 + 0.24 * sin(3 * theta + phi1) + 0.08 * cos(5 * theta + phi2)) - */ -export function act2OasisRadius(R: number, theta: number, phi1: number, phi2: number): number { - return R * (1 + 0.24 * Math.sin(3 * theta + phi1) + 0.08 * Math.cos(5 * theta + phi2)) -} - -/** - * Act 5 Frozen Lake 4-lobe harmonic radial lobe perturbation: - * r(theta) = R * (1 + 0.25 * cos(4 * theta + phi1) + 0.10 * sin(2 * theta + phi2)) - */ -export function act5FrozenLakeRadius(R: number, theta: number, phi1: number, phi2: number): number { - return R * (1 + 0.25 * Math.cos(4 * theta + phi1) + 0.10 * Math.sin(2 * theta + phi2)) -} - - -function isInsidePreset( - x: number, - y: number, - placedRects: readonly PlacedPresetRect[], - margin = 1, -): boolean { - for (const r of placedRects) { - if ( - x >= r.x - margin && - x < r.x + r.w + margin && - y >= r.y - margin && - y < r.y + r.h + margin - ) { - return true - } - } - return false -} - -function makeFloor(style: number, sequence: number, prop1 = 2): Ds1Floor { - return { - prop1, - style, - sequence, - unknown1: 0, - unknown2: 0, - hidden: false, - } -} - -function makeWall(style: number, sequence: number, type: number, prop1 = 129): Ds1Wall { - return { - prop1, - sequence, - style, - type, - unknown1: 0, - unknown2: 0, - hidden: false, - } -} - -function paintFloor( - canvas: Canvas, - x: number, - y: number, - floor: Ds1Floor, - overwriteProtected = false, -): boolean { - if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false - const cell = canvas.cells[y]?.[x] - if (!cell) return false - const existing = cell.floors[0] - if (!overwriteProtected && existing) { - // Protected styles: water (style 2/3), wooden bridge (style 4) - if (existing.style === 2 || existing.style === 3 || existing.style === 4) { - return false - } - } - ;(cell.floors as Ds1Floor[])[0] = floor - return true -} - -function paintWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): boolean { - if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false - const cell = canvas.cells[y]?.[x] - if (!cell) return false - const targetWalls = cell.walls as Ds1Wall[] - if (targetWalls.length > 0) { - targetWalls[0] = wall - } else { - targetWalls.push(wall) - } - return true -} - -function clearWalls(canvas: Canvas, x: number, y: number): void { - if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return - const cell = canvas.cells[y]?.[x] - if (cell) { - ;(cell.walls as Ds1Wall[]).length = 0 - } -} - -/* ------------------------------------------------------------------------- * - * Act 2: DRLGOUTDESR_InitAct2OutdoorLevel - * ------------------------------------------------------------------------- */ - -/** - * Corresponds to Blizzard DRLGOUTDESR_InitAct2OutdoorLevel (D2Common.0x6FD7D430). - * - * Initialize Act 2 Desert Outdoor Topography: - * - Rocky canyon ridges and cliff berms carving winding desert corridors - * (approximates Blizzard DRLGOUTDESR_PlaceCliffs / D2Common.0x6FD7D950). - * - 3-tile-wide sand caravan trails connecting gates across the dunes. - * - Far Oasis (Level 43): lush water oasis pools and Maggot Lair entrance - * (approximates Blizzard DRLGOUTDESR_PlaceFillsInFarOasis / D2Common.0x6FD7DA60). - * - Valley of Snakes (Level 45): narrow canyon gorge leading to Claw Viper Temple - * (coordinates with Blizzard DRLGOUTDESR_AddExits / D2Common.0x6FD7D9F0). - * - Canyon of the Magi (Level 46): 7 Tal Rasha tomb entrances along perimeter cliffs - * and central Arcane Sanctuary warp platform (corresponds to Blizzard - * DRLGOUTDESR_PlaceTombEntriesInCanyon / D2Common.0x6FD7DB70 and - * DRLGOUTDESR_PlaceFillsInCanyon / D2Common.0x6FD7DBC0). - * - Lost City (Level 44): ancient buried city ruins and street corridors (corresponds - * to Blizzard DRLGOUTDESR_PlaceRuinsInLostCity / D2Common.0x6FD7DAC0 and - * DRLGOUTDESR_PlaceFillsInLostCity / D2Common.0x6FD7DB00). - */ -export function initAct2OutdoorLevel( - canvas: Canvas, - request: WildernessRequest, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: PlacedPresetRect[], - rng: Rng, - stats: WildernessStats, -): Act2TopographyStats { - const topoRng = rng.fork('act2-topography') - let canyonWalls = 0 - let sandTrailCells = 0 - let oasisPoolCells = 0 - let tombEntrances = 0 - - const levelId = request.levelId - const hubX = inX * TILES_PER_BLOCK + 4 - const hubY = outY * TILES_PER_BLOCK + 4 - - // Helper to paint a 3-tile-wide sand caravan trail - const paintSandTrailTile = (x: number, y: number): void => { - if (x < 3 || x >= canvas.width - 3 || y < 3 || y >= canvas.height - 3) return - if (isInsidePreset(x, y, placedRects, 0)) return - const cell = canvas.cells[y]?.[x] - if (!cell) return - // Style 5 (desert road), prop1 194 - const floor = makeFloor(5, topoRng.int(0, 4) === 0 ? 25 : 0, 194) - if (paintFloor(canvas, x, y, floor, false)) { - clearWalls(canvas, x, y) - sandTrailCells += 1 - } - } - - // 1. Sand Caravan Trail Network: North-South and West-East axes - if (levelId !== 46) { - const startY = Math.max(4, Math.floor(canvas.height * jitterFrac(topoRng, 0.1))) - const endY = Math.min(canvas.height - 5, Math.floor(canvas.height * jitterFrac(topoRng, 0.9))) - for (let y = startY; y <= endY; y += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - paintSandTrailTile(hubX + dx, y) - } - } - - const startX = Math.max(4, Math.floor(canvas.width * jitterFrac(topoRng, 0.1))) - const endX = Math.min(canvas.width - 5, Math.floor(canvas.width * jitterFrac(topoRng, 0.9))) - for (let x = startX; x <= endX; x += 1) { - for (let dy = -1; dy <= 1; dy += 1) { - paintSandTrailTile(x, hubY + dy) - } - } - } - - // 2. Canyon Walls & Rocky Berms (Levels 41, 42, 44, 45) - if (levelId === 41 || levelId === 42 || levelId === 44 || levelId === 45) { - const isValleyOfSnakes = levelId === 45 - // In Valley of Snakes, place 2 tight parallel canyon ridge walls (corridor of 10-12 tiles) - // In other deserts, place transverse & longitudinal canyon ridges - const ridgeOffset1 = isValleyOfSnakes ? 5 : Math.round(canvas.height * jitterFrac(topoRng, 0.20)) - const ridgeOffset2 = isValleyOfSnakes ? 6 : Math.round(canvas.height * jitterFrac(topoRng, 0.20)) - const ridgeY1 = Math.max(12, Math.min(canvas.height - 14, hubY - ridgeOffset1)) - const ridgeY2 = Math.max(14, Math.min(canvas.height - 12, hubY + ridgeOffset2)) - - for (let x = 10; x < canvas.width - 10; x += 1) { - // Leave 4-tile wide canyon pass gaps where the main highway or crossroads pass - if (Math.abs(x - hubX) <= 3) continue - if (isInsidePreset(x, ridgeY1, placedRects, 2)) continue - if (paintWall(canvas, x, ridgeY1, makeWall(1, 0, 2, 129))) { - canyonWalls += 1 - } - } - - for (let x = 10; x < canvas.width - 10; x += 1) { - if (Math.abs(x - hubX) <= 3) continue - if (isInsidePreset(x, ridgeY2, placedRects, 2)) continue - if (paintWall(canvas, x, ridgeY2, makeWall(1, 0, 2, 129))) { - canyonWalls += 1 - } - } - - if (!isValleyOfSnakes) { - const ridgeOffsetX = Math.round(canvas.width * jitterFrac(topoRng, 0.20)) - const ridgeX = Math.max(12, Math.min(canvas.width - 12, hubX + ridgeOffsetX)) - for (let y = 10; y < canvas.height - 10; y += 1) { - if (Math.abs(y - hubY) <= 3) continue - if (isInsidePreset(ridgeX, y, placedRects, 2)) continue - if (paintWall(canvas, ridgeX, y, makeWall(1, 0, 1, 129))) { - canyonWalls += 1 - } - } - } - } - - // 3. Far Oasis (Level 43): Lush Water Oasis Pools & Maggot Lair - if (levelId === 43 || /Oasis/i.test(request.levelName)) { - const poolCenters = [ - { - cx: Math.max(14, Math.floor(canvas.width * jitterFrac(topoRng, 0.35))), - cy: Math.max(14, Math.floor(canvas.height * jitterFrac(topoRng, 0.35))), - radius: Math.max(5, Math.floor(canvas.width * topoRng.range(1 / 12 - 0.02, 1 / 12 + 0.02))), - phi1: topoRng.range(0, 2 * Math.PI), - phi2: topoRng.range(0, 2 * Math.PI), - }, - { - cx: Math.min(canvas.width - 16, Math.floor(canvas.width * jitterFrac(topoRng, 0.68))), - cy: Math.min(canvas.height - 16, Math.floor(canvas.height * jitterFrac(topoRng, 0.68))), - radius: Math.max(4, Math.floor(canvas.width * topoRng.range(1 / 14 - 0.02, 1 / 14 + 0.02))), - phi1: topoRng.range(0, 2 * Math.PI), - phi2: topoRng.range(0, 2 * Math.PI), - }, - ] - - for (const pool of poolCenters) { - const maxR = Math.ceil(pool.radius * 1.35) + 1 - for (let dy = -maxR; dy <= maxR; dy += 1) { - for (let dx = -maxR; dx <= maxR; dx += 1) { - const dist = Math.hypot(dx, dy) - const theta = Math.atan2(dy, dx) - const rLobe = act2OasisRadius(pool.radius, theta, pool.phi1, pool.phi2) - if (dist <= rLobe) { - const px = pool.cx + dx - const py = pool.cy + dy - if (px >= 4 && px < canvas.width - 4 && py >= 4 && py < canvas.height - 4) { - if (!isInsidePreset(px, py, placedRects, 1)) { - // Water floor: style 2 (water), prop1 2 - paintFloor(canvas, px, py, makeFloor(2, 0, 2), true) - clearWalls(canvas, px, py) - oasisPoolCells += 1 - } - } - } - } - } - } - - // Try to stamp Desert Oasis preset if available in pieces - const oasisPiece = request.pieces.find(p => /Desert Oasis/i.test(p.name)) - if (oasisPiece?.levels[0] && !stats.specialPresets.some(n => /Desert Oasis/i.test(n))) { - const ds1 = oasisPiece.levels[0] - const originX = Math.max(8, Math.floor(canvas.width * jitterFrac(topoRng, 0.4)) - Math.floor(ds1.width / 2)) - const originY = Math.max(8, Math.floor(canvas.height * jitterFrac(topoRng, 0.4)) - Math.floor(ds1.height / 2)) - stampDs1(canvas, ds1, originX, originY) - stats.specialPresets.push(oasisPiece.name) - stats.substitutions.push({ name: oasisPiece.name, role: 'object', enabled: true, clusters: 1 }) - placedRects.push({ name: oasisPiece.name, x: originX, y: originY, w: ds1.width, h: ds1.height }) - } - } - - // 4. Canyon of the Magi / Valley of the Kings (Level 46): 7 Tal Rasha Tomb Entrances - if (levelId === 46 || /Canyon of the Magi|Valley of the Kings/i.test(request.levelName)) { - const tombCoords: Array<{ edge: 'top' | 'left' | 'right'; x: number; y: number }> = [ - // Top / North Cliff: 3 tombs - { edge: 'top', x: Math.floor(canvas.width * jitterFrac(topoRng, 0.22)), y: 1 }, - { edge: 'top', x: Math.floor(canvas.width * jitterFrac(topoRng, 0.5)), y: 1 }, - { edge: 'top', x: Math.floor(canvas.width * jitterFrac(topoRng, 0.78)), y: 1 }, - // Left / West Cliff: 2 tombs - { edge: 'left', x: 1, y: Math.floor(canvas.height * jitterFrac(topoRng, 0.35)) }, - { edge: 'left', x: 1, y: Math.floor(canvas.height * jitterFrac(topoRng, 0.68)) }, - // Right / East Cliff: 2 tombs - { edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * jitterFrac(topoRng, 0.35)) }, - { edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * jitterFrac(topoRng, 0.68)) }, - ] - - for (let i = 0; i < tombCoords.length; i += 1) { - const tc = tombCoords[i]! - // Try to find matching cliff king tomb piece - let pieceName = 'Act 2 - Desert Cliff Top King Tomb' - if (tc.edge === 'left') pieceName = 'Act 2 - Desert Cliff Left King Tomb' - else if (tc.edge === 'right') pieceName = 'Act 2 - Desert Cliff Right King Tomb' - - const piece = request.pieces.find(p => p.name.trim() === pieceName.trim()) ?? - request.pieces.find(p => /King Tomb|Desert Tomb/i.test(p.name)) - - if (piece?.levels[0]) { - stampDs1(canvas, piece.levels[0], tc.x, tc.y) - stats.specialPresets.push(piece.name) - placedRects.push({ name: piece.name, x: tc.x, y: tc.y, w: piece.levels[0].width, h: piece.levels[0].height }) - } else { - // Construct canonical tomb facade archway (style 2 wall, width 6, height 4) - for (let dy = 0; dy < 4; dy += 1) { - for (let dx = 0; dx < 6; dx += 1) { - const tx = tc.x + dx - const ty = tc.y + dy - if (tx >= 0 && tx < canvas.width && ty >= 0 && ty < canvas.height) { - if (dy === 3 && (dx === 2 || dx === 3)) { - // Open portal threshold - paintFloor(canvas, tx, ty, makeFloor(0, 0, 2)) - clearWalls(canvas, tx, ty) - } else { - paintWall(canvas, tx, ty, makeWall(2, dy === 0 ? 0 : 1, 2, 129)) - } - } - } - } - } - // Add Tal Rasha Tomb entrance portal object (LevelId 66..72) - canvas.objects.push({ - type: 2, - id: 66 + i, - x: (tc.x + 3) * SUB_TILES_PER_TILE, - y: (tc.y + 3) * SUB_TILES_PER_TILE, - flags: 0, - }) - tombEntrances += 1 - } - - // Central Arcane Sanctuary warp platform (Desert Valley Warp) - const warpPiece = request.pieces.find(p => /Desert Valley Warp/i.test(p.name)) - const warpX = Math.floor(canvas.width * jitterFrac(topoRng, 0.5)) - 4 - const warpY = Math.floor(canvas.height * jitterFrac(topoRng, 0.5)) - 4 - if (warpPiece?.levels[0]) { - stampDs1(canvas, warpPiece.levels[0], warpX, warpY) - stats.specialPresets.push(warpPiece.name) - placedRects.push({ name: warpPiece.name, x: warpX, y: warpY, w: warpPiece.levels[0].width, h: warpPiece.levels[0].height }) - } else { - for (let dy = 0; dy < 8; dy += 1) { - for (let dx = 0; dx < 8; dx += 1) { - paintFloor(canvas, warpX + dx, warpY + dy, makeFloor(3, 0, 194)) - clearWalls(canvas, warpX + dx, warpY + dy) - } - } - canvas.objects.push({ - type: 2, - id: 74, - x: (warpX + 4) * SUB_TILES_PER_TILE, - y: (warpY + 4) * SUB_TILES_PER_TILE, - flags: 0, - }) - } - } - - // 5. Lost City (Level 44): Ancient Buried City Ruins - if (levelId === 44 || /Lost City/i.test(request.levelName)) { - const ruinsPieces = request.pieces.filter(p => /Desert Ruins/i.test(p.name) && p.levels.length > 0) - if (ruinsPieces.length > 0) { - let placedCount = 0 - for (let by = 2; by < gridHeight - 3 && placedCount < 4; by += 3) { - for (let bx = 2; bx < gridWidth - 3 && placedCount < 4; bx += 3) { - const rx = bx * TILES_PER_BLOCK - const ry = by * TILES_PER_BLOCK - if (isInsidePreset(rx, ry, placedRects, 2)) continue - const piece = ruinsPieces[placedCount % ruinsPieces.length]! - const ds1 = piece.levels[0]! - stampDs1(canvas, ds1, rx, ry) - placedRects.push({ name: piece.name, x: rx, y: ry, w: ds1.width, h: ds1.height }) - stats.specialPresets.push(piece.name) - placedCount += 1 - } - } - } - } - - stats.act2Topography = { canyonWalls, sandTrailCells, oasisPoolCells, tombEntrances } - stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + canyonWalls - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + sandTrailCells - stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + oasisPoolCells - stats.notes.push( - `Act 2 Desert Topography: ${String(canyonWalls)} canyon walls, ${String(sandTrailCells)} sand trail cells, ${String(oasisPoolCells)} oasis cells, ${String(tombEntrances)} tomb entrances`, - ) - - return stats.act2Topography -} - -/* ------------------------------------------------------------------------- * - * Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles - * ------------------------------------------------------------------------- */ - - -/** - * Stamp Kurast Causeway (LevelId 82): - * A fortified stone causeway bridge spanning the canal between Upper Kurast and Travincal. - */ -export function stampAct3KurastCauseway( - canvas: Canvas, - pieces: readonly WildernessPiece[], - rng: Rng, - stats: WildernessStats, - specialPlacedRects?: PlacedPresetRect[], -): Set { - if (!(rng instanceof Rng)) { - throw new Error('stampAct3KurastCauseway: missing required Rng instance') - } - const topoRng = rng.fork('act3-topography') - const placed = new Set() - const piece = pieces.find(p => p.name === 'Act 3 - Bridge') - const ds1 = piece?.levels[0] - - if (ds1) { - stampDs1(canvas, ds1, 0, 0) - placed.add('Act 3 - Bridge') - stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 }) - if (specialPlacedRects) { - specialPlacedRects.push({ - x: 0, - y: 0, - w: ds1.width, - h: ds1.height, - name: 'Act 3 - Bridge', - }) - } - } - - // Construct / ensure canonical causeway layout: - // Central paved walkway flanked by canal water and balustrades - let causewayCells = 0 - let canalCells = 0 - let balustradeCells = 0 - const walkwayCenterFrac = jitterFrac(topoRng, 0.50) - const walkwayLeft = Math.max(2, Math.min(canvas.width - 10, Math.floor(canvas.width * walkwayCenterFrac) - 4)) - const walkwayRight = walkwayLeft + 7 - - for (let y = 0; y < canvas.height; y += 1) { - for (let x = 0; x < canvas.width; x += 1) { - if (ds1 && x >= 0 && x < ds1.width && y >= 0 && y < ds1.height) { - const ds1Cell = ds1.cells[y]?.[x] - const hasFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden - const hasWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden - if (hasFloor || hasWall) { - // Keep artist-placed preset tiles from ds1; do not destructively wipe - continue - } - } - if (x >= walkwayLeft && x <= walkwayRight) { - // Paved stone causeway: style 20, prop1 194 - paintFloor(canvas, x, y, makeFloor(20, 0, 194), true) - clearWalls(canvas, x, y) - causewayCells += 1 - } else if (x === walkwayLeft - 1 || x === walkwayRight + 1) { - // Stone balustrade wall: style 20, prop1 129 - const wallType = x === walkwayLeft - 1 ? 1 : 2 - paintWall(canvas, x, y, makeWall(20, 0, wallType, 129)) - balustradeCells += 1 - } else { - // Deep canal water: style 2, sequence 0, prop1 2 - paintFloor(canvas, x, y, makeFloor(2, 0, 2), true) - clearWalls(canvas, x, y) - canalCells += 1 - } - } - } - - stats.act3Topography = { - causewayCells, - riverBridges: 1, - canalCells, - junglePathCells: 0, - } - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + causewayCells - stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + canalCells - stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + balustradeCells - stats.notes.push( - `Act 3 Causeway Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal water cells`, - ) - - return placed -} - -/** - * Initialize Act 3 Kurast Urban Districts (Levels 79 Slums, 80 Burbs, 81 Metro). - * - * Corresponds to Blizzard DRLG subsystem algorithms: - * - DRLGOUTJUNG_BuildLowerKurast (D2Common.0x6FD7FE50) for Level 79 (Slums) - * - DRLGOUTJUNG_BuildKurastBazaar (D2Common.0x6FD7FFA0) for Level 80 (Burbs) - * - DRLGOUTJUNG_BuildUpperKurast (D2Common.0x6FD800E0) for Level 81 (Metro) - * - * Urban layout features: - * - Paved stone causeways and grand avenues connecting gates and district plazas. - * - Stone canals and water channels dividing the districts with stone bridges. - * - Temples, sewers, and district ruins layout. - */ -export function initAct3OutdoorLevel( - canvas: Canvas, - request: WildernessRequest, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: PlacedPresetRect[], - rng: Rng, - stats: WildernessStats, -): Act3TopographyStats { - const topoRng = rng.fork('act3-topography') - let causewayCells = 0 - let riverBridges = 0 - let canalCells = 0 - let embankmentWalls = 0 - const junglePathCells = 0 - - const hubX = inX * TILES_PER_BLOCK + 4 - const hubY = outY * TILES_PER_BLOCK + 4 - - // 1. Paved Stone Avenues (3-tile-wide North-South & East-West) - for (let y = 6; y < canvas.height - 6; y += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - const x = hubX + dx - if (!isInsidePreset(x, y, placedRects, 0)) { - paintFloor(canvas, x, y, makeFloor(20, 0, 194)) - clearWalls(canvas, x, y) - causewayCells += 1 - } - } - } - - for (let x = 6; x < canvas.width - 6; x += 1) { - for (let dy = -1; dy <= 1; dy += 1) { - const y = hubY + dy - if (!isInsidePreset(x, y, placedRects, 0)) { - paintFloor(canvas, x, y, makeFloor(20, 0, 194)) - clearWalls(canvas, x, y) - causewayCells += 1 - } - } - } - - // 2. Stone Canals & Drainage Channels with gentle sinusoidal meandering - const canalCenterFrac = jitterFrac(topoRng, 0.55) - const canalBaseX = Math.max(12, Math.min(canvas.width - 16, Math.floor(canvas.width * canalCenterFrac))) - const canalMeanderAmp = topoRng.range(1.5, 3.0) - const canalPhase = topoRng.range(0, 2 * Math.PI) - const bridgeY = Math.max(10, Math.min(canvas.height - 10, hubY + topoRng.int(-1, 1))) - - for (let y = 8; y < canvas.height - 8; y += 1) { - const canalX = Math.max( - 12, - Math.min( - canvas.width - 16, - Math.floor(canalBaseX + Math.sin((y / canvas.height) * Math.PI * 2 + canalPhase) * canalMeanderAmp), - ), - ) - // Stone Bridge crossing at East-West avenue - if (Math.abs(y - bridgeY) <= 2) { - for (let dx = 0; dx < 4; dx += 1) { - paintFloor(canvas, canalX + dx, y, makeFloor(20, 0, 194), true) - clearWalls(canvas, canalX + dx, y) - } - riverBridges += 1 - } else { - // Water channel: style 2, prop1 2 - for (let dx = 0; dx < 4; dx += 1) { - const x = canalX + dx - if (!isInsidePreset(x, y, placedRects, 1)) { - paintFloor(canvas, x, y, makeFloor(2, 0, 2), true) - clearWalls(canvas, x, y) - canalCells += 1 - } - } - // Stone canal embankments - paintWall(canvas, canalX - 1, y, makeWall(20, 0, 1, 129)) - paintWall(canvas, canalX + 4, y, makeWall(20, 0, 2, 129)) - embankmentWalls += 2 - } - } - - // 3. Temple Island / Plaza Bounds - const templePiece = request.pieces.find(p => /Temple/i.test(p.name)) - if (templePiece?.levels[0] && !stats.specialPresets.some(n => /Temple/i.test(n))) { - const ds1 = templePiece.levels[0] - const xFrac = jitterFrac(topoRng, 0.50) - const yFrac = jitterFrac(topoRng, 0.50) - const originX = Math.max(8, Math.min(canvas.width - ds1.width - 8, Math.floor(canvas.width * xFrac) - Math.floor(ds1.width / 2))) - const originY = Math.max(8, Math.min(canvas.height - ds1.height - 8, Math.floor(canvas.height * yFrac) - Math.floor(ds1.height / 2))) - stampDs1(canvas, ds1, originX, originY) - placedRects.push({ name: templePiece.name, x: originX, y: originY, w: ds1.width, h: ds1.height }) - stats.specialPresets.push(templePiece.name) - } - - stats.act3Topography = { causewayCells, riverBridges, canalCells, junglePathCells } - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + causewayCells - stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + canalCells - stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + embankmentWalls - stats.notes.push( - `Act 3 Kurast Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal cells, ${String(riverBridges)} bridges`, - ) - - return stats.act3Topography -} - -/* ------------------------------------------------------------------------- * - * Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel - * ------------------------------------------------------------------------- */ - -/** - * Initialize Act 4 Outdoor Topography (Levels 104 Outer Steppes, 105 Plains of Despair, 106 City of the Damned): - * - Deep glowing lava chasms and fissures cutting through volcanic basalt plateaus. - * - Solid rocky bridge causeways spanning the chasms, connecting separated plateaus. - * - Volcanic mesa plateau formations and Izual prison plateau. - */ -export function initAct4OutdoorLevel( - canvas: Canvas, - request: WildernessRequest, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: PlacedPresetRect[], - rng: Rng, - stats: WildernessStats, -): Act4TopographyStats { - const topoRng = rng.fork('act4-topography') - let lavaChasmCells = 0 - let rockyBridges = 0 - let plateauCells = 0 - - const width = canvas.width - const height = canvas.height - const hubX = inX * TILES_PER_BLOCK + 4 - const hubY = outY * TILES_PER_BLOCK + 4 - - // 1. Lava Fissure: A bounded random walk diagonal chasm dividing the level into plateaus - const startFrac = jitterFrac(topoRng, 0.35) - const endFrac = jitterFrac(topoRng, 0.65) - const startY = Math.floor(height * startFrac) - const endY = Math.floor(height * endFrac) - - const chasmY: number[] = new Array(width).fill(0) - let curY = startY - for (let x = 8; x < width - 8; x += 1) { - const t = (x - 8) / (width - 16) - const baseY = Math.floor(startY + t * (endY - startY)) - if (x === 8) { - curY = startY - } else { - const step = topoRng.int(-1, 1) - const diff = baseY - curY - const drift = Math.abs(diff) >= 4 ? Math.sign(diff) : 0 - curY = Math.max(6, Math.min(height - 7, Math.max(baseY - 6, Math.min(baseY + 6, curY + step + drift)))) - } - chasmY[x] = curY - } - - const chasmYAtX = (x: number): number => { - return chasmY[x] ?? Math.floor(height * (0.35 + 0.3 * (x / width))) - } - - // Strategic rocky bridge crossing X coordinates (width >= 3 tiles) - const bridgeXPositions = [ - Math.max(12, Math.floor(width * jitterFrac(topoRng, 0.30))), - Math.max(16, Math.min(width - 16, hubX + topoRng.int(-2, 2))), - Math.min(width - 12, Math.floor(width * jitterFrac(topoRng, 0.70))), - ] - - const isBridgeX = (x: number): boolean => bridgeXPositions.some(bx => Math.abs(x - bx) <= 1) - - for (let x = 8; x < width - 8; x += 1) { - const centerY = chasmYAtX(x) - const onBridge = isBridgeX(x) - - if (onBridge) { - // Solid rock bridge across the chasm: style 10, sequence 0, prop1 2 - // Spans beyond the basalt lips (-4 to 4) to ensure seamless connection - for (let dy = -4; dy <= 4; dy += 1) { - const y = centerY + dy - if (y >= 0 && y < height) { - paintFloor(canvas, x, y, makeFloor(10, 0, 2), true) - clearWalls(canvas, x, y) - } - } - rockyBridges += 1 - } else { - // Boiling lava chasm for Act 4 - Lava, or rocky canyon floor for Act 4 - Mesa (style 10) - const isMesa = request.levelTypeName === 'Act 4 - Mesa' || (request.levelId >= 104 && request.levelId <= 106) - const isLava = request.levelTypeName === 'Act 4 - Lava' && !isMesa - const chasmStyle = isLava ? 20 : 10 - const chasmSeq = isLava ? 3 : 0 - for (let dy = -2; dy <= 2; dy += 1) { - const y = centerY + dy - if (y >= 0 && y < height) { - if (!isInsidePreset(x, y, placedRects, 1)) { - paintFloor(canvas, x, y, makeFloor(chasmStyle, chasmSeq, 2), true) - clearWalls(canvas, x, y) - lavaChasmCells += 1 - } - } - } - // Basalt chasm edge lips - paintWall(canvas, x, centerY - 3, makeWall(10, 0, 2, 129)) - paintWall(canvas, x, centerY + 3, makeWall(10, 0, 2, 129)) - } - } - - // 2. Basalt Plateau Ridges / Demonic Altar Formations - if (request.levelId === 105) { - // Plains of Despair: Izual's prison plateau in center - const px0 = hubX - 5 + topoRng.int(-1, 1) - const py0 = hubY - 5 + topoRng.int(-1, 1) - for (let dy = 0; dy < 10; dy += 1) { - for (let dx = 0; dx < 10; dx += 1) { - const x = px0 + dx - const y = py0 + dy - if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) { - if (dx === 0 || dx === 9 || dy === 0 || dy === 9) { - paintWall(canvas, x, y, makeWall(10, 0, 2, 129)) - } else { - paintFloor(canvas, x, y, makeFloor(10, 0, 2)) - clearWalls(canvas, x, y) - plateauCells += 1 - } - } - } - } - } else { - // 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) { - 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 } - stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + lavaChasmCells - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + rockyBridges - stats.notes.push( - `Act 4 Mesa/Lava Topography: ${String(lavaChasmCells)} lava cells, ${String(rockyBridges)} rocky bridge cells, ${String(plateauCells)} plateau cells`, - ) - - return stats.act4Topography -} - -/* ------------------------------------------------------------------------- * - * Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel - * ------------------------------------------------------------------------- */ - -function findBestBarricadeRow( - nominalY: number, - height: number, - width: number, - hubX: number, - placedRects: readonly PlacedPresetRect[], -): number { - const gateLeft = hubX - 2 - const gateRight = hubX + 2 - - const evaluateRow = (y: number): number => { - if (y < 16 || y > height - 16) return -1 - for (let x = gateLeft; x <= gateRight; x += 1) { - if (isInsidePreset(x, y, placedRects, 1)) return -1 - } - let count = 0 - for (let x = 8; x < width - 8; x += 1) { - if (x >= gateLeft && x <= gateRight) continue - if (!isInsidePreset(x, y, placedRects, 1)) count += 1 - } - return count - } - - const nominalScore = evaluateRow(nominalY) - if (nominalScore >= 12) return nominalY - - let bestY = nominalY - let bestScore = nominalScore - for (let delta = 1; delta <= 16; delta += 1) { - for (const sign of [1, -1]) { - const candY = nominalY + sign * delta - const score = evaluateRow(candY) - if (score >= 12) return candY - if (score > bestScore) { - bestScore = score - bestY = candY - } - } - } - return bestY -} - -function findBestBarricadeCol( - nominalX: number, - width: number, - height: number, - hubY: number, - placedRects: readonly PlacedPresetRect[], -): number { - const gateTop = hubY - 2 - const gateBottom = hubY + 2 - - const evaluateCol = (x: number): number => { - if (x < 16 || x > width - 16) return -1 - for (let y = gateTop; y <= gateBottom; y += 1) { - if (isInsidePreset(x, y, placedRects, 1)) return -1 - } - let count = 0 - for (let y = 8; y < height - 8; y += 1) { - if (y >= gateTop && y <= gateBottom) continue - if (!isInsidePreset(x, y, placedRects, 1)) count += 1 - } - return count - } - - const nominalScore = evaluateCol(nominalX) - if (nominalScore >= 12) return nominalX - - let bestX = nominalX - let bestScore = nominalScore - for (let delta = 1; delta <= 16; delta += 1) { - for (const sign of [1, -1]) { - const candX = nominalX + sign * delta - const score = evaluateCol(candX) - if (score >= 12) return candX - if (score > bestScore) { - bestScore = score - bestX = candX - } - } - } - return bestX -} - -/** - * Initialize Act 5 Outdoor Topography (Levels 111 Frigid Highlands, 112 Arreat Plateau, 117 Frozen Tundra): - * - Transverse siege fortifications & spiked palisade barricade walls with choke-point gates. - * - Military dugout battlefield trenches running behind defense lines. - * - Snow crater plateaus and frozen lakes with slippery ice floors. - * - Prisoner cages and Hell Portal redoubts. - */ -export function initAct5OutdoorLevel( - canvas: Canvas, - request: WildernessRequest, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: PlacedPresetRect[], - rng: Rng, - stats: WildernessStats, -): Act5TopographyStats { - const topoRng = rng.fork('act5-topography') - let barricadeWalls = 0 - let trenchCells = 0 - let frozenLakeCells = 0 - - const width = canvas.width - const height = canvas.height - const hubX = inX * TILES_PER_BLOCK + 4 - const hubY = outY * TILES_PER_BLOCK + 4 - - const isSnow = - request.levelId === 112 || - request.levelId === 117 || - /snow|plateau|tundra/i.test(request.levelName) - const groundStyle = isSnow ? 6 : 0 - const isVertical = height > width - - // 0. Central Military Road Corridor: establishes continuous passage from entrance to exit - if (isVertical) { - for (let y = 8; y < height - 8; y += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - const rx = hubX + dx - if (rx >= 4 && rx < width - 4 && !isInsidePreset(rx, y, placedRects, 0)) { - clearWalls(canvas, rx, y) - paintFloor(canvas, rx, y, makeFloor(groundStyle, 0, 2), false) - } - } - } - } else { - for (let x = 8; x < width - 8; x += 1) { - for (let dy = -1; dy <= 1; dy += 1) { - const ry = hubY + dy - if (ry >= 4 && ry < height - 4 && !isInsidePreset(x, ry, placedRects, 0)) { - clearWalls(canvas, x, ry) - paintFloor(canvas, x, ry, makeFloor(groundStyle, 0, 2), false) - } - } - } - } - - // 1. Transverse Siege Barricade Wall Lines (cutting across the level) - // When corridor height > width (vertical corridor), barricades cut horizontally across X in [8..W-8] with gate at hubX. - // When corridor width >= height (horizontal corridor), barricades cut vertically across Y in [8..H-8] with gate at hubY. - if (isVertical) { - const yFracs = [ - jitterFrac(topoRng, 0.25), - jitterFrac(topoRng, 0.50), - jitterFrac(topoRng, 0.75), - ] - const barricadeYPositions = yFracs.map(frac => { - const nominalY = Math.max(16, Math.min(height - 16, Math.floor(height * frac))) - return findBestBarricadeRow(nominalY, height, width, hubX, placedRects) - }) - - for (const by of barricadeYPositions) { - // Barricade choke-point gate at hubX - const gateLeft = hubX - 2 - const gateRight = hubX + 2 - - for (let x = 8; x < width - 8; x += 1) { - if (isInsidePreset(x, by, placedRects, 1)) continue - - if (x >= gateLeft && x <= gateRight) { - // Choke-point gate: clear for passage, fortified end-caps at ends - clearWalls(canvas, x, by) - paintFloor(canvas, x, by, makeFloor(groundStyle, 0, 2)) - if (x === gateLeft) paintWall(canvas, x, by, makeWall(2, 1, 2, 129)) - if (x === gateRight) paintWall(canvas, x, by, makeWall(2, 2, 2, 129)) - } else { - // Spiked wood/iron barricade palisade wall: style 2, prop1 129 (type 2: along X) - if (paintWall(canvas, x, by, makeWall(2, 0, 2, 129))) { - barricadeWalls += 1 - } - } - } - - // 2. Military Dugout Trenches: 2-tile wide trench running parallel behind barricade - const trenchY = by - 2 - for (let x = 10; x < width - 10; x += 1) { - if (Math.abs(x - hubX) > 3 && !isInsidePreset(x, trenchY, placedRects, 1)) { - for (let dy = 0; dy < 2; dy += 1) { - // Dirt/mud trench floor: style groundStyle, prop1 2 - paintFloor(canvas, x, trenchY + dy, makeFloor(groundStyle, 0, 2)) - clearWalls(canvas, x, trenchY + dy) - trenchCells += 1 - } - } - } - } - } else { - // Horizontal / square corridor: barricades cut vertically across Y - const barricadeXPositions = [ - findBestBarricadeCol( - Math.max(16, Math.floor(gridWidth * topoRng.range(0.35 - 0.04, 0.35 + 0.04)) * TILES_PER_BLOCK), - width, - height, - hubY, - placedRects, - ), - findBestBarricadeCol( - Math.min(width - 16, Math.floor(gridWidth * topoRng.range(0.70 - 0.04, 0.70 + 0.04)) * TILES_PER_BLOCK), - width, - height, - hubY, - placedRects, - ), - ] - - for (const bx of barricadeXPositions) { - // Barricade choke-point gate at hubY - const gateTop = hubY - 2 - const gateBottom = hubY + 2 - - for (let y = 8; y < height - 8; y += 1) { - if (isInsidePreset(bx, y, placedRects, 1)) continue - - if (y >= gateTop && y <= gateBottom) { - // Choke-point gate: clear for passage, fortified end-caps at ends - clearWalls(canvas, bx, y) - paintFloor(canvas, bx, y, makeFloor(groundStyle, 0, 2)) - if (y === gateTop) paintWall(canvas, bx, y, makeWall(2, 1, 1, 129)) - if (y === gateBottom) paintWall(canvas, bx, y, makeWall(2, 2, 1, 129)) - } else { - // Spiked wood/iron barricade palisade wall: style 2, prop1 129 (type 1: along Y) - if (paintWall(canvas, bx, y, makeWall(2, 0, 1, 129))) { - barricadeWalls += 1 - } - } - } - - // 2. Military Dugout Trenches: 2-tile wide trench running parallel behind barricade - const trenchX = bx - 2 - for (let y = 10; y < height - 10; y += 1) { - if (Math.abs(y - hubY) > 3 && !isInsidePreset(trenchX, y, placedRects, 1)) { - for (let dx = 0; dx < 2; dx += 1) { - // Dirt/mud trench floor: style groundStyle, prop1 2 - paintFloor(canvas, trenchX + dx, y, makeFloor(groundStyle, 0, 2)) - clearWalls(canvas, trenchX + dx, y) - trenchCells += 1 - } - } - } - } - } - - // 3. Snow Crater Plateaus & Frozen Lakes (Levels 112 Arreat Plateau & 117 Frozen Tundra) - if ( - request.levelId === 112 || - request.levelId === 117 || - /snow|plateau|tundra/i.test(request.levelName) - ) { - const lakeCenter = { - cx: isVertical - ? (topoRng.int(0, 1) === 0 - ? Math.max(10, Math.floor(width * 0.22)) - : Math.min(width - 10, Math.floor(width * 0.78))) - : Math.max(14, Math.floor(width * jitterFrac(topoRng, 0.52))), - cy: Math.max(14, Math.floor(height * jitterFrac(topoRng, 0.48))), - radius: Math.max(5, Math.min(8, Math.floor(width * 0.12))), - phi1: topoRng.range(0, 2 * Math.PI), - phi2: topoRng.range(0, 2 * Math.PI), - } - - const maxR = Math.ceil(lakeCenter.radius * 1.4) + 1 - for (let dy = -maxR; dy <= maxR; dy += 1) { - for (let dx = -maxR; dx <= maxR; dx += 1) { - const dist = Math.hypot(dx, dy) - const theta = Math.atan2(dy, dx) - const rLobe = act5FrozenLakeRadius(lakeCenter.radius, theta, lakeCenter.phi1, lakeCenter.phi2) - if (dist <= rLobe) { - const x = lakeCenter.cx + dx - const y = lakeCenter.cy + dy - if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) { - const roadClear = isVertical ? Math.abs(x - hubX) > 3 : Math.abs(y - hubY) > 3 - if (!isInsidePreset(x, y, placedRects, 1) && roadClear) { - // Slippery frozen ice floor: style 2, sequence 0, prop1 2 - paintFloor(canvas, x, y, makeFloor(2, 0, 2), true) - clearWalls(canvas, x, y) - frozenLakeCells += 1 - } - } - } - } - } - - // Stamp Snow Lake preset if available - const lakePiece = request.pieces.find(p => /Snow Lake/i.test(p.name)) - if (lakePiece?.levels[0] && !stats.specialPresets.some(n => /Snow Lake/i.test(n))) { - const ds1 = lakePiece.levels[0] - const lx = isVertical - ? (topoRng.int(0, 1) === 0 ? 8 : Math.max(8, width - ds1.width - 8)) - : Math.max(8, Math.floor(width * jitterFrac(topoRng, 0.5)) - Math.floor(ds1.width / 2)) - const ly = Math.max(8, Math.floor(height * jitterFrac(topoRng, 0.5)) - Math.floor(ds1.height / 2)) - stampDs1(canvas, ds1, lx, ly) - placedRects.push({ name: lakePiece.name, x: lx, y: ly, w: ds1.width, h: ds1.height }) - stats.specialPresets.push(lakePiece.name) - } - } - - stats.act5Topography = { barricadeWalls, trenchCells, frozenLakeCells } - stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + barricadeWalls - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + trenchCells - stats.proceduralWaterCells = (stats.proceduralWaterCells ?? 0) + frozenLakeCells - stats.notes.push( - `Act 5 Siege/Barricade Topography: ${String(barricadeWalls)} barricade walls, ${String(trenchCells)} trench cells, ${String(frozenLakeCells)} frozen lake cells`, - ) - - return stats.act5Topography -} diff --git a/src/game/wilderness-jungles.ts b/src/game/wilderness-jungles.ts deleted file mode 100644 index 1ab71fb..0000000 --- a/src/game/wilderness-jungles.ts +++ /dev/null @@ -1,713 +0,0 @@ -/** - * Diablo II Act 3 Jungle River Network & Branch Topology Algorithm (`DRLG_GenerateJungles`). - * - * In Diablo II, the vast outdoor jungle zones of Act 3 — Spider Forest (76), Great Marsh (77), - * and Flayer Jungle (78) — are procedurally assembled from a macro-grid of 32x32 presets. - * Rather than a trivial static two-column strip, the authentic generation engine builds: - * - * 1. Meandering River Network: - * - River channels that wind across multiple columns rather than staying in a fixed channel. - * - Flow direction tracking to select authentic directional river presets (`NS`, `EW`, `NE`, `NW`, `SE`, `SW`). - * 2. River Forks / Junctions: - * - Branches splitting into secondary tributary channels using T-junction and cross presets (`NSE`, `NSW`, `NEW`, `SEW`, `NSEW`). - * 3. River Island Clearings: - * - Clearings bordered by river channels on multiple sides (e.g. 2 or more orthogonal sides), forming defensible enclaves. - * 4. Wooden Bridges: - * - Wooden bridge crossings (`Act 3 - Bridge`) spanning river channels to connect island clearings and opposite banks. - * 5. Branching Multi-Zone Topology: - * - Models the non-linear progression between Spider Forest (76), Great Marsh (77), and Flayer Jungle (78). - * - Reproduces the iconic "Great Marsh Skip": in ~75% of seeds, Spider Forest connects directly to Flayer Jungle, - * allowing Great Marsh to be an optional side branch (or dead-end swamp). In the remaining seeds, Great Marsh - * serves as the mandatory throughway. - */ - -import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts' -import { Rng } from './rng.ts' -import type { Canvas, PlacedPresetRect, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts' - -/** Connection between two Act 3 zones. */ -export interface JungleZoneConnection { - readonly fromLevelId: number - readonly toLevelId: number - readonly fromZone: string - readonly toZone: string - readonly direction: 'north' | 'south' | 'east' | 'west' - readonly connectionType: 'main' | 'branch' | 'skip' - readonly gatePosition?: { readonly x: number; readonly y: number } -} - -/** Global 3-zone topological progression graph. */ -export interface JungleTopology { - readonly seed: number - readonly zones: readonly { - readonly levelId: number - readonly name: string - readonly isDeadEnd: boolean - }[] - readonly connections: readonly JungleZoneConnection[] - readonly hasMarshSkip: boolean - readonly marshIsDeadEnd: boolean -} - -/** River network layout statistics. */ -export interface JungleRiverNetwork { - readonly meanderingPath: readonly { readonly col: number; readonly row: number }[] - readonly meanderCount: number - readonly forks: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[] - readonly islands: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[] - readonly bridges: readonly { readonly col: number; readonly row: number; readonly x: number; readonly y: number; readonly pieceName: string }[] - readonly clearings: readonly { readonly col: number; readonly row: number; readonly pieceName: string; readonly isIsland: boolean }[] -} - -/** Complete layout statistics for an Act 3 Jungle level. */ -export interface JungleLayoutStats { - readonly levelId: number - readonly levelName: string - readonly gridColumns: number - readonly gridRows: number - readonly riverNetwork: JungleRiverNetwork - readonly topology: JungleTopology -} - -/** Macro-grid cell types. */ -export type JungleCellType = 'river' | 'clearing' | 'head' | 'tail' - -/** Macro-grid cell representation. */ -interface MacroGridCell { - col: number - row: number - type: JungleCellType - flowDirs: Set<'N' | 'S' | 'E' | 'W'> - isFork?: boolean - isIsland?: boolean - pieceName?: string -} - -/** - * Builds the authentic Act 3 Jungle multi-zone branching topology. - * - * In Diablo II: - * - Spider Forest (76) begins at Kurast Docks (75). - * - Spider Forest always connects to Great Marsh (77). - * - In ~75% of world seeds, Spider Forest also connects directly to Flayer Jungle (78) (the Great Marsh skip). - * - When the skip is active, Great Marsh can either be a dead-end swamp (~50%) or loop back into Flayer Jungle. - * - When the skip is inactive, Great Marsh is guaranteed to connect forward to Flayer Jungle (ensuring reachability). - */ -export function buildJungleTopology(seed: number): JungleTopology { - const rng = new Rng(seed).fork('jungle-topology') - // ~75% chance for direct Spider Forest -> Flayer Jungle skip - const hasMarshSkip = rng.int(0, 3) !== 0 - // If no skip, Great Marsh must connect to Flayer Jungle. If skip exists, Great Marsh can be dead-end. - const marshIsDeadEnd = hasMarshSkip && rng.chance(0.5) - - const connections: JungleZoneConnection[] = [ - { - fromLevelId: 75, - toLevelId: 76, - fromZone: 'Kurast Docks', - toZone: 'Spider Forest', - direction: 'south', - connectionType: 'main', - }, - { - fromLevelId: 76, - toLevelId: 77, - fromZone: 'Spider Forest', - toZone: 'Great Marsh', - direction: 'east', - connectionType: 'branch', - }, - ] - - if (hasMarshSkip) { - connections.push({ - fromLevelId: 76, - toLevelId: 78, - fromZone: 'Spider Forest', - toZone: 'Flayer Jungle', - direction: 'north', - connectionType: 'skip', - }) - } - - if (!marshIsDeadEnd) { - connections.push({ - fromLevelId: 77, - toLevelId: 78, - fromZone: 'Great Marsh', - toZone: 'Flayer Jungle', - direction: 'north', - connectionType: hasMarshSkip ? 'branch' : 'main', - }) - } - - connections.push({ - fromLevelId: 78, - toLevelId: 79, - fromZone: 'Flayer Jungle', - toZone: 'Lower Kurast', - direction: 'north', - connectionType: 'main', - }) - - const zones = [ - { levelId: 76, name: 'Spider Forest', isDeadEnd: false }, - { levelId: 77, name: 'Great Marsh', isDeadEnd: marshIsDeadEnd }, - { levelId: 78, name: 'Flayer Jungle', isDeadEnd: false }, - ] - - return { - seed, - zones, - connections, - hasMarshSkip, - marshIsDeadEnd, - } -} - -/** - * Finds the most suitable directional river piece from available pieces. - */ -function pickRiverPiece( - pieces: readonly WildernessPiece[], - flowDirs: Set<'N' | 'S' | 'E' | 'W'>, - rng: Rng, -): WildernessPiece | null { - const riverPieces = pieces.filter( - p => - p.name.startsWith('Act 3 - Jungle ') && - p.name !== 'Act 3 - Jungle Head' && - p.name !== 'Act 3 - Jungle Tail' && - p.levels.length > 0, - ) - if (riverPieces.length === 0) return null - - const requiredTokens = Array.from(flowDirs) - const tokenStr = requiredTokens.sort().join('') - - // 1. Exact match on token (e.g. "Act 3 - Jungle NS", "Act 3 - Jungle SW", etc.) - const exactMatches = riverPieces.filter(p => { - const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim() - const tokens = rest.split(/\s+/) - return tokens.some(t => t.toUpperCase() === tokenStr) - }) - if (exactMatches.length > 0) return exactMatches[rng.int(0, exactMatches.length - 1)]! - - // 2. Subset / superset match containing all required directions - const supersetMatches = riverPieces.filter(p => { - const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim() - return requiredTokens.every(d => rest.includes(d)) - }) - if (supersetMatches.length > 0) return supersetMatches[rng.int(0, supersetMatches.length - 1)]! - - // 3. Fallback to any river piece - return riverPieces[rng.int(0, riverPieces.length - 1)]! -} - -/** - * Finds the most suitable clearing piece matching the Act 3 sub-theme. - */ -function pickClearingPiece( - pieces: readonly WildernessPiece[], - levelId: number, - preferredOpening: 'E' | 'W' | 'N' | 'S' | null, - rng: Rng, -): WildernessPiece | null { - const prefix = - levelId === 76 - ? 'Act 3 - Clearing Webby' - : levelId === 77 - ? 'Act 3 - Clearing Boggy' - : 'Act 3 - Clearing Pygmy' - - let pool = pieces.filter(p => p.name.startsWith(prefix) && p.levels.length > 0) - if (pool.length === 0) { - pool = pieces.filter(p => p.name.startsWith('Act 3 - Clearing') && p.levels.length > 0) - } - if (pool.length === 0) return null - - if (preferredOpening) { - const openingMatches = pool.filter(p => p.name.includes(preferredOpening)) - if (openingMatches.length > 0) return openingMatches[rng.int(0, openingMatches.length - 1)]! - } - - return pool[rng.int(0, pool.length - 1)]! -} - -/** - * Stamps wooden bridge floor tiles directly across the water channel. - */ -function synthesizeBridgePlanks( - canvas: Canvas, - bridgeX: number, - bridgeY: number, - orientation: 'horizontal' | 'vertical', - bridgeDs1?: Ds1, -): void { - const makeBridgeFloor = (): Ds1Floor => ({ - prop1: 2, - sequence: 0, - style: 4, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - - // A 4-tile wide plank walkway bridging across the 32-tile river channel - if (orientation === 'horizontal') { - for (let dy = 14; dy <= 17; dy += 1) { - for (let dx = 0; dx < 32; dx += 1) { - const cx = bridgeX + dx - const cy = bridgeY + dy - if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) { - const row = canvas.cells[cy] - if (row?.[cx]) { - if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) { - const ds1Cell = bridgeDs1.cells[dy]?.[dx] - const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden - const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1 - if (hasPresetWall || hasPresetFloor) { - continue - } - } - ;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor() - } - } - } - } - } else { - for (let dx = 14; dx <= 17; dx += 1) { - for (let dy = 0; dy < 32; dy += 1) { - const cx = bridgeX + dx - const cy = bridgeY + dy - if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) { - const row = canvas.cells[cy] - if (row?.[cx]) { - if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) { - const ds1Cell = bridgeDs1.cells[dy]?.[dx] - const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden - const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1 - if (hasPresetWall || hasPresetFloor) { - continue - } - } - ;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor() - } - } - } - } - } -} - -/** - * 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. - * - * Corresponds to Blizzard DRLGOUTJUNG_BuildJungle (D2Common.0x6FD7FC20). - * - * In Diablo II 1.13c / D2MOO, DRLGOUTJUNG_BuildJungle assembles the 32x32 macro-grid for - * Spider Forest (76), Great Marsh (77), and Flayer Jungle (78), stamping head/tail presets - * (LVLPREST_ACT3_JUNGLE_HEAD / LVLPREST_ACT3_JUNGLE_TAIL) and assigning river/clearing presets. - * This TypeScript implementation reconstructs that behavior with: - * - Meandering river paths wandering across columns. - * - River forks splitting into secondary tributary channels. - * - Island clearings surrounded by river channels. - * - Wooden bridges crossing river channels to connect trails and islands. - * - Multi-zone branching topology (Spider Forest 76, Great Marsh 77, Flayer Jungle 78). - */ -export function DRLG_GenerateJungles( - canvas: Canvas, - levelId: number, - pieces: readonly WildernessPiece[], - rng: Rng, - stats: WildernessStats, - stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number, - placedRects?: PlacedPresetRect[], -): Set { - const placed = new Set() - - const numCols = Math.max(2, Math.floor(canvas.width / 32)) - const numRows = Math.max(3, Math.floor(canvas.height / 32)) - - // 1. Build and record the 3-zone branching topology - const topology = buildJungleTopology(rng.seed) - stats.jungleTopology = topology - - // 2. Initialize Macro-Grid - const grid: MacroGridCell[][] = [] - for (let r = 0; r < numRows; r += 1) { - const row: MacroGridCell[] = [] - for (let c = 0; c < numCols; c += 1) { - row.push({ - col: c, - row: r, - type: 'clearing', - flowDirs: new Set(), - }) - } - grid.push(row) - } - - // Row 0 is Tail, Row numRows - 1 is Head - for (let c = 0; c < numCols; c += 1) { - grid[0]![c]!.type = 'tail' - grid[numRows - 1]![c]!.type = 'head' - } - - // 3. Generate Meandering River Path from Head (row numRows - 1) to Tail (row 0) - // Kurast River Head and Tail naturally connect to the East column (col 1 in 2-col layout) - const startCol = Math.min(1, numCols - 1) - const endCol = Math.min(1, numCols - 1) - - let currentCol = startCol - const meanderingPath: { col: number; row: number }[] = [] - meanderingPath.push({ col: currentCol, row: numRows - 1 }) - grid[numRows - 1]![currentCol]!.flowDirs.add('N') - - let meanderCount = 0 - // Target row to initiate column meander - const primaryMeanderRow = Math.max(1, Math.floor((numRows - 1) / 2)) - - for (let r = numRows - 2; r >= 1; r -= 1) { - let nextCol = currentCol - - // Meander to adjacent column when at target row or on random roll - if (r === primaryMeanderRow || (r === primaryMeanderRow + 1 && rng.chance(0.6))) { - if (numCols === 2) { - nextCol = 1 - currentCol - } else { - const step = rng.chance(0.5) ? -1 : 1 - nextCol = Math.max(0, Math.min(numCols - 1, currentCol + step)) - } - } - - if (nextCol !== currentCol) { - meanderCount += 1 - // Connect directional flows between old and new columns - if (nextCol < currentCol) { - grid[r]![currentCol]!.flowDirs.add('W') - grid[r]![nextCol]!.flowDirs.add('E') - } else { - grid[r]![currentCol]!.flowDirs.add('E') - grid[r]![nextCol]!.flowDirs.add('W') - } - currentCol = nextCol - } - - grid[r]![currentCol]!.type = 'river' - grid[r]![currentCol]!.flowDirs.add('S') - grid[r]![currentCol]!.flowDirs.add('N') - meanderingPath.push({ col: currentCol, row: r }) - } - - // Ensure tail connection - if (currentCol !== endCol) { - meanderCount += 1 - if (endCol < currentCol) { - grid[1]![currentCol]!.flowDirs.add('W') - grid[1]![endCol]!.flowDirs.add('E') - } else { - grid[1]![currentCol]!.flowDirs.add('E') - grid[1]![endCol]!.flowDirs.add('W') - } - currentCol = endCol - } - grid[0]![endCol]!.flowDirs.add('S') - meanderingPath.push({ col: endCol, row: 0 }) - - // 4. Generate River Fork / Tributary Branch - const forkRow = Math.max(1, Math.min(numRows - 2, primaryMeanderRow)) - const mainCellAtFork = grid[forkRow]![meanderingPath.find(p => p.row === forkRow)?.col ?? 0]! - const forkCol = numCols === 2 ? 1 - mainCellAtFork.col : (mainCellAtFork.col === 0 ? 1 : mainCellAtFork.col - 1) - - const forks: { col: number; row: number; pieceName?: string }[] = [] - mainCellAtFork.isFork = true - if (forkCol < mainCellAtFork.col) { - mainCellAtFork.flowDirs.add('W') - grid[forkRow]![forkCol]!.flowDirs.add('E') - } else { - mainCellAtFork.flowDirs.add('E') - grid[forkRow]![forkCol]!.flowDirs.add('W') - } - grid[forkRow]![forkCol]!.type = 'river' - grid[forkRow]![forkCol]!.flowDirs.add('N') - grid[forkRow]![forkCol]!.flowDirs.add('S') - - forks.push({ col: mainCellAtFork.col, row: forkRow }) - - // 5. Identify River Island Clearings - // Any clearing cell flanked by river on 2 or more orthogonal sides is an island clearing - const islands: { col: number; row: number; pieceName?: string }[] = [] - const clearings: { col: number; row: number; pieceName: string; isIsland: boolean }[] = [] - - for (let r = 1; r < numRows - 1; r += 1) { - for (let c = 0; c < numCols; c += 1) { - const cell = grid[r]![c]! - if (cell.type !== 'clearing') continue - - let riverNeighbors = 0 - if (c > 0 && grid[r]![c - 1]!.type === 'river') riverNeighbors += 1 - if (c < numCols - 1 && grid[r]![c + 1]!.type === 'river') riverNeighbors += 1 - if (r > 0 && grid[r - 1]![c]!.type === 'river') riverNeighbors += 1 - if (r < numRows - 1 && grid[r + 1]![c]!.type === 'river') riverNeighbors += 1 - - if (riverNeighbors >= 2) { - cell.isIsland = true - islands.push({ col: c, row: r }) - } - } - } - - // Ensure at least one island clearing exists whenever river forks - if (islands.length === 0 && numRows >= 4) { - const candidateR = forkRow === 1 ? 2 : 1 - const candidateC = numCols === 2 ? 1 - grid[candidateR]![0]!.col : 1 - const candidateCell = grid[candidateR]?.[candidateC] - if (candidateCell && candidateCell.type === 'clearing') { - candidateCell.isIsland = true - islands.push({ col: candidateC, row: candidateR }) - } - } - - // 6. Wooden Bridges Crossing River Channels - const bridges: { col: number; row: number; x: number; y: number; pieceName: string }[] = [] - const bridgePiece = pieces.find(p => p.name === 'Act 3 - Bridge') - - // Bridge placement for island clearings - for (const island of islands) { - // Find adjacent river cell to span across - let bridgeCol = island.col - let bridgeRow = island.row - let orientation: 'horizontal' | 'vertical' = 'horizontal' - - if (island.col > 0 && grid[island.row]![island.col - 1]!.type === 'river') { - bridgeCol = island.col - 1 - orientation = 'horizontal' - } else if (island.col < numCols - 1 && grid[island.row]![island.col + 1]!.type === 'river') { - bridgeCol = island.col + 1 - orientation = 'horizontal' - } else if (island.row > 0 && grid[island.row - 1]![island.col]!.type === 'river') { - bridgeRow = island.row - 1 - orientation = 'vertical' - } else if (island.row < numRows - 1 && grid[island.row + 1]![island.col]!.type === 'river') { - bridgeRow = island.row + 1 - orientation = 'vertical' - } - - const bx = bridgeCol * 32 - const by = bridgeRow * 32 - bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' }) - - if (bridgePiece?.levels[0]) { - const bDs1 = bridgePiece.levels[0] - const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2)) - const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2)) - stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY) - placedRects?.push({ x: bx + offsetX, y: by + offsetY, w: bDs1.width, h: bDs1.height, name: bridgePiece.name }) - } - synthesizeBridgePlanks(canvas, bx, by, orientation, bridgePiece?.levels[0]) - placed.add('Act 3 - Bridge') - stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 }) - } - - // Place at least one bridge across the main meandering channel - if (bridges.length === 0) { - const bridgeCol = meanderingPath[Math.floor(meanderingPath.length / 2)]?.col ?? 1 - const bridgeRow = Math.floor(numRows / 2) - const bx = bridgeCol * 32 - const by = bridgeRow * 32 - bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' }) - - if (bridgePiece?.levels[0]) { - const bDs1 = bridgePiece.levels[0] - stampDs1Fn(canvas, bDs1, bx, by) - placedRects?.push({ x: bx, y: by, w: bDs1.width, h: bDs1.height, name: bridgePiece.name }) - } - synthesizeBridgePlanks(canvas, bx, by, 'horizontal', bridgePiece?.levels[0]) - placed.add('Act 3 - Bridge') - stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 }) - } - - // 7. Stamp Jungle Head and Tail - const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail') - const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head') - - if (tailPiece?.levels[0]) { - stampDs1Fn(canvas, tailPiece.levels[0], 0, 0) - placed.add(tailPiece.name) - stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 }) - placedRects?.push({ x: 0, y: 0, w: tailPiece.levels[0].width, h: tailPiece.levels[0].height, name: tailPiece.name }) - } - if (headPiece?.levels[0]) { - stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32) - placed.add(headPiece.name) - stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 }) - placedRects?.push({ x: 0, y: (numRows - 1) * 32, w: headPiece.levels[0].width, h: headPiece.levels[0].height, name: headPiece.name }) - } - - // 8. Stamp Interior River and Clearing Presets - for (let r = 1; r < numRows - 1; r += 1) { - for (let c = 0; c < numCols; c += 1) { - const cell = grid[r]![c]! - const x = c * 32 - const y = r * 32 - - if (cell.type === 'river') { - const riverPiece = pickRiverPiece(pieces, cell.flowDirs, rng) - if (riverPiece?.levels[0]) { - const ds1 = riverPiece.levels[rng.int(0, riverPiece.levels.length - 1)]! - stampDs1Fn(canvas, ds1, x, y) - cell.pieceName = riverPiece.name - placed.add(riverPiece.name) - stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 }) - placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: riverPiece.name }) - } - } else if (cell.type === 'clearing') { - const preferredOpening = - c < numCols - 1 && grid[r]![c + 1]!.type === 'river' - ? 'E' - : c > 0 && grid[r]![c - 1]!.type === 'river' - ? 'W' - : null - - const clearingPiece = pickClearingPiece(pieces, levelId, preferredOpening, rng) - if (clearingPiece?.levels[0]) { - const ds1 = clearingPiece.levels[rng.int(0, clearingPiece.levels.length - 1)]! - stampDs1Fn(canvas, ds1, x, y) - cell.pieceName = clearingPiece.name - placed.add(clearingPiece.name) - stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 }) - placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: clearingPiece.name }) - clearings.push({ - col: c, - row: r, - pieceName: clearingPiece.name, - isIsland: cell.isIsland ?? false, - }) - } - } - } - } - - // 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity - for (const b of bridges) { - if (bridgePiece?.levels[0]) { - const ds1 = bridgePiece.levels[0] - stampDs1Fn(canvas, ds1, b.x, b.y) - placedRects?.push({ x: b.x, y: b.y, w: ds1.width, h: ds1.height, name: bridgePiece.name }) - } - synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal', bridgePiece?.levels[0]) - } - - // 10. Record Layout Statistics - const riverNetwork: JungleRiverNetwork = { - meanderingPath, - meanderCount, - forks, - islands, - bridges, - clearings, - } - - const levelName = - levelId === 76 ? 'Spider Forest' : levelId === 77 ? 'Great Marsh' : 'Flayer Jungle' - - stats.jungleLayout = { - levelId, - levelName, - gridColumns: numCols, - gridRows: numRows, - riverNetwork, - 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) { - const row = canvas.cells[y] - if (row?.[x]) { - ;(row[x]!.floors as Ds1Floor[])[0] = { - prop1: 2, - sequence: 0, - style: 4, - unknown1: 0, - 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: 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 - stats.riverForks = forks.length - stats.meanderCount = meanderCount - - return placed -} diff --git a/src/game/wilderness-siege.ts b/src/game/wilderness-siege.ts deleted file mode 100644 index 86edafb..0000000 --- a/src/game/wilderness-siege.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Act 5 Bloody Foothills (Level 110, Act 5 - Siege 1) dynamic siege generation. - * - * Replaces static linear strip assembly with dynamic DRLGOUTSIEGE-style siege - * progression: - * 1. Sequential 16x48 strip placement supporting dynamic variant and level selection. - * 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels. - * 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 10/11) - * and traversable breach openings. - * 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts. - * 5. Crumbled stone fort ruins interspersed between siege strips. - * 6. Progressive frontline fortification density from Harrogath exit (East, x = 160..240, low) - * towards Shenk the Overseer / Arreat Plateau barricade (West, x = 0..80, high). - */ - -import type { Ds1, Ds1Floor, Ds1Wall } from '../formats/ds1.ts' -import { Rng } from './rng.ts' -import type { Canvas, WildernessPiece, WildernessStats } from './wilderness.ts' -import { stampDs1, emptyWall } from './wilderness.ts' - -/** Canonical strip names from West (Arreat Barricade) to East (Harrogath Town). */ -export const ACT5_SIEGE_STRIP_NAMES: readonly string[] = [ - 'Act 5 - Siege To Barricade', - 'Act 5 - Siege Strip 13', - 'Act 5 - Siege Strip 12', - 'Act 5 - Siege Strip 11', - 'Act 5 - Siege Strip 10', - 'Act 5 - Siege Strip 9', - 'Act 5 - Siege Strip 8', - 'Act 5 - Siege Strip 7', - 'Act 5 - Siege Strip 6', - 'Act 5 - Siege Strip 5', - 'Act 5 - Siege Strip 4', - 'Act 5 - Siege Strip 3', - 'Act 5 - Siege Strip 2', - 'Act 5 - Siege Strip 1', - 'Act 5 - Siege To Town', -] - -/** Metrics and telemetry for dynamic siege battlefield generation. */ -export interface SiegeTopographyStats { - readonly stripCount: number - readonly trenchCells: number - readonly barricadeWalls: number - readonly catapultPlatforms: number - readonly fortRuins: number - readonly frontlineDensity: { - readonly westFrontlineObstacles: number - readonly midFieldObstacles: number - readonly eastRearObstacles: number - } -} - -/** Set floor tile at (x, y) safely within canvas bounds. */ -function setFloor(canvas: Canvas, x: number, y: number, floor: Ds1Floor): void { - if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return - const row = canvas.cells[y] - if (!row) return - const cell = row[x] - if (!cell) return - const targetFloors = cell.floors as Ds1Floor[] - if (targetFloors.length > 0) { - targetFloors[0] = floor - } else { - targetFloors.push(floor) - } -} - -/** Set wall tile at (x, y) safely within canvas bounds. */ -function setWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): void { - if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return - const row = canvas.cells[y] - if (!row) return - const cell = row[x] - if (!cell) return - const targetWalls = cell.walls as Ds1Wall[] - if (targetWalls.length > 0) { - targetWalls[0] = wall - } else { - targetWalls.push(wall) - } -} - -/** Clear all wall layers at (x, y) to guarantee traversability. */ -function clearCellWalls(canvas: Canvas, x: number, y: number): void { - if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return - const row = canvas.cells[y] - if (!row) return - const cell = row[x] - if (!cell) return - const targetWalls = cell.walls as Ds1Wall[] - for (let i = 0; i < targetWalls.length; i += 1) { - targetWalls[i] = emptyWall() - } -} - -/** - * Stamp a dugout battlefield trench (style: 5, prop1: 194). - * Clears walls in the trench path to guarantee traversable channels. - */ -function stampTrench( - canvas: Canvas, - startX: number, - startY: number, - length: number, - orientation: 'horizontal' | 'vertical', - rng: Rng, -): number { - let cellsStamped = 0 - const width = 2 - if (orientation === 'vertical') { - for (let dy = 0; dy < length; dy += 1) { - const y = startY + dy - if (y < 6 || y > canvas.height - 6) continue - for (let dx = 0; dx < width; dx += 1) { - const x = startX + dx - if (x < 4 || x >= canvas.width - 4) continue - setFloor(canvas, x, y, { - style: 5, - sequence: rng.int(0, 5), - prop1: 194, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - clearCellWalls(canvas, x, y) - cellsStamped += 1 - } - } - } else { - for (let dx = 0; dx < length; dx += 1) { - const x = startX + dx - if (x < 4 || x >= canvas.width - 4) continue - for (let dy = 0; dy < width; dy += 1) { - const y = startY + dy - if (y < 6 || y > canvas.height - 6) continue - setFloor(canvas, x, y, { - style: 5, - sequence: rng.int(0, 5), - prop1: 194, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - clearCellWalls(canvas, x, y) - cellsStamped += 1 - } - } - } - return cellsStamped -} - -/** - * Stamp a breached wooden barricade palisade (style: 2, prop1: 129). - * Features splintered end-caps (sequence: 10 or 11) flanking a traversable breach opening. - */ -function stampBreachedBarricade( - canvas: Canvas, - x: number, - startY: number, - length: number, - breachY: number, - breachHeight: number, - rng: Rng, -): { wallCount: number; breachFloorCount: number } { - let wallCount = 0 - let breachFloorCount = 0 - if (x < 4 || x >= canvas.width - 4) return { wallCount: 0, breachFloorCount: 0 } - - for (let dy = 0; dy < length; dy += 1) { - const y = startY + dy - if (y < 6 || y > canvas.height - 6) continue - - if (y >= breachY && y < breachY + breachHeight) { - // Traversable breach gap: clear walls and trample floor - clearCellWalls(canvas, x, y) - setFloor(canvas, x, y, { - style: 5, - sequence: 0, - prop1: 194, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - breachFloorCount += 1 - } else if (y === breachY - 1 || y === breachY + breachHeight) { - // Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 10 or 11) - setWall(canvas, x, y, { - style: 2, - sequence: rng.int(10, 11), - prop1: 129, - type: 7, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - wallCount += 1 - } else { - // Intact spiked wooden palisade (fence.dt1 type 1 left wall, seq 10) - setWall(canvas, x, y, { - style: 2, - sequence: 10, - prop1: 129, - type: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - wallCount += 1 - } - } - return { wallCount, breachFloorCount } -} - -/** - * Stamp a raised catapult defense platform (style: 4, prop1: 2) with defensive parapets. - * Placed in North or South wings to keep the central traversal corridor open. - */ -function stampCatapultPlatform( - canvas: Canvas, - originX: number, - originY: number, - width: number, - height: number, - rng: Rng, -): void { - for (let dy = 0; dy < height; dy += 1) { - const y = originY + dy - if (y < 6 || y > canvas.height - 6) continue - for (let dx = 0; dx < width; dx += 1) { - const x = originX + dx - if (x < 4 || x >= canvas.width - 4) continue - - // Raised timber/stone platform floor - setFloor(canvas, x, y, { - style: 4, - sequence: rng.int(0, 3), - prop1: 2, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - - // Parapet rampart along outer perimeter, leaving access opening on interior side - const isPerimeter = dx === 0 || dx === width - 1 || dy === 0 || dy === height - 1 - const isAccessGap = (originY < 20 && dy === height - 1 && dx >= 1 && dx <= 2) || - (originY > 26 && dy === 0 && dx >= 1 && dx <= 2) - if (isPerimeter && !isAccessGap) { - setWall(canvas, x, y, { - style: 2, - sequence: 10, - prop1: 2, - type: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - } - } - } -} - -/** - * Stamp crumbled stone fort ruins (style: 18) interspersed between strips. - * Features weathered stone flagstone floors and broken masonry walls. - */ -function stampFortRuin( - canvas: Canvas, - originX: number, - originY: number, - size: number, - rng: Rng, -): void { - for (let dy = 0; dy < size; dy += 1) { - const y = originY + dy - if (y < 6 || y > canvas.height - 6) continue - for (let dx = 0; dx < size; dx += 1) { - const x = originX + dx - if (x < 4 || x >= canvas.width - 4) continue - - // Ruined camp flagstone floor (camp.dt1 style 18 floor) - setFloor(canvas, x, y, { - style: 18, - sequence: rng.int(0, 4), - prop1: 2, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - - // Weathered/crumbled perimeter wall with missing sections (camp.dt1 style 18 wall) - const isPerimeter = dx === 0 || dx === size - 1 || dy === 0 || dy === size - 1 - if (isPerimeter && rng.next() > 0.35) { - setWall(canvas, x, y, { - style: 18, - sequence: rng.int(0, 2), - prop1: 129, - type: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - } - } - } -} - -/** Find and choose strip DS1 piece supporting dynamic variants and level variations. */ -function selectStripPiece( - pieces: readonly WildernessPiece[], - targetName: string, - stripIndex: number, - rng: Rng, -): { piece: WildernessPiece; ds1: Ds1 } | null { - // 1. Direct exact name match - const exact = pieces.filter(p => p.name === targetName && p.levels.length > 0) - if (exact.length > 0) { - const chosen = exact[rng.int(0, exact.length - 1)]! - const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! - return { piece: chosen, ds1 } - } - - // 2. Prefix or variant matches (e.g. "Act 5 - Siege Strip 5 A") - const variantMatches = pieces.filter( - p => p.name.startsWith(targetName) && p.levels.length > 0, - ) - if (variantMatches.length > 0) { - const chosen = variantMatches[rng.int(0, variantMatches.length - 1)]! - const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! - return { piece: chosen, ds1 } - } - - // 3. Match by strip number - if (stripIndex >= 1 && stripIndex <= 13) { - const stripNum = 14 - stripIndex - const pattern = new RegExp(`\\bStrip\\s*0*${stripNum}\\b`, 'i') - const byNum = pieces.filter(p => pattern.test(p.name) && p.levels.length > 0) - if (byNum.length > 0) { - const chosen = byNum[rng.int(0, byNum.length - 1)]! - const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! - return { piece: chosen, ds1 } - } - } - - // 4. Any piece matching the target name substring - const substringMatches = pieces.filter( - p => p.name.toLowerCase().includes(targetName.toLowerCase()) && p.levels.length > 0, - ) - if (substringMatches.length > 0) { - const chosen = substringMatches[rng.int(0, substringMatches.length - 1)]! - const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]! - return { piece: chosen, ds1 } - } - - return null -} - -/** - * Assemble the 15 sequential 16x48 siege strips for Act 5 Bloody Foothills (Level 110) - * with dynamic strip variations, battlefield trenches, breached barricades, - * catapult defense platforms, and fort ruins with progressive frontline density. - */ -export function stampAct5SiegeStrips( - canvas: Canvas, - pieces: readonly WildernessPiece[], - rng: Rng, - stats: WildernessStats, -): Set { - if (!(rng instanceof Rng)) { - throw new Error('stampAct5SiegeStrips: missing required Rng parameter') - } - if (!stats) { - throw new Error('stampAct5SiegeStrips: missing required WildernessStats parameter') - } - const placed = new Set() - - // 1. Assemble 15 strips West (x=0) to East (x=224) - const dynamicStripStride = canvas.width > 0 - ? Math.floor(canvas.width / ACT5_SIEGE_STRIP_NAMES.length) - : 16 - let currentX = 0 - for (let i = 0; i < ACT5_SIEGE_STRIP_NAMES.length; i += 1) { - const targetName = ACT5_SIEGE_STRIP_NAMES[i]! - const selection = selectStripPiece(pieces, targetName, i, rng) - if (!selection) { - currentX += dynamicStripStride - continue - } - - // DS1 tiles often include a +1 border overlap column (e.g. 17 width for 16 stride) - const ds1Width = selection.ds1.width - const stripStride = ds1Width > 0 - ? (ds1Width % 16 === 1 ? ds1Width - 1 : ds1Width) - : dynamicStripStride - stampDs1(canvas, selection.ds1, currentX, 0) - currentX += stripStride - placed.add(selection.piece.name) - stats.substitutions.push({ - name: selection.piece.name, - role: 'object', - enabled: true, - clusters: 1, - }) - } - - // 2. Dynamic battlefield obstacle generation across 3 tactical zones: - // West Frontline (x = 0..80): Heavy fortification near Arreat Barricade / Shenk - // Mid Field (x = 80..160): Moderate fortifications in no-man's land - // East Rear (x = 160..240): Light defenses near Harrogath town gates - let totalTrenchCells = 0 - let totalBarricadeWalls = 0 - let catapultPlatformCount = 0 - let fortRuinCount = 0 - - let westObstacles = 0 - let midObstacles = 0 - let eastObstacles = 0 - - // ------------------------------------------------------------------------- - // Zone 1: West Frontline (x in [0, 80)) - High density (Shenk's stronghold) - // ------------------------------------------------------------------------- - // Trenches (3 trenches) - totalTrenchCells += stampTrench(canvas, 18 + rng.int(-2, 2), 8 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng) - totalTrenchCells += stampTrench(canvas, 50 + rng.int(-2, 2), 28 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng) - totalTrenchCells += stampTrench(canvas, 34 + rng.int(-2, 2), 12 + rng.int(-1, 1), 7 + rng.int(-1, 1), 'horizontal', rng) - westObstacles += 3 - - // Breached Barricades (3 barricades) - const b1 = stampBreachedBarricade(canvas, 26 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng) - totalBarricadeWalls += b1.wallCount - totalTrenchCells += b1.breachFloorCount - - const b2 = stampBreachedBarricade(canvas, 42 + rng.int(-2, 2), 8 + rng.int(-1, 1), 12, 13, 3, rng) - totalBarricadeWalls += b2.wallCount - totalTrenchCells += b2.breachFloorCount - - const b3 = stampBreachedBarricade(canvas, 68 + rng.int(-2, 2), 26 + rng.int(-1, 1), 12, 31, 3, rng) - totalBarricadeWalls += b3.wallCount - totalTrenchCells += b3.breachFloorCount - westObstacles += 3 - - // Catapult Platforms (2 platforms) - stampCatapultPlatform(canvas, 12 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, 4, rng) - stampCatapultPlatform(canvas, 58 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, 4, rng) - catapultPlatformCount += 2 - westObstacles += 2 - - // Fort Ruins (2 ruins) - stampFortRuin(canvas, 30 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, rng) - stampFortRuin(canvas, 64 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, rng) - fortRuinCount += 2 - westObstacles += 2 - - // ------------------------------------------------------------------------- - // Zone 2: Mid Field (x in [80, 160)) - Medium density (No-man's land) - // ------------------------------------------------------------------------- - // Trenches (2 trenches) - totalTrenchCells += stampTrench(canvas, 98 + rng.int(-2, 2), 10 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng) - totalTrenchCells += stampTrench(canvas, 138 + rng.int(-2, 2), 28 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng) - midObstacles += 2 - - // Breached Barricades (2 barricades) - const b4 = stampBreachedBarricade(canvas, 114 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng) - totalBarricadeWalls += b4.wallCount - totalTrenchCells += b4.breachFloorCount - - const b5 = stampBreachedBarricade(canvas, 146 + rng.int(-2, 2), 8 + rng.int(-1, 1), 12, 13, 3, rng) - totalBarricadeWalls += b5.wallCount - totalTrenchCells += b5.breachFloorCount - midObstacles += 2 - - // Catapult Platforms (1 platform) - stampCatapultPlatform(canvas, 86 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, 4, rng) - catapultPlatformCount += 1 - midObstacles += 1 - - // Fort Ruins (1 ruin) - stampFortRuin(canvas, 124 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, rng) - fortRuinCount += 1 - midObstacles += 1 - - // ------------------------------------------------------------------------- - // Zone 3: East Rear (x in [160, 240)) - Low density (Harrogath outskirts) - // ------------------------------------------------------------------------- - // Trenches (1 trench) - totalTrenchCells += stampTrench(canvas, 186 + rng.int(-2, 2), 12 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng) - eastObstacles += 1 - - // Breached Barricades (1 barricade) - const b6 = stampBreachedBarricade(canvas, 202 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng) - totalBarricadeWalls += b6.wallCount - totalTrenchCells += b6.breachFloorCount - eastObstacles += 1 - - // Catapult Platforms (0 platforms in rear reserve) - - // Fort Ruins (1 ruin) - stampFortRuin(canvas, 170 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, rng) - fortRuinCount += 1 - eastObstacles += 1 - - // Record metrics and frontline density gradient - const siegeStats: SiegeTopographyStats = { - stripCount: placed.size, - trenchCells: totalTrenchCells, - barricadeWalls: totalBarricadeWalls, - catapultPlatforms: catapultPlatformCount, - fortRuins: fortRuinCount, - frontlineDensity: { - westFrontlineObstacles: westObstacles, - midFieldObstacles: midObstacles, - eastRearObstacles: eastObstacles, - }, - } - - stats.siegeTopography = siegeStats - stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + totalBarricadeWalls - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + totalTrenchCells - stats.notes.push('generated dynamic siege trenches and fortifications') - - return placed -} diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts deleted file mode 100644 index 4609e96..0000000 --- a/src/game/wilderness.ts +++ /dev/null @@ -1,6477 +0,0 @@ -/** - * Diablo II's wilderness level generator (`Levels.txt.DrlgType == 3`). - * - * 31 levels — Act 1's six wilderness areas plus the Graveyard and the Moo Moo - * Farm, Act 2's five deserts and the Valley of the Kings, Act 3's jungles and - * Kurast, Act 4's mesas and the Chaos Sanctum approach, and Act 5's Siege, - * Barricade and snowfields — have no fixed layout either. Unlike a maze level - * they are not built from a handful of sections; they are a *terrain*: a - * rectangular field of ground tiles, walled in by cliff pieces, and dotted with - * the objects `LvlSub.txt` describes. - * - * **Act I is no longer generated here.** Its eight outdoor levels (2-7, 17, 39) - * come from the function-by-function port of D2MOO's DRLG in `src/game/drlg`, - * checked bit for bit against the native oracle by - * `tests/drlg-act1-oracle.test.ts`. {@link generateWilderness} throws for them. - * - * ## Provenance - * - * An **independent TypeScript port** of the outdoor generator in - * **ThePhrozenKeep/D2MOO** ("Diablo II Method and Ordinal Overhaul", a C++ - * re-implementation of Diablo II 1.10f). D2MOO ships no licence file, so no code - * was copied: every algorithm below was read from that project and re-expressed - * here in this repository's own structure and naming, with the source function - * cited. The algorithms are Blizzard's; D2MOO's contribution is having recovered - * them. - * - * Sources, all under `source/D2Common/` in D2MOO: - * - * - `src/Drlg/DrlgOutdoors.cpp` — `DRLGOUTDOORS_GenerateLevel` (the entry point), - * `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` (how a preset DS1 is stamped into - * the block grid), `DRLGOUTDOORS_AddAct124SecondaryBorder`, - * `DRLGOUTDOORS_PlaceAct1245OutdoorBorders` (in `DrlgOutPlace.cpp`), - * `DRLGOUTWILD_InitAct1OutdoorLevel`. - * - `src/Drlg/DrlgOutPlace.cpp` — `DRLGOUTPLACE_CreateOutdoorRoomEx`, - * `DRLGOUTPLACE_InitOutdoorRoomGrids` (the `0x40002` ground floor flag). - * - `src/Drlg/DrlgTileSub.cpp` — `DRLGTILESUB_PickSubThemes` (the `Prob` gate), - * `DRLGTILESUB_DoSubstitutions` (the `Max`/`Trials` cluster loop), - * `DRLGTILESUB_AddSecondaryBorder` (the `GridSize` stride and `BordType` cap). - * - `include/DataTbls/LevelsTbls.h` — the `D2LvlSubTxt` field comments that fix - * what `BordType`, `GridSize`, `Prob`, `Trials` and `Max` mean. - * - `doc/Coordinates.md` — the coordinate systems. - * - * ## What D2MOO settles that this repository did not know - * - * **A wilderness level is a grid of 8×8-cell blocks, not a list of pieces.** - * `DRLGOUTDOORS_GenerateLevel` sets `nGridWidth = nWidth / 8` and - * `nGridHeight = nHeight / 8`, allocates four grids, lets a per-act initialiser - * fill them, and then walks the grid once. Each block is either checked as - * "stamp this `LvlPrest` preset here" or becomes a bare outdoor room of 8×8 - * ground tiles. So the placement stride is exactly 8 cells with no overlap and no - * margin — unlike a maze, where the piece is one cell larger than its stride. - * - * **`LvlSub.GridSize` does not size the level.** `Levels.txt.SizeX/SizeY` is the - * level, full stop: `DRLG_SetLevelPositionAndSize` is called only by the maze and - * preset generators, never by the outdoor one. `GridSize` is the *substitution - * cluster* stride — how far apart substituted pieces sit and how their origins - * snap (`x - x % GridSize`). - * - * **`BordType` does not choose the border DS1.** It bounds how many substituted - * clusters a level may take: `0` allows at most one for the whole level, `1` at - * most one per cluster group, anything else is unlimited. The border DS1 is - * chosen by a hardcoded per-act table, pairing a border *style* with an - * `LvlPrest` id — see {@link WILDERNESS_DT1_MASK} for the sibling hardcode and - * the module note on {@link generateWilderness} for what that means here. - * - * **`ProbN`/`TrialsN`/`MaxN` are indexed by `Levels.txt.SubTheme`, not by - * difficulty.** The knowledge base lists them as five opaque groups. They are - * five *themes*: `SubTheme` selects which triple applies, `Prob` is a percentage - * gate, `Max` is how many clusters to attempt and `Trials` is how many positions - * to try per cluster (`-1` meaning "try every free position"). The table bears - * this out — `SubTheme` runs 0..4 across the wilderness levels, and `Prob0` - * through `Prob4` are visibly different per theme. - * - * **The ground tile is not in any table.** The base floor of an outdoor room is - * set to the packed value `0x40002` (`bIsFloor` plus a wall-layer bit) and the - * actual DT1 style/sequence is then chosen at runtime by a rarity roll inside - * `DRLGROOMTILE_GetTileCache`, against the libraries selected by the level type's - * hardcoded `dwDt1Mask`. This port supports multi-DT1 weighted ground tile blending - * via `fillGround` using `dwDt1Mask` / `RarityFrameIndex` or candidate ground tiles, - * with single-tile fallback for backward compatibility — see {@link WildernessRequest.groundTile}. - */ -import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from '../formats/ds1.ts' -import type { Dt1 } from '../formats/dt1.ts' -import { Rng } from './rng.ts' -import { SUB_TILES_PER_TILE } from './map.ts' -import type { Side } from './world-graph.ts' -import { - initAct2OutdoorLevel, - initAct3OutdoorLevel, - stampAct3KurastCauseway, - initAct4OutdoorLevel, - initAct5OutdoorLevel, - type Act2TopographyStats, - type Act3TopographyStats, - type Act4TopographyStats, - type Act5TopographyStats, -} from './wilderness-acts.ts' -import { - DRLG_GenerateJungles, - buildJungleTopology, - type JungleTopology, - type JungleZoneConnection, - type JungleLayoutStats, - type JungleRiverNetwork, -} from './wilderness-jungles.ts' -import { - stampAct5SiegeStrips, - type SiegeTopographyStats, -} from './wilderness-siege.ts' -import { cell, actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts' -import type { D2Table } from './acts.ts' -export { actOfLevel, hasOutdoorWaypoint, OUTDOOR_WAYPOINT_LEVELS } from './acts.ts' -import { lookupObject, isWaypointDs1Object } from './object-lookup.ts' -import type { ObjectsTable } from './objects.ts' -import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts' -import type { SuperUnique } from './monsters.ts' -import { DRLG_GetActNoFromLevelId } from './drlg/drlg-drlg.ts' - -export { - DRLG_GenerateJungles, - buildJungleTopology, - type JungleTopology, - type JungleZoneConnection, - type JungleLayoutStats, - type JungleRiverNetwork, - stampAct5SiegeStrips, - type SiegeTopographyStats, -} - -/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */ -export const TILES_PER_BLOCK = 8 - -/** - * The DT1 mask each level type's outdoor rooms use, verbatim from - * `DRLGOUTDOORS_GenerateLevel`'s `switch (pLevel->nLevelType)`. - * - * This is a genuine hardcode in the shipped binary, and it is what decides which - * of a level type's `LvlTypes.txt` `File 1..32` libraries supply the outdoor - * ground. Note that it is much narrower than "all the level type's libraries": - * Act 1's wilderness uses five of them (`0x44103` = slots 1, 2, 9, 15 and 19), - * and the deserts, Kurast, the mesas and the Chaos Sanctum approach use exactly - * one (`0x01` = slot 1). - */ -export const WILDERNESS_DT1_MASK: Readonly> = { - 'Act 1 - Wilderness': 0x44103, - 'Act 3 - Jungle': 0x04, - 'Act 2 - Desert': 0x01, - 'Act 3 - Kurast': 0x01, - 'Act 4 - Mesa': 0x01, - 'Act 4 - Lava': 0x01, - 'Act 5 - Siege': 0x11, - 'Act 5 - Barricade': 0x11, -} - -/** - * The ground DT1 mask for a level type. - * - * @param levelTypeName - `LvlTypes.txt` name. - * @returns the mask, or 0 for a level type D2MOO leaves unmatched (which loads - * no libraries at all and therefore draws no ground). - */ -export function wildernessDt1Mask(levelTypeName: string, subType?: number, levelName?: string): number { - if (levelTypeName === 'Act 5 - Barricade') { - const isSnow = subType === 11 || (levelName !== undefined && /snow|tundra/i.test(levelName)) - return isSnow ? 0x10 : 0x01 - } - return WILDERNESS_DT1_MASK[levelTypeName] ?? 0 -} - -/* ------------------------------------------------------------------------- * - * Public request / response shapes - * ------------------------------------------------------------------------- */ - -/** - * One `LvlPrest.txt` row that can be stamped into a wilderness level. - * - * These are the border and fill pieces — `Act 1 - Wild Border 1..12`, - * `Act 1 - Wild Cliff Border 2..10`, `Act 2 - Desert Border 1..12`, - * `Act 2 - Desert Fill *`, `Act 3 - Jungle W/E/...` and so on. Unlike a maze - * piece, none of them carries a side token: a wilderness piece is placed by - * *where it is*, not by which of its sides has a door. - */ -export interface WildernessPiece { - /** `LvlPrest.txt` `Name`. */ - readonly name: string - /** Decoded variants, in `File1..File6` order. */ - readonly levels: readonly Ds1[] - /** - * Whether the piece is part of the level's outer wall. - * - * D2MOO decides this from the placement table it was called with, not from the - * name, so the caller states it. Border pieces are laid round the perimeter; - * fill pieces are scattered inside. - */ - readonly border: boolean - /** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */ - readonly animSpeed?: number -} - -/** - * One `LvlSub.txt` row: a set of objects or terrain patches the level may scatter. - * - * `Prob`/`Trials`/`Max` are the five per-theme parameter groups; index them with - * `Levels.txt.SubTheme`. - */ -export interface WildernessSubstitution { - /** `LvlSub.txt` `Name`, e.g. `Trees`. */ - readonly name: string - /** `LvlSub.txt` `Type`, which `Levels.txt.SubType` (or `SubShrine`) selects. */ - readonly type: number - /** `LvlSub.txt` `GridSize`: cluster stride and origin snap, in blocks. */ - readonly gridSize: number - /** `LvlSub.txt` `BordType`: how many clusters the level may take. */ - readonly bordType: number - /** `LvlSub.txt` `Dt1Mask`, OR-ed into the room's mask when the theme is picked. */ - readonly dt1Mask: number - /** `LvlSub.txt` `Prob0..4`, in per-cent. */ - readonly prob: readonly number[] - /** `LvlSub.txt` `Trials0..4`; `-1` means "try every free position". */ - readonly trials: readonly number[] - /** `LvlSub.txt` `Max0..4`: clusters to attempt. */ - readonly max: readonly number[] - /** The decoded `LvlSub.txt` `File`; index 0 is enough for every shipped row. */ - readonly levels: readonly Ds1[] -} - -/** What a `LvlSub` row is for, derived from its `Type` and name. */ -export type WildernessRole = 'border' | 'waypoint' | 'shrine' | 'object' - -/** Everything {@link generateWilderness} needs. */ -export interface WildernessRequest { - /** `Levels.txt` `Id`. */ - readonly levelId: number - /** `Levels.txt` `Name`. */ - readonly levelName: string - /** `LvlTypes.txt` name, e.g. `Act 1 - Wilderness`. */ - readonly levelTypeName: string - /** `Levels.txt` `SizeX`, in cells. */ - readonly sizeX: number - /** `Levels.txt` `SizeY`, in cells. */ - readonly sizeY: number - /** `Levels.txt` `SubType`, selecting the `LvlSub` rows to scatter. */ - readonly subType: number - /** `Levels.txt` `SubTheme`, indexing `Prob`/`Trials`/`Max`. */ - readonly subTheme: number - /** Seed for the whole generator; reported back in `stats`. */ - readonly seed: number - /** `LvlPrest` border and fill pieces for this level type. */ - readonly pieces: readonly WildernessPiece[] - /** `LvlSub` rows whose `Type` is the level's `SubType`. */ - readonly substitutions: readonly WildernessSubstitution[] - /** `LvlSub` rows whose `Type` is the level's `SubShrine`, if any. */ - readonly shrineSubstitutions?: readonly WildernessSubstitution[] - /** - * Special preset names to spawn in the level's interior. - * - * When omitted, defaults to {@link SPECIAL_PRESETS_BY_LEVEL} for the level's ID. - */ - readonly specialPresets?: readonly string[] - /** Optional loaded Levels.txt table to drive act resolution, waypoints, and object queries. */ - readonly levelsTable?: D2Table - /** - * The ground tile to lay under everything, as a DT1 `style`/`sequence` pair. - * Can also accept an array of {@link WeightedGroundTile} candidates for multi-tile blending. - * - * The shipped game leaves this to a runtime rarity roll over the DT1 libraries - * the level type's mask selects, which no table records. When omitted, - * {@link generateWilderness} derives it from the level's own pieces or DT1 libraries, - * recording the choice in `stats.groundTile` (and `stats.groundTiles`). - */ - readonly groundTile?: { readonly style: number; readonly sequence: number } | readonly WeightedGroundTile[] - /** - * Array of candidate ground tiles with relative weights for multi-tile ground blending. - * When provided, `fillGround` blends these tiles across the canvas. - */ - readonly groundTiles?: readonly WeightedGroundTile[] - /** - * DT1 libraries loaded for the level. When supplied without explicit groundTile(s), - * candidate ground tiles are derived using dwDt1Mask and RarityFrameIndex. - */ - readonly dt1Libraries?: readonly Dt1[] - /** - * Explicit block-grid size, for the levels `Levels.txt` leaves unset. - * - * Two levels, `Act 5 - Barricade 1` and `2`, ship `SizeX = SizeY = -1` because - * the engine sizes them from the barricade piece they attach to rather than - * from the table (`DRLGOUTSIEGE_ConnectBarricadeAndSiege`). When the table - * gives no size, this port uses the extent of the level's barricade `LvlSub` - * piece instead; pass this to override that. - */ - readonly sizeOverride?: { readonly x: number; readonly y: number } - /** - * The cross-level openings this level must have, and which edge each is on. - * - * Supplied by the world graph rather than decided here, for two reasons. - * - * The first is that the sides are not a property of the level: Diablo II's - * outdoor placer re-picks them for every seed, subject to the constraint that - * two openings of one level may not share an edge. Hard-coding "the Cold - * Plains exit is on the north edge" states one legal roll as if it were a - * law. - * - * The second is that a seam has two ends. If this level puts its exit on the - * east edge, its neighbour must put the matching entrance on its west edge, - * and neither level can work that out alone. `assignGateSides` in - * `world-graph.ts` solves the whole act at once and hands each level the - * answer. - * - * When omitted the generator falls back to its own Act 1 table, which is what - * the tests and `verify-generators` use. - */ - readonly gates?: readonly PlannedGate[] - /** Optional seam constraints from predecessor/connected levels for 1:1 spatial alignment. */ - readonly seamConstraints?: readonly SeamConstraint[] - /** Optional forced gate variant indices per block key (`${x},${y}`). */ - readonly openGates?: ReadonlyMap - /** Optional set of block keys (`${x},${y}`) claimed by transition/facade presets to skip during border placement. */ - readonly claimedBorderBlocks?: ReadonlySet - /** Optional `SuperUniques.txt` table for data-driven outdoor boss and landmark generation. */ - readonly superuniques?: D2Table - /** Optional `MonPreset.txt` table for data-driven DS1 spawner object IDs. */ - readonly monpreset?: D2Table - /** Optional custom landmark entity definitions overriding CANONICAL_LANDMARK_ENTITIES. */ - readonly customLandmarks?: readonly LandmarkEntityDef[] -} - -/** One cross-level opening, as decided by the act layout. */ -export interface PlannedGate { - /** Which edge of this level the opening sits on. */ - readonly side: Side - /** The level on the other side. */ - readonly toLevelId: number - /** A human-readable name, used for the road anchor. */ - readonly label: string - /** - * Which border-piece variant to open, if the caller has a preference. - * - * Three is the ordinary open gate; four is the variant the Burial Grounds and - * the Cold Plains use for the graveyard seam. - */ - readonly variant?: number | undefined - /** Exact block coordinate along the perimeter edge (0..gridWidth-1 or 0..gridHeight-1) */ - readonly blockCoord?: number | undefined - /** Optional exact tile coordinate within the level */ - readonly tileCoord?: { readonly x: number; readonly y: number } | undefined -} - -/** Explicit spatial alignment constraint across a level seam. */ -export interface SeamConstraint { - readonly fromLevelId?: number | undefined - readonly toLevelId?: number | undefined - readonly side: Side - readonly blockCoord: number - readonly tileCoord?: { readonly x: number; readonly y: number } | undefined -} - -/** Placed gate opening along the level's perimeter. */ -export interface GateOpening { - readonly side: Side - readonly toLevelId: number - readonly blockCoord: number - readonly tileCoord: { readonly x: number; readonly y: number } -} - -/** The generated level. */ -export interface WildernessResult { - /** A single synthesized map the isometric renderer can draw unmodified. */ - readonly level: Ds1 - /** What the generator did, for reporting and tests. */ - readonly stats: Record & { - readonly landmarkEntities?: LandmarkEntity[] - readonly notes?: string[] - readonly unresolved?: string[] - readonly gateOpenings?: readonly GateOpening[] - readonly groundTile?: { readonly style: number; readonly sequence: number } | null | undefined - readonly groundTiles?: readonly WeightedGroundTile[] | undefined - readonly animSpeed?: number - } - /** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */ - readonly animSpeed?: number -} - -/** - * Canonical default open ground floor tiles per wilderness level type of Acts 2-5 (Act I outdoor - * ground comes from the DRLG port, src/game/drlg). - */ -export const CANONICAL_GROUND_TILES: Readonly> = { - 'Act 2 - Desert': { style: 0, sequence: 1 }, - 'Act 3 - Jungle': { style: 0, sequence: 0 }, - 'Act 3 - Kurast': { style: 1, sequence: 0 }, - 'Act 4 - Mesa': { style: 10, sequence: 0 }, - 'Act 4 - Lava': { style: 20, sequence: 3 }, - 'Act 5 - Siege': { style: 0, sequence: 0 }, - 'Act 5 - Barricade': { style: 0, sequence: 0 }, - 'Act 5 - Barricade Snow': { style: 6, sequence: 0 }, -} - -/** Canonical default open ground floor tile pools per wilderness level type of Acts 2-5. */ -export const CANONICAL_GROUND_POOLS: Readonly> = { - 'Act 2 - Desert': [ - { style: 0, sequence: 1, weight: 25 }, - { style: 0, sequence: 2, weight: 20 }, - { style: 0, sequence: 50, weight: 8 }, - { style: 0, sequence: 0, weight: 6 }, - ], - 'Act 3 - Jungle': [ - { style: 0, sequence: 0, weight: 12 }, - { style: 0, sequence: 1, weight: 8 }, - { style: 0, sequence: 3, weight: 2 }, - { style: 0, sequence: 4, weight: 2 }, - ], - 'Act 3 - Kurast': [ - { style: 1, sequence: 0, weight: 16 }, - { style: 1, sequence: 11, weight: 2 }, - { style: 1, sequence: 12, weight: 2 }, - { style: 1, sequence: 13, weight: 2 }, - ], - 'Act 4 - Mesa': [ - { style: 10, sequence: 2, weight: 10 }, - { style: 10, sequence: 0, weight: 9 }, - { style: 10, sequence: 1, weight: 9 }, - { style: 10, sequence: 12, weight: 9 }, - ], - 'Act 4 - Lava': [ - { style: 20, sequence: 3, weight: 45 }, - { style: 20, sequence: 0, weight: 45 }, - { style: 20, sequence: 1, weight: 45 }, - { style: 20, sequence: 2, weight: 45 }, - { style: 20, sequence: 4, weight: 45 }, - { style: 20, sequence: 5, weight: 45 }, - { style: 20, sequence: 6, weight: 45 }, - { style: 20, sequence: 7, weight: 45 }, - ], - 'Act 5 - Siege': [ - { style: 0, sequence: 0, weight: 10 }, - { style: 0, sequence: 1, weight: 4 }, - ], - 'Act 5 - Barricade': [ - { style: 0, sequence: 0, weight: 10 }, - { style: 0, sequence: 1, weight: 4 }, - ], - 'Act 5 - Barricade Snow': [ - { style: 6, sequence: 0, weight: 73 }, - { style: 6, sequence: 1, weight: 42 }, - { style: 6, sequence: 2, weight: 42 }, - { style: 6, sequence: 4, weight: 42 }, - { style: 6, sequence: 3, weight: 40 }, - ], -} - -/* ------------------------------------------------------------------------- * - * Classification - * ------------------------------------------------------------------------- */ - -/** - * Classify a `LvlSub.txt` row. - * - * `LvlSub.Type` is the join key against `Levels.txt.SubType` and `SubShrine`, and - * the shipped rows fall into fixed bands: `0..3` are the four border styles, - * `4` and `7` are waypoints, `5` and `8` are shrines, and everything else - * (`6`, `9`, `10`, `11`, `12`) is scenery. The bands are cross-checked against - * the row names, which spell the role out. - * - * @param name - `LvlSub.txt` `Name`. - * @param type - `LvlSub.txt` `Type`. - * @returns the role. - */ -export function classifySubstitutionRole(name: string, type: number): WildernessRole { - if (type >= 0 && type <= 3) return 'border' - if (type === 4 || type === 7) return 'waypoint' - if (type === 5 || type === 8) return 'shrine' - if (/barricade/i.test(name)) return 'border' - return 'object' -} - -/* ------------------------------------------------------------------------- * - * The canvas - * ------------------------------------------------------------------------- */ - -/** A blank wall cell, matching the decoder's placeholder. */ -export function emptyWall(): Ds1Wall { - return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -/** A blank floor or shadow cell. */ -export function emptyFloor(): Ds1Floor { - return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -/** The mutable map under construction. */ -export interface Canvas { - readonly width: number - readonly height: number - readonly cells: Ds1Cell[][] - readonly wallLayers: number - readonly floorLayers: number - readonly substitutionLayers: number - readonly objects: Ds1Object[] -} - -/** - * Create an empty map of the given cell extent. - * - * @param width - cells. - * @param height - cells. - * @param wallLayers - wall layers every cell carries. - * @param floorLayers - floor layers every cell carries. - * @param substitutionLayers - substitution layers every cell carries. - * @returns the canvas. - */ -export function createCanvas(width: number, height: number, wallLayers: number, floorLayers: number, substitutionLayers: number): Canvas { - 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: Array.from({ length: wallLayers }, emptyWall), - floors: Array.from({ length: floorLayers }, emptyFloor), - shadows: [emptyFloor()], - substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })), - }) - } - cells.push(row) - } - return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] } -} - -/** - * Copy a decoded map's cells into the canvas at a cell offset. - * - * Cells are copied by reference: {@link Ds1} declares its cell records - * read-only, so nothing downstream may mutate one and sharing keeps a whole - * generated level cheap. Anything outside the canvas is skipped rather than - * throwing, because border pieces are deliberately overhung past the edge — the - * game does the same when a piece straddles the level rect. - * - * Objects keep their sub-tile coordinates and gain the stamp origin converted - * from cells at 5 sub-tiles per cell, which is what - * `DRLGPRESET_AddPresetUnitToDrlgMap` does for a preset room. - * - * @param canvas - the canvas. - * @param source - the piece to stamp. - * @param originX - left edge in cells. - * @param originY - top edge in cells. - * @returns the number of cells actually written. - */ -export function stampDs1(canvas: Canvas, source: Ds1, originX: number, originY: number): number { - let written = 0 - for (let y = 0; y < source.height; y += 1) { - const targetY = originY + y - if (targetY < 0 || targetY >= canvas.height) continue - const sourceRow = source.cells[y] - if (sourceRow === undefined) continue - const targetRow = canvas.cells[targetY]! - for (let x = 0; x < source.width; x += 1) { - const targetX = originX + x - if (targetX < 0 || targetX >= canvas.width) continue - const cell = sourceRow[x] - if (cell === undefined) continue - const targetCell = targetRow[targetX]! - - // Floors: copy any non-empty floor layer from source. If source cell has no - // non-empty floors, target's ground floor is preserved intact. - for (let i = 0; i < cell.floors.length; i += 1) { - const floor = cell.floors[i] - if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) { - const targetFloors = targetCell.floors as Ds1Floor[] - if (i < targetFloors.length) { - targetFloors[i] = floor - } else { - targetFloors.push(floor) - } - } - } - - // Walls: copy any non-empty wall layer from source. - for (let i = 0; i < cell.walls.length; i += 1) { - const wall = cell.walls[i] - if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) { - const targetWalls = targetCell.walls as Ds1Wall[] - if (i < targetWalls.length) { - targetWalls[i] = wall - } else { - targetWalls.push(wall) - } - } - } - - // Shadows: copy any non-empty shadow layer from source. - for (let i = 0; i < cell.shadows.length; i += 1) { - const shadow = cell.shadows[i] - if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) { - const targetShadows = targetCell.shadows as Ds1Floor[] - if (i < targetShadows.length) { - targetShadows[i] = shadow - } else { - targetShadows.push(shadow) - } - } - } - - // Substitutions: copy non-zero substitution layer values. - for (let i = 0; i < cell.substitutions.length; i += 1) { - const sub = cell.substitutions[i] - if (sub !== undefined && sub.value !== 0) { - const targetSubs = targetCell.substitutions as Ds1Substitution[] - if (i < targetSubs.length) { - targetSubs[i] = sub - } else { - targetSubs.push(sub) - } - } - } - - written += 1 - } - } - for (const object of source.objects) { - canvas.objects.push({ - type: object.type, - id: object.id, - x: originX * SUB_TILES_PER_TILE + object.x, - y: originY * SUB_TILES_PER_TILE + object.y, - flags: object.flags, - }) - } - return written -} - -/** A candidate ground tile with an optional weighting/rarity. */ -export interface WeightedGroundTile { - readonly style: number - readonly sequence: number - /** - * Relative selection weight (e.g. from `RarityFrameIndex`, `Rarity`, or piece counts). - * Defaults to 1 (or `rarityFrameIndex` / `rarity` / `Rarity`) if omitted or <= 0. - */ - readonly weight?: number - /** - * Direct DT1 `rarityFrameIndex`, if sourced from a DT1 tile. - */ - readonly rarityFrameIndex?: number - /** - * Direct DT1 `rarity` (alternative field name used in some loaders/representations). - */ - readonly rarity?: number - /** - * Direct DT1 `Rarity` (uppercase field name used in native structs). - */ - readonly Rarity?: number - /** Optional library slot index where the tile originated. */ - readonly libraryIndex?: number - /** Optional tile index within the DT1 library. */ - readonly tileIndex?: number - /** Optional tile type (0 for floor). */ - readonly type?: number -} - -/** Configuration options for ground tile blending. */ -export interface FillGroundOptions { - /** PRNG seed or Rng instance for deterministic tile generation. */ - readonly seed?: number | Rng - /** Blending mode: 'cdf' (cell-by-cell weighted draw) or 'spatial' (coherent noise patches). Default: 'spatial'. */ - readonly mode?: 'cdf' | 'spatial' - /** Spatial noise scale in cells (default: 6). Higher values produce broader patches. */ - readonly spatialScale?: number - /** Weight of spatial coherence vs cell jitter in [0, 1] (default: 0.6). */ - readonly spatialCoherence?: number -} - -/** - * Fast, high-quality 32-bit integer hash for 2D coordinates and a seed. - * Returns a uniform pseudo-random number in [0, 1). - */ -export function cellHash(x: number, y: number, seed: number): number { - let h = (seed ^ Math.imul(x, 0x1b873593) ^ Math.imul(y, 0x85ebca6b)) >>> 0 - h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0 - h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0 - h = (h ^ (h >>> 16)) >>> 0 - return h / 4294967296 -} - -/** - * 2D smooth value noise with cubic Hermite interpolation. - * Returns a value in [0, 1). - */ -export function smoothNoise2D(x: number, y: number, scale: number, seed: number): number { - const invScale = 1 / Math.max(1, scale) - const gx = Math.floor(x * invScale) - const gy = Math.floor(y * invScale) - const fx = x * invScale - gx - const fy = y * invScale - gy - - // Cubic Hermite smoothstep: 3t^2 - 2t^3 - const sx = fx * fx * (3 - 2 * fx) - const sy = fy * fy * (3 - 2 * fy) - - const n00 = cellHash(gx, gy, seed) - const n10 = cellHash(gx + 1, gy, seed) - const n01 = cellHash(gx, gy + 1, seed) - const n11 = cellHash(gx + 1, gy + 1, seed) - - const nx0 = n00 + sx * (n10 - n00) - const nx1 = n01 + sx * (n11 - n01) - return nx0 + sy * (nx1 - nx0) -} - -function isTileArray( - val: unknown, -): val is readonly unknown[] { - return Array.isArray(val) -} - -/** - * Sample a single ground tile at (x, y) from weighted candidate tiles. - */ -export function sampleGroundTile( - tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[], - x: number, - y: number, - optionsOrSeed?: number | Rng | FillGroundOptions, -): WeightedGroundTile { - if (!isTileArray(tileOrTiles)) return tileOrTiles - if (tileOrTiles.length === 0) return { style: 0, sequence: 0 } - const tiles = tileOrTiles as readonly WeightedGroundTile[] - if (tiles.length === 1) return tiles[0]! - - let seed = 0 - let mode: 'cdf' | 'spatial' = 'spatial' - let spatialScale = 6 - let spatialCoherence = 0.6 - - if (typeof optionsOrSeed === 'number') { - seed = optionsOrSeed >>> 0 - } else if (optionsOrSeed instanceof Rng) { - seed = optionsOrSeed.seed >>> 0 - } else if (optionsOrSeed && typeof optionsOrSeed === 'object') { - if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0 - else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0 - if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode - if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) { - spatialScale = optionsOrSeed.spatialScale - } - if (typeof optionsOrSeed.spatialCoherence === 'number') { - spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence)) - } - } - - const valid = tileOrTiles.filter(t => { - const w = t.weight !== undefined ? t.weight : t.rarityFrameIndex !== undefined ? t.rarityFrameIndex : 1 - return w > 0 - }) - const active = valid.length > 0 ? valid : tileOrTiles - if (active.length === 1) return active[0]! - - let totalWeight = 0 - for (const c of active) { - const w = c.weight !== undefined ? c.weight : c.rarityFrameIndex !== undefined ? c.rarityFrameIndex : 1 - totalWeight += Math.max(0, w) - } - if (totalWeight <= 0) return active[0]! - - const cdfs: number[] = [] - let running = 0 - for (let i = 0; i < active.length; i += 1) { - const w = active[i]!.weight !== undefined - ? active[i]!.weight! - : active[i]!.rarityFrameIndex !== undefined - ? active[i]!.rarityFrameIndex! - : 1 - running += Math.max(0, w) - cdfs.push(running / totalWeight) - } - cdfs[cdfs.length - 1] = 1.0 - - let u: number - if (mode === 'cdf') { - u = cellHash(x, y, seed) - } else { - const smooth = smoothNoise2D(x, y, spatialScale, seed) - const jitter = cellHash(x, y, seed ^ 0x9e3779b9) - u = spatialCoherence * smooth + (1 - spatialCoherence) * jitter - } - - for (let i = 0; i < cdfs.length; i += 1) { - if (u < cdfs[i]!) return active[i]! - } - return active[active.length - 1]! -} - -/** - * Write one or more floor tiles into every cell of a rectangle with weighted random blending. - * - * Supports: - * 1. Single tile fallback (backward compatibility): All cells receive the single tile. - * 2. Multi-tile weighted distribution: Tiles are sampled via CDF sampling or spatial noise, - * naturally blending variations (e.g. pure grass, pebbled dirt, weeds) across the ground - * without jarring discontinuities. - * - * @param canvas - the canvas. - * @param tileOrTiles - single tile or array of weighted candidate tiles. - * @param x - left edge in cells. - * @param y - top edge in cells. - * @param width - cells. - * @param height - cells. - * @param optionsOrSeed - PRNG seed, Rng instance, or blending options. - * @returns the number of cells written. - */ -export function fillGround( - canvas: Canvas, - tileOrTiles: WeightedGroundTile | readonly WeightedGroundTile[], - x: number, - y: number, - width: number, - height: number, - optionsOrSeed?: number | Rng | FillGroundOptions, -): number { - if (!isTileArray(tileOrTiles)) { - const tile = tileOrTiles - let written = 0 - for (let cy = y; cy < y + height; cy += 1) { - if (cy < 0 || cy >= canvas.height) continue - const row = canvas.cells[cy]! - for (let cx = x; cx < x + width; cx += 1) { - if (cx < 0 || cx >= canvas.width) continue - const cell = row[cx]! - const floor = cell.floors[0] - if (floor === undefined) continue - Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) - written += 1 - } - } - return written - } - - if (tileOrTiles.length === 0) return 0 - const tiles = tileOrTiles as readonly WeightedGroundTile[] - - if (tiles.length === 1) { - const tile = tiles[0]! - let written = 0 - for (let cy = y; cy < y + height; cy += 1) { - if (cy < 0 || cy >= canvas.height) continue - const row = canvas.cells[cy]! - for (let cx = x; cx < x + width; cx += 1) { - if (cx < 0 || cx >= canvas.width) continue - const cell = row[cx]! - const floor = cell.floors[0] - if (floor === undefined) continue - Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) - written += 1 - } - } - return written - } - - const validCandidates: { style: number; sequence: number; weight: number }[] = [] - for (const t of tileOrTiles) { - const w = t.weight !== undefined - ? Math.max(0, t.weight) - : t.rarityFrameIndex !== undefined - ? Math.max(0, t.rarityFrameIndex) - : 1 - if (w > 0) { - validCandidates.push({ style: t.style, sequence: t.sequence, weight: w }) - } - } - - const active = validCandidates.length > 0 - ? validCandidates - : tileOrTiles.map(t => ({ style: t.style, sequence: t.sequence, weight: 1 })) - - if (active.length === 1) { - const tile = active[0]! - let written = 0 - for (let cy = y; cy < y + height; cy += 1) { - if (cy < 0 || cy >= canvas.height) continue - const row = canvas.cells[cy]! - for (let cx = x; cx < x + width; cx += 1) { - if (cx < 0 || cx >= canvas.width) continue - const cell = row[cx]! - const floor = cell.floors[0] - if (floor === undefined) continue - Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) - written += 1 - } - } - return written - } - - let totalWeight = 0 - for (const c of active) totalWeight += c.weight - const cdfThresholds: number[] = [] - let running = 0 - for (let i = 0; i < active.length; i += 1) { - running += active[i]!.weight - cdfThresholds.push(running / totalWeight) - } - cdfThresholds[cdfThresholds.length - 1] = 1.0 - - const pickCandidate = (u: number): { style: number; sequence: number } => { - for (let i = 0; i < cdfThresholds.length; i += 1) { - if (u < cdfThresholds[i]!) return active[i]! - } - return active[active.length - 1]! - } - - let seed = 0 - let mode: 'cdf' | 'spatial' = 'spatial' - let spatialScale = 6 - let spatialCoherence = 0.6 - - if (typeof optionsOrSeed === 'number') { - seed = optionsOrSeed >>> 0 - } else if (optionsOrSeed instanceof Rng) { - seed = optionsOrSeed.seed >>> 0 - } else if (optionsOrSeed && typeof optionsOrSeed === 'object') { - if (typeof optionsOrSeed.seed === 'number') seed = optionsOrSeed.seed >>> 0 - else if (optionsOrSeed.seed instanceof Rng) seed = optionsOrSeed.seed.seed >>> 0 - if (optionsOrSeed.mode !== undefined) mode = optionsOrSeed.mode - if (typeof optionsOrSeed.spatialScale === 'number' && optionsOrSeed.spatialScale > 0) { - spatialScale = optionsOrSeed.spatialScale - } - if (typeof optionsOrSeed.spatialCoherence === 'number') { - spatialCoherence = Math.max(0, Math.min(1, optionsOrSeed.spatialCoherence)) - } - } - - if (mode === 'cdf') { - let written = 0 - for (let cy = y; cy < y + height; cy += 1) { - if (cy < 0 || cy >= canvas.height) continue - const row = canvas.cells[cy]! - for (let cx = x; cx < x + width; cx += 1) { - if (cx < 0 || cx >= canvas.width) continue - const cell = row[cx]! - const floor = cell.floors[0] - if (floor === undefined) continue - const u = cellHash(cx, cy, seed) - const picked = pickCandidate(u) - Object.assign(floor, { prop1: 2, style: picked.style, sequence: picked.sequence, hidden: false }) - written += 1 - } - } - return written - } - - interface CellEntry { - floor: Ds1Floor - v: number - index: number - } - const cellsToWrite: CellEntry[] = [] - - for (let cy = y; cy < y + height; cy += 1) { - if (cy < 0 || cy >= canvas.height) continue - const row = canvas.cells[cy]! - for (let cx = x; cx < x + width; cx += 1) { - if (cx < 0 || cx >= canvas.width) continue - const cell = row[cx]! - const floor = cell.floors[0] - if (floor === undefined) continue - const smooth = smoothNoise2D(cx, cy, spatialScale, seed) - const jitter = cellHash(cx, cy, seed ^ 0x9e3779b9) - const v = spatialCoherence * smooth + (1 - spatialCoherence) * jitter - cellsToWrite.push({ - floor, - v, - index: cy * canvas.width + cx, - }) - } - } - - if (cellsToWrite.length === 0) return 0 - - cellsToWrite.sort((a, b) => (a.v !== b.v ? a.v - b.v : a.index - b.index)) - - const totalCells = cellsToWrite.length - for (let r = 0; r < totalCells; r += 1) { - const u = (r + 0.5) / totalCells - const picked = pickCandidate(u) - Object.assign(cellsToWrite[r]!.floor, { - prop1: 2, - style: picked.style, - sequence: picked.sequence, - hidden: false, - }) - } - - return totalCells -} - -/* ------------------------------------------------------------------------- * - * Ground selection - * ------------------------------------------------------------------------- */ - -/** - * Extract candidate ground floor tiles from DT1 libraries filtered by dwDt1Mask. - * - * In Diablo II outdoor levels, DRLGROOMTILE_GetTileCache scans the libraries - * activated by the level type's dwDt1Mask (switch on nLevelType in DRLGOUTDOORS_GenerateLevel). - * Floor tiles (tile.type === 0) with positive RarityFrameIndex are accumulated - * into candidate ground tiles weighted by their rarity. - * - * @param libraries - All DT1 libraries resolved for the level (in slot order). - * @param dt1Mask - The bitmask selecting which DT1 slots are active for ground. - * @returns Array of weighted ground tiles. - */ -export function deriveGroundTilesFromDt1Libraries( - libraries: readonly Dt1[], - dt1Mask: number, -): WeightedGroundTile[] { - const counts = new Map< - string, - { style: number; sequence: number; weight: number; libraryIndex: number; tileIndex: number } - >() - - for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) { - if ((dt1Mask & (1 << libIndex)) === 0) continue - const lib = libraries[libIndex] - if (!lib) continue - for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) { - const tile = lib.tiles[tIndex]! - if (tile.type !== 0) continue - const rawRarity = - (tile as unknown as { rarity?: number; Rarity?: number }).rarity ?? - (tile as unknown as { rarity?: number; Rarity?: number }).Rarity ?? - tile.rarityFrameIndex - const rarity = Math.max(0, rawRarity | 0) - if (rarity <= 0) continue - const key = `${String(tile.style)}:${String(tile.sequence)}` - const existing = counts.get(key) - if (existing === undefined) { - counts.set(key, { style: tile.style, sequence: tile.sequence, weight: rarity, libraryIndex: libIndex, tileIndex: tIndex }) - } else { - existing.weight += rarity - } - } - } - - if (counts.size === 0) { - for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) { - if ((dt1Mask & (1 << libIndex)) === 0) continue - const lib = libraries[libIndex] - if (!lib) continue - for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) { - const tile = lib.tiles[tIndex]! - if (tile.type !== 0) continue - const key = `${String(tile.style)}:${String(tile.sequence)}` - if (!counts.has(key)) { - counts.set(key, { style: tile.style, sequence: tile.sequence, weight: 1, libraryIndex: libIndex, tileIndex: tIndex }) - } - } - } - } - - const result: WeightedGroundTile[] = Array.from(counts.values()).map(e => ({ - style: e.style, - sequence: e.sequence, - weight: e.weight, - rarityFrameIndex: e.weight, - rarity: e.weight, - Rarity: e.weight, - libraryIndex: e.libraryIndex, - tileIndex: e.tileIndex, - type: 0, - })) - result.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)) - return result -} - -/** - * Derive candidate ground tiles from the level's own border and fill pieces. - * - * Scans interior cells of each piece variant for visible floor tiles, tallying - * their occurrences to use as relative weights. - * - * @param pieces - Border and fill pieces. - * @returns Array of weighted ground tiles sorted by weight descending. - */ -export function deriveGroundTiles(pieces: readonly WildernessPiece[]): WeightedGroundTile[] { - const counts = new Map() - const tally = (style: number, sequence: number): void => { - const key = `${String(style)}:${String(sequence)}` - const entry = counts.get(key) - if (entry === undefined) counts.set(key, { style, sequence, count: 1 }) - else entry.count += 1 - } - for (const piece of pieces) { - for (const level of piece.levels) { - for (let y = 0; y < level.height; y += 1) { - const row = level.cells[y] - if (row === undefined) continue - for (let x = 0; x < level.width; x += 1) { - if (x === 0 || y === 0 || x === level.width - 1 || y === level.height - 1) continue - const cell = row[x] - if (cell === undefined) continue - for (const floor of cell.floors) { - if (floor.hidden || floor.prop1 === 0) continue - tally(floor.style, floor.sequence) - } - } - } - } - } - const result = Array.from(counts.values()).map(e => ({ - style: e.style, - sequence: e.sequence, - weight: e.count, - })) - result.sort((a, b) => b.weight - a.weight) - return result -} - -/** - * Pick the tile the level's own pieces stand on. - * - * The shipped game picks the ground with a rarity roll over the DT1 libraries - * that the level type's hardcoded mask selects (`DRLGROOMTILE_GetTileCache`), - * and no table records which tile that lands on. The observable proxy is the - * pieces themselves: a border or fill DS1 is authored as a chunk of scenery - * sitting *on* the outdoor ground, so the floor it uses away from its own outer - * ring is that ground. - * - * The outer ring is excluded because that is where the cliff face and its - * transition tiles live. Cells with `prop1 == 0` are excluded because they are - * unused slots, not tiles. The most frequent survivor wins, ties going to the - * first seen so the choice is deterministic. - * - * @param pieces - the level's border and fill pieces. - * @returns the tile, or null when no piece carries any floor. - */ -export function deriveGroundTile(pieces: readonly WildernessPiece[]): { style: number; sequence: number } | null { - const tiles = deriveGroundTiles(pieces) - if (tiles.length === 0) return null - return { style: tiles[0]!.style, sequence: tiles[0]!.sequence } -} - -/* ------------------------------------------------------------------------- * - * Piece geometry - * ------------------------------------------------------------------------- */ - -/** - * A piece's extent in blocks. - * - * `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` computes exactly this, with integer - * division, to decide how many grid cells a stamped preset clears. - * - * @param piece - the piece. - * @param variant - which decoded variant to measure. - * @returns the extent in blocks, at least 1×1. - */ -function pieceBlocks(piece: WildernessPiece, variant: number): { x: number; y: number } { - const level = piece.levels[variant % piece.levels.length] - if (level === undefined) return { x: 1, y: 1 } - return { - x: Math.max(Math.floor(level.width / TILES_PER_BLOCK), 1), - y: Math.max(Math.floor(level.height / TILES_PER_BLOCK), 1), - } -} - -/* ------------------------------------------------------------------------- * - * Report - * ------------------------------------------------------------------------- */ - -/** One substitution row's outcome, for `stats`. */ -export interface SubstitutionReport { - readonly name: string - readonly role: WildernessRole - readonly enabled: boolean - readonly clusters: number -} - -/** A fixed SuperUnique boss or guard NPC anchored to a wilderness landmark or border gate. */ -export interface LandmarkEntity { - readonly role: 'superunique' | 'npc_guard' - readonly id: string - readonly monsterId: string - readonly name: string - readonly landmark: string - readonly tileX: number - readonly tileY: number - readonly subtileX?: number - readonly subtileY?: number - readonly levelId?: number - readonly modifiers: readonly string[] - readonly minionCount: readonly [number, number] - readonly minionMonsterId: string - readonly objectId?: number -} - -/** Procedural terrain pass telemetry counters (Issue #62). */ -export interface WildernessTelemetry { - readonly proceduralRoadCells: number - readonly proceduralDividerCells: number - readonly proceduralWaterCells: number - readonly proceduralWallCells: number -} - -/** The report collector. */ -export interface WildernessStats { - readonly substitutions: SubstitutionReport[] - readonly borderPieces: Record - readonly unresolved: string[] - readonly notes: string[] - borderStamped: number - groundCells: number - groundTile: { style: number; sequence: number } | null - groundTiles?: readonly WeightedGroundTile[] | undefined - sizeSource: string - roadCells: number - roadSegments: number - anchors: number - specialPresets: string[] - /** Every way into or out of the level, in cells. See `WildernessEntrance`. */ - entrances: WildernessEntrance[] - waypointsSpawned?: number - waypointTile?: { readonly x: number; readonly y: number } | null - shrinesSpawned?: number - interactivesSpawned?: number - landmarkEntities?: LandmarkEntity[] - act2Topography?: Act2TopographyStats - act3Topography?: Act3TopographyStats - act4Topography?: Act4TopographyStats - act5Topography?: Act5TopographyStats - jungleLayout?: JungleLayoutStats - jungleTopology?: JungleTopology - bridgesPlaced?: number - riverIslands?: number - riverForks?: number - meanderCount?: number - siegeTopography?: SiegeTopographyStats - subTheme?: number - proceduralRoadCells?: number - proceduralDividerCells?: number - proceduralWaterCells?: number - proceduralWallCells?: number - drlgVertices?: readonly DrlgVertex[] | undefined - roadPolylines?: readonly (readonly { readonly x: number; readonly y: number }[])[] | undefined - gateOpenings?: GateOpening[] -} - -/* ------------------------------------------------------------------------- * - * Tile cache (`DRLGROOMTILE_GetTileCache`) - * ------------------------------------------------------------------------- */ - -/** Query parameters for DRLGROOMTILE_GetTileCache. */ -export interface TileCacheQuery { - /** Tile type (0 = floor, 1 = left wall, etc.). Defaults to 0 (floor). */ - readonly type?: number | undefined - /** Filter by specific style if provided. */ - readonly style?: number | undefined - /** Filter by specific sequence if provided. */ - readonly sequence?: number | undefined - /** Seed or PRNG roll in [0, 1) or integer seed for deterministic weighted selection. */ - readonly seed: number | Rng - /** Optional cell coordinates (x, y) to derive a deterministic hash if seed is provided. */ - readonly x?: number | undefined - readonly y?: number | undefined -} - -/** Result returned by DRLGROOMTILE_GetTileCache. */ -export interface TileCacheResult { - readonly style: number - readonly sequence: number - readonly type: number - readonly libraryIndex: number - readonly tileIndex: number - readonly weight: number - readonly rarityFrameIndex: number -} - -/** - * Blizzard DRLGROOMTILE_GetTileCache reimplementation. - * - * Scans active DT1 libraries according to dwDt1Mask and retrieves candidate - * tiles matching the query, performing a weighted selection proportional to tile - * rarity (`rarityFrameIndex` / `rarity` / `Rarity`). - */ -export function DRLGROOMTILE_GetTileCache( - libraries: readonly Dt1[], - dt1Mask: number, - query: TileCacheQuery, -): TileCacheResult | null { - const targetType = query.type ?? 0 - const targetStyle = query.style - const targetSequence = query.sequence - - const candidates: TileCacheResult[] = [] - let totalWeight = 0 - - for (let libIndex = 0; libIndex < libraries.length; libIndex += 1) { - if ((dt1Mask & (1 << libIndex)) === 0) continue - const lib = libraries[libIndex] - if (!lib) continue - - for (let tIndex = 0; tIndex < lib.tiles.length; tIndex += 1) { - const tile = lib.tiles[tIndex]! - if (tile.type !== targetType) continue - if (targetStyle !== undefined && tile.style !== targetStyle) continue - if (targetSequence !== undefined && tile.sequence !== targetSequence) continue - - const rawRarity = - (tile as unknown as { rarity?: number; Rarity?: number }).rarity ?? - (tile as unknown as { rarity?: number; Rarity?: number }).Rarity ?? - tile.rarityFrameIndex - const weight = Math.max(1, rawRarity | 0) - - candidates.push({ - style: tile.style, - sequence: tile.sequence, - type: tile.type, - libraryIndex: libIndex, - tileIndex: tIndex, - weight, - rarityFrameIndex: weight, - }) - totalWeight += weight - } - } - - if (candidates.length === 0) return null - - // Determine roll in [0, 1) - let roll: number - if (typeof query.seed === 'number') { - if (query.seed >= 0 && query.seed < 1 && !Number.isInteger(query.seed)) { - roll = query.seed - } else if (query.x !== undefined && query.y !== undefined) { - roll = cellHash(query.x, query.y, query.seed) - } else { - roll = cellHash(candidates.length, totalWeight, query.seed) - } - } else { - roll = query.seed.next() - } - - let target = roll * totalWeight - for (let i = 0; i < candidates.length; i += 1) { - const candidate = candidates[i]! - if (target < candidate.weight) { - return candidate - } - target -= candidate.weight - } - - return candidates[candidates.length - 1] ?? null -} - -/** - * The hardcoded passes D2MOO runs for an outdoor level that this port does not - * reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so - * the gap is visible rather than implied. - */ -export const UNIMPLEMENTED_PASSES: readonly string[] = [ - 'DRLGOUTPLACE_CreateLevelConnections', - - 'DRLGOUTDOORS_SpawnAct3Mephisto', -] - -/* ------------------------------------------------------------------------- * - * Border - * ------------------------------------------------------------------------- */ - -type BorderRole = 'tl' | 'tr' | 'br' | 'bl' | 'top' | 'right' | 'bottom' | 'left' - -function borderRoleAt(x: number, y: number, gridWidth: number, gridHeight: number): BorderRole { - if (x === 0 && y === 0) return 'tl' - if (x === gridWidth - 1 && y === 0) return 'tr' - if (x === gridWidth - 1 && y === gridHeight - 1) return 'br' - if (x === 0 && y === gridHeight - 1) return 'bl' - if (y === 0) return 'top' - if (x === gridWidth - 1) return 'right' - if (y === gridHeight - 1) return 'bottom' - return 'left' -} - -/** - * Find the directional border piece matching `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`. - * - * Corresponds to Blizzard DRLG subsystem algorithms: - * - Act 2 Desert: `DRLGOUTDESR_PlaceBorders` (D2Common.0x6FD7D9B0) placing numeric borders 1..8. - * - Act 3 Kurast: `DRLGOUTJUNG_BuildLowerKurast` (D2Common.0x6FD7FE50), - * `DRLGOUTJUNG_BuildKurastBazaar` (D2Common.0x6FD7FFA0), and - * `DRLGOUTJUNG_BuildUpperKurast` (D2Common.0x6FD800E0) placing cardinal compass borders N..SW. - * - * In Diablo II: - * - Numeric `Border 1..8` map to: 1=Bottom, 2=Left, 3=Top, 4=Right, - * 5=Bottom-Left, 6=Top-Left, 7=Top-Right, 8=Bottom-Right. - * - Compass `Border N/S/E/W/NE/NW/SE/SW` (Act 3 Kurast) map directly by cardinal direction. - */ -function findDirectionalBorderPiece(pieces: readonly WildernessPiece[], role: BorderRole): WildernessPiece | null { - const numericPatterns: Record = { - bottom: /Border\s+1\b/i, - left: /Border\s+2\b/i, - top: /Border\s+3\b/i, - right: /Border\s+4\b/i, - bl: /Border\s+5\b/i, - tl: /Border\s+6\b/i, - tr: /Border\s+7\b/i, - br: /Border\s+8\b/i, - } - const compassPatterns: Record = { - top: /Border\s+N\b/i, - bottom: /Border\s+S\b/i, - right: /Border\s+E\b/i, - left: /Border\s+W\b/i, - tr: /Border\s+NE\b/i, - tl: /Border\s+NW\b/i, - br: /Border\s+SE\b/i, - bl: /Border\s+SW\b/i, - } - const hasExtendedNumericBorders = pieces.some(p => /Border\s+(?:9|1\d)\b/i.test(p.name)) - if (!hasExtendedNumericBorders) { - const numPat = numericPatterns[role] - const matchingNum = pieces.find(p => numPat.test(p.name) && !/Cliff Border/i.test(p.name)) - ?? pieces.find(p => numPat.test(p.name)) - if (matchingNum !== undefined) return matchingNum - } - const compPat = compassPatterns[role] - const matchingComp = pieces.find(p => compPat.test(p.name)) - if (matchingComp !== undefined) return matchingComp - return null -} - -/** - * Walk the block grid's outer ring, clockwise from the top-left. - * - * This is the path `DRLGOUTPLACE_PlaceAct1245OutdoorBorders` walks: it follows - * the level's outline vertex ring and stamps one preset per grid step along each - * edge with the edge's own preset id. - * - * @param gridWidth - blocks across. - * @param gridHeight - blocks down. - * @returns the ring's block coordinates, clockwise, each exactly once. - */ -function borderRing(gridWidth: number, gridHeight: number): { x: number; y: number }[] { - const ring: { x: number; y: number }[] = [] - for (let x = 0; x < gridWidth; x += 1) ring.push({ x, y: 0 }) - for (let y = 1; y < gridHeight; y += 1) ring.push({ x: gridWidth - 1, y }) - for (let x = gridWidth - 2; x >= 0; x -= 1) ring.push({ x, y: gridHeight - 1 }) - for (let y = gridHeight - 2; y >= 1; y -= 1) ring.push({ x: 0, y }) - return ring -} - -/** Orientation of a border piece along the outer edge of a level. */ -export type BorderOrientation = - | 'north' - | 'south' - | 'east' - | 'west' - | 'corner-nw' - | 'corner-ne' - | 'corner-se' - | 'corner-sw' - -export interface ClassifiedBorderPiece { - readonly piece: WildernessPiece - readonly orientation: BorderOrientation | null - readonly isCliff: boolean -} - -/** - * Classify a border piece by its orientation and whether it is a stone cliff. - * - * Blizzard North uses a universal numbering convention across outdoor border presets: - * - 1 = South edge (horizontal straight) - * - 2 = West edge (vertical straight) - * - 3 = North edge (horizontal straight) - * - 4 = East edge (vertical straight) - * - 5 = SW corner - * - 6 = NW corner - * - 7 = NE corner - * - 8 = SE corner - * - 9..12 = corner / transition variations (9=SW, 10=NW, 11=NE, 12=SE) - * - * Act 2: - * - Desert Border 1..12 (1..4 straight edges, 5..12 corners) - * - Desert Cliff Left Wall/Path/Ends/King Tomb -> West edge - * - Desert Cliff Right Wall/Path/Ends/King Tomb -> East edge - * - Desert Cliff Top / Top King Tomb -> North edge - * - * Act 3: - * - Slums/Burbs/Metro Border N, S, E, W, NE, NW, SE, SW - * - * Act 4: - * - Mesa Border 1..12 (1..4 straight edges, 5..12 corners) - * - * Act 5: - * - Barricade Cliff Border 1..12 & Snow variants (isCliff: true) - * - Barricade Ravine Border 1..12 & Snow variants (isCliff: false) - * - Barricade Ravine-Cliff Border 5 (SW corner transition) - * - Barricade Cliff Ravine Border 7 (NE corner transition) - * - Snow Border 1..12 - * - * Cave entrances ("clfcave", "clfcave2", "Cave Right", "Cave Left") are dedicated structures, - * never generic border pieces. - */ -export function classifyBorderPiece(name: string): { orientation: BorderOrientation | null; isCliff: boolean } { - if (/cave/i.test(name)) return { orientation: null, isCliff: false } - const isCliff = /cliff|stnclf/i.test(name) - - // Act 3 style: "Border N", "Border S", "Border NE", etc. - const cardinal = name.match(/\b(?:border|edge|brdr|cliff)\s+([NESW]{1,2})\b/i) - if (cardinal) { - const dir = cardinal[1]!.toUpperCase() - if (dir === 'N') return { orientation: 'north', isCliff } - if (dir === 'S') return { orientation: 'south', isCliff } - if (dir === 'E') return { orientation: 'east', isCliff } - if (dir === 'W') return { orientation: 'west', isCliff } - if (dir === 'NW') return { orientation: 'corner-nw', isCliff } - if (dir === 'NE') return { orientation: 'corner-ne', isCliff } - if (dir === 'SE') return { orientation: 'corner-se', isCliff } - if (dir === 'SW') return { orientation: 'corner-sw', isCliff } - } - - // Act 1, 2, 4, 5 style: - // 1 = South edge - // 2 = West edge - // 3 = North edge - // 4 = East edge - // 5, 9 = SW corner - // 6, 10 = NW corner (including 6A, 6B, 6C) - // 7, 11 = NE corner - // 8, 12 = SE corner - const numMatch = name.match(/(?:border|edge|brdr_?snow|brdr|cliff|ravine)\s*0?(\d+)/i) - if (numMatch) { - const num = Number(numMatch[1]) - if (num === 1) return { orientation: 'south', isCliff } - if (num === 2) return { orientation: 'west', isCliff } - if (num === 3) return { orientation: 'north', isCliff } - if (num === 4) return { orientation: 'east', isCliff } - if (num === 5 || num === 9) return { orientation: 'corner-sw', isCliff } - if (num === 6 || num === 10) return { orientation: 'corner-nw', isCliff } - if (num === 7 || num === 11) return { orientation: 'corner-ne', isCliff } - if (num === 8 || num === 12) return { orientation: 'corner-se', isCliff } - } - - // Directional names (Act 2 Desert Cliff Left/Right/Top, entrance/exit btm/top, cardinal words) - if (/\b(?:north[\s-]?west|nw)\b/i.test(name)) return { orientation: 'corner-nw', isCliff } - if (/\b(?:north[\s-]?east|ne)\b/i.test(name)) return { orientation: 'corner-ne', isCliff } - if (/\b(?:south[\s-]?west|sw)\b/i.test(name)) return { orientation: 'corner-sw', isCliff } - if (/\b(?:south[\s-]?east|se)\b/i.test(name)) return { orientation: 'corner-se', isCliff } - if (/\b(?:north|top)\b/i.test(name)) return { orientation: 'north', isCliff } - if (/\b(?:south|bottom|btm)\b/i.test(name)) return { orientation: 'south', isCliff } - if (/\b(?:east|right|rght)\b/i.test(name)) return { orientation: 'east', isCliff } - if (/\b(?:west|left)\b/i.test(name)) return { orientation: 'west', isCliff } - - return { orientation: null, isCliff } -} - -/** - * Determine the perimeter orientation for a block cell on a `gridWidth x gridHeight` grid. - */ -export function getCellOrientation(x: number, y: number, gridWidth: number, gridHeight: number): BorderOrientation { - if (x === 0 && y === 0) return 'corner-nw' - if (x === gridWidth - 1 && y === 0) return 'corner-ne' - if (x === gridWidth - 1 && y === gridHeight - 1) return 'corner-se' - if (x === 0 && y === gridHeight - 1) return 'corner-sw' - if (y === 0) return 'north' - if (x === gridWidth - 1) return 'east' - if (y === gridHeight - 1) return 'south' - return 'west' -} - -/** - * Select a matching piece for the given orientation. - */ -export function selectBorderPiece( - orientation: BorderOrientation, - classified: readonly ClassifiedBorderPiece[], - rng: Rng, - preferCliff?: boolean, -): WildernessPiece | null { - const matches = classified.filter(c => c.orientation === orientation) - if (matches.length === 0) return null - - if (preferCliff === true) { - const cliffMatches = matches.filter(c => c.isCliff) - if (cliffMatches.length > 0) { - return cliffMatches[rng.int(0, cliffMatches.length - 1)]!.piece - } - } else if (preferCliff === false) { - const nonCliffMatches = matches.filter(c => !c.isCliff) - if (nonCliffMatches.length > 0) { - return nonCliffMatches[rng.int(0, nonCliffMatches.length - 1)]!.piece - } - } - - return matches[rng.int(0, matches.length - 1)]!.piece -} - -/** - * Check if a border DS1 variant has a continuous wall along the border orientation. - */ -export function isClosedBorder(level: Ds1, orientation: BorderOrientation): boolean { - if (orientation === 'north' || orientation === 'south') { - for (let y = 0; y < level.height; y += 1) { - let continuous = true - for (let x = 0; x < level.width; x += 1) { - if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) { - continuous = false - break - } - } - if (continuous) return true - } - return false - } - if (orientation === 'west' || orientation === 'east') { - for (let x = 0; x < level.width; x += 1) { - let continuous = true - for (let y = 0; y < level.height; y += 1) { - if (!level.cells[y]?.[x]?.walls.some(w => !w.hidden && w.prop1 !== 0)) { - continuous = false - break - } - } - if (continuous) return true - } - return false - } - return true -} - -/** - * Select a specific border variant based on whether this perimeter position is an opening (exit/entrance). - */ -export function selectBorderVariant( - piece: WildernessPiece, - orientation: BorderOrientation, - isOpen: boolean, - rng: Rng, -): Ds1 { - // In Blizzard LvlPrest.txt tables with >= 4 variants (Act 1 Bord1..4 and Act 4 Border1..4), - // slots 0..2 are closed borders (a/b/c) and slots 3+ are open exit/gate borders (o/oe). - let closedLevels: readonly Ds1[] - let openLevels: readonly Ds1[] - if (piece.levels.length >= 4) { - closedLevels = piece.levels.slice(0, 3) - openLevels = piece.levels.slice(3) - } else { - closedLevels = piece.levels.filter(l => isClosedBorder(l, orientation)) - openLevels = piece.levels.filter(l => !isClosedBorder(l, orientation)) - } - - if (isOpen && openLevels.length > 0) { - return openLevels[rng.int(0, openLevels.length - 1)]! - } - if (!isOpen && closedLevels.length > 0) { - return closedLevels[rng.int(0, closedLevels.length - 1)]! - } - return piece.levels[rng.int(0, piece.levels.length - 1)]! -} - -/** - * Determine perimeter opening (exit/entrance) coordinates for a wilderness level. - */ -export function getPerimeterOpenings( - levelId: number, - gridWidth: number, - gridHeight: number, - gates?: readonly PlannedGate[], - seamConstraints?: readonly SeamConstraint[], -): Set { - const openings = new Set() - const key = (x: number, y: number) => `${String(x)},${String(y)}` - - if (levelId === 39 || levelId === 46) { - // Moo Moo Farm (Cow Level) & Canyon of the Magi: 0 exits! - return openings - } - - const minX = gridWidth > 2 ? 1 : 0 - const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1 - const minY = gridHeight > 2 ? 1 : 0 - const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1 - - if (gates !== undefined && gates.length > 0) { - for (const gate of gates) { - let coord = gate.blockCoord - if (coord === undefined && seamConstraints !== undefined) { - const match = seamConstraints.find( - c => - c.side === gate.side && - (c.toLevelId === gate.toLevelId || - c.fromLevelId === gate.toLevelId || - c.toLevelId === levelId || - c.fromLevelId === levelId), - ) ?? seamConstraints.find(c => c.side === gate.side) - if (match !== undefined) { - coord = match.blockCoord - } - } - if (coord === undefined && gate.tileCoord !== undefined) { - coord = (gate.side === 'north' || gate.side === 'south') - ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) - : Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) - } - - switch (gate.side) { - case 'north': { - const bx = coord !== undefined - ? Math.max(minX, Math.min(maxX, coord)) - : Math.floor(gridWidth / 2) - openings.add(key(bx, 0)) - break - } - case 'south': { - const bx = coord !== undefined - ? Math.max(minX, Math.min(maxX, coord)) - : Math.floor(gridWidth / 2) - openings.add(key(bx, gridHeight - 1)) - break - } - case 'west': { - const by = coord !== undefined - ? Math.max(minY, Math.min(maxY, coord)) - : Math.floor(gridHeight / 2) - openings.add(key(0, by)) - break - } - case 'east': { - const by = coord !== undefined - ? Math.max(minY, Math.min(maxY, coord)) - : Math.floor(gridHeight / 2) - openings.add(key(gridWidth - 1, by)) - break - } - } - } - return openings - } - - if (seamConstraints !== undefined && seamConstraints.length > 0) { - for (const c of seamConstraints) { - switch (c.side) { - case 'north': { - const bx = Math.max(minX, Math.min(maxX, c.blockCoord)) - openings.add(key(bx, 0)) - break - } - case 'south': { - const bx = Math.max(minX, Math.min(maxX, c.blockCoord)) - openings.add(key(bx, gridHeight - 1)) - break - } - case 'west': { - const by = Math.max(minY, Math.min(maxY, c.blockCoord)) - openings.add(key(0, by)) - break - } - case 'east': { - const by = Math.max(minY, Math.min(maxY, c.blockCoord)) - openings.add(key(gridWidth - 1, by)) - break - } - } - } - return openings - } - - // Outdoor wilderness levels across Acts 1..5: - // Default entrance on South edge, exit on East edge so borders are never sealed shut - const inX = Math.floor(gridWidth / 2) - openings.add(key(inX, gridHeight - 1)) - const outY = Math.floor(gridHeight / 2) - openings.add(key(gridWidth - 1, outY)) - return openings -} - -/** - * Lay the level's border pieces around the block grid's outer ring. - * - * `DRLGOUTDOORS_SpawnOutdoorLevelPresetEx` is the model: straight edge pieces are - * placed according to edge orientation (North, South, East, West), followed by corner - * join pieces (NW, NE, SE, SW). In Act 1 wilderness levels, North and West boundaries - * prefer solid rock cliffs (`StnClf`), while South and East boundaries use fence borders, - * matching original Diablo II DRLG rules. - * - * Perimeter openings (exits/entrances) select open variants (e.g. `Bord1o`/`Bord4o`), - * while all other perimeter pieces use solid/closed wall variants (`isClosedBorder`). - * Supports forced gate variants (`openGates`) and claimed border blocks (`claimedBorderBlocks`). - * - * @param canvas - the canvas. - * @param pieces - the level's border pieces, already filtered to `border`. - * @param gridWidth - blocks across. - * @param gridHeight - blocks down. - * @param rng - the level's random stream, for the variant rotation. - * @param stats - report collector. - * @param levelId - optional level id for perimeter opening rules. - * @param openGates - optional map from `${x},${y}` block key to forced variant index. - * @param claimedBorderBlocks - optional set of `${x},${y}` keys to skip (e.g. Town Transitions, Facades). - * @param gates - optional planned gates from the world graph. - * @param seamConstraints - optional seam constraints for spatial alignment. - * @returns the number of pieces stamped. - */ -export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders( - canvas: Canvas, - pieces: readonly WildernessPiece[], - gridWidth: number, - gridHeight: number, - rng: Rng, - stats: WildernessStats, - levelId?: number, - openGates?: ReadonlyMap, - claimedBorderBlocks?: ReadonlySet, - gates?: readonly PlannedGate[], - seamConstraints?: readonly SeamConstraint[], -): number { - if (stats.proceduralWallCells === undefined) { - stats.proceduralWallCells = 0 - } - - if (pieces.length === 0) { - stats.unresolved.push('no border piece for this level type') - return 0 - } - - // Filter out cave entrances from generic border placement - const validPieces = pieces.filter(p => !/cave/i.test(p.name)) - const candidatePieces = validPieces.length > 0 ? validPieces : pieces - - const classified: ClassifiedBorderPiece[] = candidatePieces - .map(piece => { - const { orientation, isCliff } = classifyBorderPiece(piece.name) - return { piece, orientation, isCliff } - }) - .filter((item): item is ClassifiedBorderPiece & { orientation: BorderOrientation } => item.orientation !== null) - - const ordered = [...candidatePieces].sort((a, b) => { - const areaA = pieceBlocks(a, 0).x * pieceBlocks(a, 0).y - const areaB = pieceBlocks(b, 0).x * pieceBlocks(b, 0).y - return areaA - areaB || a.name.localeCompare(b.name) - }) - - // Stamp straight edges first in clockwise ring walk, then corners last, - // matching D2MOO DRLGOUTPLACE_PlaceAct1245OutdoorBorders where corner presets - // overwrite straight edge presets at vertices to form seamless joins. - const straightCells: { x: number; y: number }[] = [] - // North edge (moving East) - for (let x = 1; x < gridWidth - 1; x += 1) straightCells.push({ x, y: 0 }) - // East edge (moving South) - for (let y = 1; y < gridHeight - 1; y += 1) straightCells.push({ x: gridWidth - 1, y }) - // South edge (moving West) - for (let x = gridWidth - 2; x >= 1; x -= 1) straightCells.push({ x, y: gridHeight - 1 }) - // West edge (moving North) - for (let y = gridHeight - 2; y >= 1; y -= 1) straightCells.push({ x: 0, y }) - - // Corner join pieces stamped last: NW, NE, SE, SW - const cornerCells: { x: number; y: number }[] = [ - { x: 0, y: 0 }, // NW - { x: gridWidth - 1, y: 0 }, // NE - { x: gridWidth - 1, y: gridHeight - 1 }, // SE - { x: 0, y: gridHeight - 1 }, // SW - ] - - const openings = levelId !== undefined ? getPerimeterOpenings(levelId, gridWidth, gridHeight, gates, seamConstraints) : new Set() - if (openGates !== undefined) { - for (const gateKey of openGates.keys()) { - openings.add(gateKey) - } - } - - let cursor = rng.int(0, ordered.length - 1) - let stamped = 0 - - const stampCell = (cell: { x: number; y: number }, isCorner: boolean) => { - const key = `${String(cell.x)},${String(cell.y)}` - if (claimedBorderBlocks?.has(key) === true) { - return - } - - const orientation = getCellOrientation(cell.x, cell.y, gridWidth, gridHeight) - const isOpen = !isCorner && openings.has(key) - // North, West, NW, NE, SW prefer cliff pieces if available (e.g. Act 1 wilderness mountain boundaries), - // but openings must not prefer cliffs to ensure open gate variants are selected. - const preferCliff = - !isOpen && - (orientation === 'north' || - orientation === 'west' || - orientation === 'corner-nw' || - orientation === 'corner-ne' || - orientation === 'corner-sw') - - let piece = selectBorderPiece(orientation, classified, rng, preferCliff) - if (!piece) { - piece = ordered[cursor]! - cursor = (cursor + 1) % ordered.length - } - if (!piece || piece.levels.length === 0) return - - let level: Ds1 | undefined - const forcedVariant = openGates?.get(key) - if (forcedVariant !== undefined) { - level = piece.levels[Math.min(forcedVariant, piece.levels.length - 1)] - } else { - level = selectBorderVariant(piece, orientation, isOpen, rng) - } - if (level === undefined) return - - const maxOriginX = Math.max(canvas.width - level.width, 0) - const maxOriginY = Math.max(canvas.height - level.height, 0) - const originX = level.width <= TILES_PER_BLOCK + 1 - ? cell.x * TILES_PER_BLOCK - : Math.min(cell.x * TILES_PER_BLOCK, maxOriginX) - const originY = level.height <= TILES_PER_BLOCK + 1 - ? cell.y * TILES_PER_BLOCK - : Math.min(cell.y * TILES_PER_BLOCK, maxOriginY) - - stampDs1(canvas, level, originX, originY) - stats.borderPieces[piece.name] = (stats.borderPieces[piece.name] ?? 0) + 1 - stats.borderStamped += 1 - stamped += 1 - } - - for (const cell of straightCells) stampCell(cell, false) - for (const cell of cornerCells) stampCell(cell, true) - - return stamped -} - -export const layBorder = DRLGOUTPLACE_PlaceAct1245OutdoorBorders - -/* ------------------------------------------------------------------------- * - * Act 1 Outdoor Anchors, Presets & Dirt Road Generation - * (`DRLGOUTWILD_InitAct1OutdoorLevel`, `DRLGOUTDOORS_SpawnAct1DirtPaths`, - * `DRLG_OUTDOORS_GenerateDirtPath`) - * ------------------------------------------------------------------------- */ - -/** - * Where a road ends. - * - * Until now these were purely internal: the road network needs endpoints to - * draw between and nothing more. They are also, though, the exact world - * coordinates of every way into and out of the level, which is what the world - * graph needs and what was previously being computed and thrown away — only - * `stats.anchors`, a count, escaped the generator. - * - * Coordinates are in cells, with `(0, 0)` at the final map's origin. Unlike the - * maze generator there is no `minX` shift to undo. - */ -export interface RoadAnchor { - readonly label: string - /** The opening itself: the cell the player crosses. */ - readonly anchorCell: { readonly x: number; readonly y: number } - /** One step inside the level from `anchorCell`, where the road starts. */ - readonly interiorCell: { readonly x: number; readonly y: number } - /** The level on the other side, when this anchor is a way out. */ - readonly toLevelId?: number | undefined - /** Which edge of the map this anchor sits on, for border gates. */ - readonly side?: Side | undefined - /** Whether this is a border seam or the mouth of an interior preset. */ - readonly kind?: 'gate' | 'preset' | undefined -} - -/** A level entrance or exit, as reported in `stats.entrances`. */ -export interface WildernessEntrance { - /** The anchor's name, e.g. `Cold Plains Exit`. */ - readonly label: string - /** The crossing point, in cells. */ - readonly x: number - readonly y: number - /** Where the player stands after crossing, in cells. */ - readonly interiorX: number - readonly interiorY: number - /** The level on the other side, or -1 when the generator does not know. */ - readonly toLevelId: number - /** The map edge, for border seams. */ - readonly side: Side | null - /** Border seam or interior preset mouth. */ - readonly kind: 'gate' | 'preset' -} - -export interface SpecialPresetPlacement { - readonly name: string - readonly ds1: Ds1 - readonly bx: number - readonly by: number - readonly blocksX: number - readonly blocksY: number -} - -export interface Act1OutdoorPlan { - readonly openGates: Map - readonly claimedBorderBlocks: Set - readonly claimedBlocks: boolean[][] - readonly roadObstacleBlocks: boolean[][] - readonly specialPresets: SpecialPresetPlacement[] - readonly anchors: RoadAnchor[] - readonly gateOpenings?: GateOpening[] -} - -/** Map a compass side onto the generator's own edge names. */ -const SIDE_TO_EDGE: Readonly> = { - north: 'top', - south: 'bottom', - east: 'right', - west: 'left', -} - -/** - * Turn the plan's anchors into the entrance list the packer bakes. - * - * @param anchors - the plan's anchors. - * @returns one entrance per anchor. - */ -function toEntrances(anchors: readonly RoadAnchor[]): WildernessEntrance[] { - return anchors.map(anchor => ({ - label: anchor.label, - x: anchor.anchorCell.x, - y: anchor.anchorCell.y, - interiorX: anchor.interiorCell.x, - interiorY: anchor.interiorCell.y, - toLevelId: anchor.toLevelId ?? -1, - side: anchor.side ?? null, - kind: anchor.kind ?? 'preset', - })) -} - -/** - * Plan the border gates and road anchors of an Acts 2-5 outdoor level from the - * seams the world graph supplies. - * - * The name is historical: the planner was written for Act 1, whose outdoor - * levels now come from the 1:1 DRLG port (src/game/drlg). Its Act 1 preset - * placement is gone, so `specialPresets` is always empty. - */ -export function planAct1OutdoorLevel( - request: WildernessRequest, - gridWidth: number, - gridHeight: number, - rng: Rng, - stats?: WildernessStats, -): Act1OutdoorPlan { - if (request.levelId >= 1 && request.levelId < 1024 && DRLG_GetActNoFromLevelId(request.levelId) === 0) { - throw new Error(`planAct1OutdoorLevel: level ${request.levelId} is an Act I outdoor level; those come from the DRLG port (src/game/drlg)`) - } - const openGates = new Map() - const claimedBorderBlocks = new Set() - const claimedBlocks: boolean[][] = Array.from({ length: gridHeight }, () => - Array.from({ length: gridWidth }, () => false), - ) - const roadObstacleBlocks: boolean[][] = Array.from({ length: gridHeight }, () => - Array.from({ length: gridWidth }, () => false), - ) - const specialPresets: SpecialPresetPlacement[] = [] - const anchors: RoadAnchor[] = [] - - const gateOpenings: GateOpening[] = [] - - const addBorderGate = ( - edge: 'top' | 'bottom' | 'left' | 'right', - variant: number, - label: string, - toLevelId?: number, - forcedBlockCoord?: number, - forcedTileCoord?: { readonly x: number; readonly y: number }, - ): void => { - const side: Side = edge === 'top' ? 'north' : edge === 'bottom' ? 'south' : edge === 'left' ? 'west' : 'east' - - let blockCoord = forcedBlockCoord - let tileCoord = forcedTileCoord - - if (blockCoord === undefined && request.seamConstraints !== undefined) { - const match = request.seamConstraints.find(c => { - if (c.side !== side) return false - if (toLevelId !== undefined && toLevelId !== -1) { - return c.toLevelId === toLevelId || c.fromLevelId === toLevelId - } - return c.toLevelId === request.levelId || c.fromLevelId === request.levelId - }) ?? request.seamConstraints.find(c => c.side === side) - - if (match !== undefined) { - blockCoord = match.blockCoord - tileCoord = tileCoord ?? match.tileCoord - } - } - - if (blockCoord === undefined && tileCoord !== undefined) { - blockCoord = (side === 'north' || side === 'south') - ? Math.floor(tileCoord.x / TILES_PER_BLOCK) - : Math.floor(tileCoord.y / TILES_PER_BLOCK) - } - - if (edge === 'left') { - const minY = gridHeight > 2 ? 1 : 0 - const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1 - let by: number - if (blockCoord !== undefined) { - by = Math.max(minY, Math.min(maxY, blockCoord)) - if (claimedBorderBlocks.has(`0,${String(by)}`)) { - for (let tries = 0; tries < gridHeight; tries += 1) { - if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break - by = minY + ((by - minY + 1) % (maxY - minY + 1)) - } - } - } else { - by = rng.int(2, Math.max(2, gridHeight - 3)) - for (let tries = 0; tries < gridHeight; tries += 1) { - if (!claimedBorderBlocks.has(`0,${String(by)}`) && !openGates.has(`0,${String(by)}`)) break - by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) - } - } - openGates.set(`0,${String(by)}`, variant) - claimedBlocks[by]![0] = true - const anchorCell = tileCoord ?? { x: 3, y: 8 * by + 3 } - const interiorCell = tileCoord ? { x: tileCoord.x + 8, y: tileCoord.y } : { x: 11, y: 8 * by + 3 } - anchors.push({ - label, - anchorCell, - interiorCell, - toLevelId, - side: 'west', - kind: 'gate', - }) - gateOpenings.push({ - side: 'west', - toLevelId: toLevelId ?? -1, - blockCoord: by, - tileCoord: anchorCell, - }) - } else if (edge === 'right') { - const minY = gridHeight > 2 ? 1 : 0 - const maxY = gridHeight > 2 ? gridHeight - 2 : gridHeight - 1 - let by: number - if (blockCoord !== undefined) { - by = Math.max(minY, Math.min(maxY, blockCoord)) - if (claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`)) { - for (let tries = 0; tries < gridHeight; tries += 1) { - if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break - by = minY + ((by - minY + 1) % (maxY - minY + 1)) - } - } - } else { - by = rng.int(2, Math.max(2, gridHeight - 3)) - for (let tries = 0; tries < gridHeight; tries += 1) { - if (!claimedBorderBlocks.has(`${String(gridWidth - 1)},${String(by)}`) && !openGates.has(`${String(gridWidth - 1)},${String(by)}`)) break - by = 2 + ((by - 1) % Math.max(1, gridHeight - 4)) - } - } - openGates.set(`${String(gridWidth - 1)},${String(by)}`, variant) - claimedBlocks[by]![gridWidth - 1] = true - const anchorCell = tileCoord ?? { x: 8 * (gridWidth - 1) + 3, y: 8 * by + 3 } - const interiorCell = tileCoord ? { x: tileCoord.x - 8, y: tileCoord.y } : { x: 8 * (gridWidth - 2) + 3, y: 8 * by + 3 } - anchors.push({ - label, - anchorCell, - interiorCell, - toLevelId, - side: 'east', - kind: 'gate', - }) - gateOpenings.push({ - side: 'east', - toLevelId: toLevelId ?? -1, - blockCoord: by, - tileCoord: anchorCell, - }) - } else if (edge === 'top') { - const minX = gridWidth > 2 ? 1 : 0 - const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1 - let bx: number - if (blockCoord !== undefined) { - bx = Math.max(minX, Math.min(maxX, blockCoord)) - if (claimedBorderBlocks.has(`${String(bx)},0`)) { - for (let tries = 0; tries < gridWidth; tries += 1) { - if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break - bx = minX + ((bx - minX + 1) % (maxX - minX + 1)) - } - } - } else { - bx = rng.int(2, Math.max(2, gridWidth - 3)) - for (let tries = 0; tries < gridWidth; tries += 1) { - if (!claimedBorderBlocks.has(`${String(bx)},0`) && !openGates.has(`${String(bx)},0`)) break - bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) - } - } - openGates.set(`${String(bx)},0`, variant) - claimedBlocks[0]![bx] = true - const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 3 } - const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y + 8 } : { x: 8 * bx + 3, y: 11 } - anchors.push({ - label, - anchorCell, - interiorCell, - toLevelId, - side: 'north', - kind: 'gate', - }) - gateOpenings.push({ - side: 'north', - toLevelId: toLevelId ?? -1, - blockCoord: bx, - tileCoord: anchorCell, - }) - } else { - const minX = gridWidth > 2 ? 1 : 0 - const maxX = gridWidth > 2 ? gridWidth - 2 : gridWidth - 1 - let bx: number - if (blockCoord !== undefined) { - bx = Math.max(minX, Math.min(maxX, blockCoord)) - if (claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`)) { - for (let tries = 0; tries < gridWidth; tries += 1) { - if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break - bx = minX + ((bx - minX + 1) % (maxX - minX + 1)) - } - } - } else { - bx = rng.int(2, Math.max(2, gridWidth - 3)) - for (let tries = 0; tries < gridWidth; tries += 1) { - if (!claimedBorderBlocks.has(`${String(bx)},${String(gridHeight - 1)}`) && !openGates.has(`${String(bx)},${String(gridHeight - 1)}`)) break - bx = 2 + ((bx - 1) % Math.max(1, gridWidth - 4)) - } - } - openGates.set(`${String(bx)},${String(gridHeight - 1)}`, variant) - claimedBlocks[gridHeight - 1]![bx] = true - const anchorCell = tileCoord ?? { x: 8 * bx + 3, y: 8 * (gridHeight - 1) + 3 } - const interiorCell = tileCoord ? { x: tileCoord.x, y: tileCoord.y - 8 } : { x: 8 * bx + 3, y: 8 * (gridHeight - 2) + 3 } - anchors.push({ - label, - anchorCell, - interiorCell, - toLevelId, - side: 'south', - kind: 'gate', - }) - gateOpenings.push({ - side: 'south', - toLevelId: toLevelId ?? -1, - blockCoord: bx, - tileCoord: anchorCell, - }) - } - } - - const id = request.levelId - const plannedGates = request.gates - - if (plannedGates !== undefined) { - // Seams come from the act layout solve. - for (const gate of plannedGates) { - let blockCoord = gate.blockCoord - let tileCoord = gate.tileCoord - if (blockCoord === undefined && request.seamConstraints !== undefined) { - const match = request.seamConstraints.find(c => { - if (c.side !== gate.side) return false - return c.toLevelId === gate.toLevelId || c.fromLevelId === gate.toLevelId - }) ?? request.seamConstraints.find(c => c.side === gate.side) - if (match !== undefined) { - blockCoord = match.blockCoord - tileCoord = tileCoord ?? match.tileCoord - } - } - addBorderGate(SIDE_TO_EDGE[gate.side], gate.variant ?? 3, gate.label, gate.toLevelId, blockCoord, tileCoord) - } - } else if (id === 46) { - // Canyon of the Magi (46): closed border, no border gates - } else { - addBorderGate('left', 3, 'Entry Gate') - addBorderGate('right', 3, 'Exit Gate') - } - - if (stats !== undefined) { - stats.gateOpenings = gateOpenings - } - - return { - openGates, - claimedBorderBlocks, - claimedBlocks, - roadObstacleBlocks, - specialPresets, - anchors, - gateOpenings, - } -} - -export interface DrlgVertex { - x: number - y: number - type: 'gate' | 'preset' | 'hub' | 'waypoint' | 'inflection' | 'corner' - label?: string | undefined - id?: number | undefined - direction?: number | undefined - dwFlags?: number | undefined - next?: DrlgVertex | null | undefined - prev?: DrlgVertex | null | undefined -} - -export interface DrlgRoadNetwork { - readonly vertices: DrlgVertex[] - readonly polylines: { x: number; y: number }[][] -} - -export type DrlgVertexResult = DrlgVertex[] & { - readonly vertices: DrlgVertex[] - readonly polylines: { x: number; y: number }[][] - readonly lines: { x: number; y: number }[][] - readonly cyclicVertices?: DrlgVertex[] | undefined - readonly head?: DrlgVertex | undefined -} - -/** - * Catmull-Rom spline interpolation between 4 control points at parameter t [0, 1]. - */ -export function catmullRomPoint( - p0: { readonly x: number; readonly y: number }, - p1: { readonly x: number; readonly y: number }, - p2: { readonly x: number; readonly y: number }, - p3: { readonly x: number; readonly y: number }, - t: number, -): { x: number; y: number } { - const t2 = t * t - const t3 = t2 * t - const x = 0.5 * ( - (2 * p1.x) + - (-p0.x + p2.x) * t + - (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + - (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3 - ) - const y = 0.5 * ( - (2 * p1.y) + - (-p0.y + p2.y) * t + - (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + - (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3 - ) - return { x, y } -} - -/** - * Sample a Catmull-Rom spline through a sequence of control points, - * steering clear of obstacle blocks. - */ -export function sampleCatmullRomSpline( - controlPoints: readonly { readonly x: number; readonly y: number }[], - bounds: { readonly width: number; readonly height: number }, - roadObstacleBlocks?: boolean[][], -): { x: number; y: number }[] { - if (controlPoints.length === 0) return [] - if (controlPoints.length === 1) return [{ x: controlPoints[0]!.x, y: controlPoints[0]!.y }] - - const result: { x: number; y: number }[] = [] - const n = controlPoints.length - - const isObstacle = (cx: number, cy: number): boolean => { - if (!roadObstacleBlocks) return false - const bx = Math.floor(cx / TILES_PER_BLOCK) - const by = Math.floor(cy / TILES_PER_BLOCK) - return roadObstacleBlocks[by]?.[bx] === true - } - - const addPoint = (rawX: number, rawY: number) => { - let px = Math.max(0, Math.min(bounds.width - 1, Math.round(rawX))) - let py = Math.max(0, Math.min(bounds.height - 1, Math.round(rawY))) - - if (isObstacle(px, py)) { - const obx = Math.floor(px / TILES_PER_BLOCK) - const oby = Math.floor(py / TILES_PER_BLOCK) - const centerTileX = obx * TILES_PER_BLOCK + 4 - const centerTileY = oby * TILES_PER_BLOCK + 4 - const dirX = px - centerTileX - const dirY = py - centerTileY - - const stepX = dirX >= 0 ? 1 : -1 - const stepY = dirY >= 0 ? 1 : -1 - if (Math.abs(dirX) >= Math.abs(dirY)) { - px = stepX > 0 ? (obx + 1) * TILES_PER_BLOCK : obx * TILES_PER_BLOCK - 1 - } else { - py = stepY > 0 ? (oby + 1) * TILES_PER_BLOCK : oby * TILES_PER_BLOCK - 1 - } - px = Math.max(0, Math.min(bounds.width - 1, px)) - py = Math.max(0, Math.min(bounds.height - 1, py)) - } - - const last = result[result.length - 1] - if (!last || last.x !== px || last.y !== py) { - result.push({ x: px, y: py }) - } - } - - for (let i = 0; i < n - 1; i += 1) { - const p0 = i > 0 ? controlPoints[i - 1]! : { - x: 2 * controlPoints[0]!.x - controlPoints[1]!.x, - y: 2 * controlPoints[0]!.y - controlPoints[1]!.y, - } - const p1 = controlPoints[i]! - const p2 = controlPoints[i + 1]! - const p3 = i + 2 < n ? controlPoints[i + 2]! : { - x: 2 * controlPoints[n - 1]!.x - controlPoints[n - 2]!.x, - y: 2 * controlPoints[n - 1]!.y - controlPoints[n - 2]!.y, - } - - const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) - const steps = Math.max(3, Math.ceil(dist / 1.0)) - for (let s = 0; s <= steps; s += 1) { - const t = s / steps - const pt = catmullRomPoint(p0, p1, p2, p3, t) - addPoint(pt.x, pt.y) - } - } - - return result -} - -/** - * Find clear block path connecting start to end block avoiding obstacle blocks. - */ -function findClearBlockCorridor( - startBx: number, - startBy: number, - endBx: number, - endBy: number, - gridWidth: number, - gridHeight: number, - roadObstacleBlocks: boolean[][], -): { bx: number; by: number }[] { - if (startBx === endBx && startBy === endBy) { - return [{ bx: startBx, by: startBy }] - } - - interface Node { - bx: number - by: number - g: number - h: number - f: number - parent: Node | null - } - - const openSet: Node[] = [] - const closedSet = new Set() - - const keyOf = (bx: number, by: number) => `${String(bx)},${String(by)}` - const h = (bx: number, by: number) => Math.hypot(endBx - bx, endBy - by) - - const startNode: Node = { - bx: startBx, - by: startBy, - g: 0, - h: h(startBx, startBy), - f: h(startBx, startBy), - parent: null, - } - openSet.push(startNode) - - const DIRS = [ - { dx: 1, dy: 0 }, - { dx: -1, dy: 0 }, - { dx: 0, dy: 1 }, - { dx: 0, dy: -1 }, - { dx: 1, dy: 1 }, - { dx: -1, dy: 1 }, - { dx: 1, dy: -1 }, - { dx: -1, dy: -1 }, - ] - - let bestNode = startNode - let bestDist = h(startBx, startBy) - - while (openSet.length > 0) { - let bestIdx = 0 - for (let i = 1; i < openSet.length; i += 1) { - if (openSet[i]!.f < openSet[bestIdx]!.f) { - bestIdx = i - } - } - const current = openSet.splice(bestIdx, 1)[0]! - - if (current.bx === endBx && current.by === endBy) { - const path: { bx: number; by: number }[] = [] - let curr: Node | null = current - while (curr !== null) { - path.unshift({ bx: curr.bx, by: curr.by }) - curr = curr.parent - } - return path - } - - closedSet.add(keyOf(current.bx, current.by)) - - if (current.h < bestDist) { - bestDist = current.h - bestNode = current - } - - for (const dir of DIRS) { - const nbx = current.bx + dir.dx - const nby = current.by + dir.dy - if (nbx < 1 || nbx >= gridWidth - 1 || nby < 1 || nby >= gridHeight - 1) continue - if (closedSet.has(keyOf(nbx, nby))) continue - - if ( - !(nbx === endBx && nby === endBy) && - roadObstacleBlocks[nby]?.[nbx] === true - ) { - continue - } - - if (dir.dx !== 0 && dir.dy !== 0) { - if ( - roadObstacleBlocks[current.by]?.[current.bx + dir.dx] === true && - roadObstacleBlocks[current.by + dir.dy]?.[current.bx] === true - ) { - continue - } - } - - const moveCost = dir.dx !== 0 && dir.dy !== 0 ? 1.414 : 1.0 - const g = current.g + moveCost - const nodeH = h(nbx, nby) - const existing = openSet.find(n => n.bx === nbx && n.by === nby) - if (existing) { - if (g < existing.g) { - existing.g = g - existing.f = g + existing.h - existing.parent = current - } - } else { - openSet.push({ - bx: nbx, - by: nby, - g, - h: nodeH, - f: g + nodeH, - parent: current, - }) - } - } - } - - const path: { bx: number; by: number }[] = [] - let curr: Node | null = bestNode - while (curr !== null) { - path.unshift({ bx: curr.bx, by: curr.by }) - curr = curr.parent - } - return path -} - -/** Direction constants in D2MOO D2AltDirections order. */ -export const ALTDIR_WEST = 0 -export const ALTDIR_NORTH = 1 -export const ALTDIR_EAST = 2 -export const ALTDIR_SOUTH = 3 - -/** - * Bresenham line algorithm connecting two points on integer grid coordinates (0 RNG rolls). - */ -export function bresenhamLine( - x0: number, - y0: number, - x1: number, - y1: number, -): { x: number; y: number }[] { - const points: { x: number; y: number }[] = [] - let x = Math.round(x0) - let y = Math.round(y0) - const targetX = Math.round(x1) - const targetY = Math.round(y1) - - const dx = Math.abs(targetX - x) - const dy = Math.abs(targetY - y) - const sx = x < targetX ? 1 : -1 - const sy = y < targetY ? 1 : -1 - let err = dx - dy - - while (true) { - points.push({ x, y }) - if (x === targetX && y === targetY) break - const e2 = 2 * err - if (e2 > -dy) { - err -= dy - x += sx - } - if (e2 < dx) { - err += dx - y += sy - } - } - - return points -} - -/** - * Check whether an outdoor grid cell is valid for spawning / hub placement - * (`DRLGOUTDOORS_TestGridCellSpawnValid`, mask 0x1B81). - */ -export function DRLGOUTDOORS_TestGridCellSpawnValid( - x: number, - y: number, - gridOrPlan?: { readonly roadObstacleBlocks?: boolean[][]; readonly gridFlags?: number[][] } | number[][], -): boolean { - if (!gridOrPlan) return true - if (Array.isArray(gridOrPlan)) { - const val = gridOrPlan[y]?.[x] ?? 0 - return (val & 0x1B81) === 0 - } - if (gridOrPlan.gridFlags) { - const val = gridOrPlan.gridFlags[y]?.[x] ?? 0 - if ((val & 0x1B81) !== 0) return false - } - if (gridOrPlan.roadObstacleBlocks) { - if (gridOrPlan.roadObstacleBlocks[y]?.[x] === true) return false - } - return true -} - -/** - * Snap vertex to 8-cell grid based on border direction (`DRLGOUTDOORS_CalculatePathCoordinates`, - * D2Common.0x6FD7F500). - * - * Supports both TypeScript `(vertex1, origin)` and D2MOO `(level, vertex1, vertex2)` call styles. - */ -export function DRLGOUTDOORS_CalculatePathCoordinates( - arg1: any, - arg2?: any, - arg3?: any, -): { x: number; y: number } { - let origin = { x: 0, y: 0 } - let v1: any - let v2: any = null - - if (arg3 !== undefined || (arg2 !== undefined && typeof arg2 === 'object' && ('direction' in arg2 || 'nDirection' in arg2))) { - const origX = arg1?.nPosX ?? arg1?.x ?? 0 - const origY = arg1?.nPosY ?? arg1?.y ?? 0 - origin = { x: origX, y: origY } - v1 = arg2 - v2 = arg3 - } else { - v1 = arg1 - if (arg2 && typeof arg2 === 'object') { - origin = { x: arg2.x ?? arg2.nPosX ?? 0, y: arg2.y ?? arg2.nPosY ?? 0 } - } - } - - const v1X = v1.nPosX ?? v1.x ?? 0 - const v1Y = v1.nPosY ?? v1.y ?? 0 - const dir = v1.nDirection ?? v1.direction ?? -1 - - let relX = v1X - origin.x - let relY = v1Y - origin.y - - switch (dir) { - case ALTDIR_WEST: - relX = 8 * Math.trunc(relX / 8) + 11 - break - case ALTDIR_NORTH: - relY = 8 * Math.trunc(relY / 8) + 11 - break - case ALTDIR_EAST: - relX = 8 * Math.trunc(relX / 8) - 5 - break - case ALTDIR_SOUTH: - relY = 8 * Math.trunc(relY / 8) - 5 - break - default: - break - } - - const result = { - x: relX + origin.x, - y: relY + origin.y, - } - - if (v2 && typeof v2 === 'object') { - v2.x = result.x - v2.y = result.y - v2.nPosX = result.x - v2.nPosY = result.y - } - - return result -} - -/** - * Calculate central crossroads hub coordinate (`sub_6FD7F5B0`, D2Common.0x6FD7F5B0). - * - * If bridge (dwFlags & 0x10): anchor to bridge coordinates. - * Else if nVertices === 1: hub = (Math.floor(nGridWidth / 2), Math.floor(nGridHeight / 2)). - * Else: - * avgX = Math.floor(sumX / (8 * nVertices)) - * avgY = Math.floor(sumY / (8 * nVertices)) - * Spiral outward with radius 0..7 and 4 directions: [-1, 0], [0, 1], [0, -1], [1, 0]. - * Pick first cell passing TestGridCellSpawnValid (cell & 0x1B81 === 0). - * Consumes 0 RNG rolls! - */ -export function DRLGOUTDOORS_CalculateHubCoordinates( - plan: Act1OutdoorPlan, - gridWidth: number, - gridHeight: number, - origin: { x: number; y: number } = { x: 0, y: 0 }, -): { x: number; y: number; bx: number; by: number } { - const dwFlags = (plan as any).dwFlags ?? 0 - const hasBridge = (dwFlags & 0x10) !== 0 || (plan as any).hasBridge === true || - plan.specialPresets.some(p => /bridge/i.test(p.name)) - const bridgePreset = plan.specialPresets.find(p => /bridge/i.test(p.name)) - const bridgeCoords = (plan as any).bridgeCoords ?? (bridgePreset ? { x: bridgePreset.bx, y: bridgePreset.by } : undefined) - - if (hasBridge && bridgeCoords && bridgeCoords.x !== -1) { - const bx = bridgeCoords.x - const by = bridgeCoords.y - return { - x: 8 * bx + 3 + origin.x, - y: 8 * by + 3 + origin.y, - bx, - by, - } - } - - const nVertices = plan.anchors.length - if (nVertices === 1) { - const bx = Math.floor(gridWidth / 2) - const by = Math.floor(gridHeight / 2) - return { - x: bx * TILES_PER_BLOCK + 4 + origin.x, - y: by * TILES_PER_BLOCK + 4 + origin.y, - bx, - by, - } - } - - let sumX = 0 - let sumY = 0 - for (const anchor of plan.anchors) { - sumX += anchor.anchorCell.x - origin.x - sumY += anchor.anchorCell.y - origin.y - } - - const count = Math.max(1, nVertices) - let avgX = Math.floor(sumX / (8 * count)) - let avgY = Math.floor(sumY / (8 * count)) - avgX = Math.max(0, Math.min(gridWidth - 1, avgX)) - avgY = Math.max(0, Math.min(gridHeight - 1, avgY)) - - const DIR_OFFSETS = [ - { x: -1, y: 0 }, - { x: 0, y: 1 }, - { x: 0, y: -1 }, - { x: 1, y: 0 }, - ] - - let chosenBx = avgX - let chosenBy = avgY - let found = false - - for (let r = 0; r < 8 && !found; r += 1) { - for (const dir of DIR_OFFSETS) { - const testX = avgX + r * dir.x - const testY = avgY + r * dir.y - if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) { - if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) { - chosenBx = testX - chosenBy = testY - found = true - break - } - } - } - } - - if (!found) { - for (let r = 8; r < Math.max(gridWidth, gridHeight) && !found; r += 1) { - for (const dir of DIR_OFFSETS) { - const testX = avgX + r * dir.x - const testY = avgY + r * dir.y - if (testX >= 0 && testX < gridWidth && testY >= 0 && testY < gridHeight) { - if (DRLGOUTDOORS_TestGridCellSpawnValid(testX, testY, plan)) { - chosenBx = testX - chosenBy = testY - found = true - break - } - } - } - } - } - - return { - x: chosenBx * TILES_PER_BLOCK + 4 + origin.x, - y: chosenBy * TILES_PER_BLOCK + 4 + origin.y, - bx: chosenBx, - by: chosenBy, - } -} - -/** - * Constructs level bounding box closed cyclic linked list and deterministic grid-level paths - * (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_CalculatePathCoordinates`, D2MOO DrlgDrlgVer.cpp:15-226). - * - * 1. Constructs level bounding box closed cyclic linked list (4 corner vertices: (0,0), (w,0), (w,h), (0,h)). - * 2. For each orthogonal connection, inserts 1-2 vertices into that edge at connection span endpoints (v15/v16). - * 3. Flags: inserted vertex `dwFlags |= 1` (connection); if preset room `dwFlags |= 2`. - * 4. Coordinates: subtract level origin to convert to relative coordinates. - * 5. Consumes 0 RNG rolls! - */ -export function createDrlgVertices( - plan: Act1OutdoorPlan, - gridWidth: number, - gridHeight: number, - rng?: Rng, - origin: { x: number; y: number } = { x: 0, y: 0 }, -): DrlgVertexResult { - const tileW = gridWidth <= 32 ? gridWidth * TILES_PER_BLOCK : gridWidth - const tileH = gridHeight <= 32 ? gridHeight * TILES_PER_BLOCK : gridHeight - const gridW = gridWidth <= 32 ? gridWidth : Math.floor(gridWidth / TILES_PER_BLOCK) - const gridH = gridHeight <= 32 ? gridHeight : Math.floor(gridHeight / TILES_PER_BLOCK) - - // 1. Level bounding box closed cyclic linked list 4 corner vertices in clockwise order: - // V0: (0, 0) - // V1: (width, 0) - // V2: (width, height) - // V3: (0, height) - const v0: DrlgVertex = { - x: 0, - y: 0, - direction: ALTDIR_NORTH, - dwFlags: 0, - type: 'corner', - label: 'Corner V0 (NW)', - } - const v1: DrlgVertex = { - x: tileW, - y: 0, - direction: ALTDIR_EAST, - dwFlags: 0, - type: 'corner', - label: 'Corner V1 (NE)', - } - const v2: DrlgVertex = { - x: tileW, - y: tileH, - direction: ALTDIR_SOUTH, - dwFlags: 0, - type: 'corner', - label: 'Corner V2 (SE)', - } - const v3: DrlgVertex = { - x: 0, - y: tileH, - direction: ALTDIR_WEST, - dwFlags: 0, - type: 'corner', - label: 'Corner V3 (SW)', - } - - // 2. For each exterior connection / gate / orth connection on each of the 4 borders: - // Compute connection entry/exit span on that edge. - // Insert 1-2 vertices into that edge at connection span endpoints (v15/v16). - // dwFlags |= 1 (connection); if preset room dwFlags |= 2. - // Coordinates: subtract level origin to convert to relative coordinates. - const northVertices: DrlgVertex[] = [] - const eastVertices: DrlgVertex[] = [] - const southVertices: DrlgVertex[] = [] - const westVertices: DrlgVertex[] = [] - - const borderConnections = (plan as any).connections ?? plan.anchors ?? [] - for (const conn of borderConnections) { - const rawX = (conn.anchorCell ? conn.anchorCell.x : (conn.x ?? 0)) - origin.x - const rawY = (conn.anchorCell ? conn.anchorCell.y : (conn.y ?? 0)) - origin.y - const side = conn.side ?? ( - rawY <= 0 ? 'north' : - rawX >= tileW - 1 ? 'east' : - rawY >= tileH - 1 ? 'south' : - rawX <= 0 ? 'west' : undefined - ) - if (!side) continue - - const isPreset = conn.kind === 'preset' || (conn as any).isPreset === true || (conn.label && /preset/i.test(conn.label)) - const dwFlags = 1 | (isPreset ? 2 : 0) - const vType = isPreset ? 'preset' : 'gate' - const label = conn.label ?? (isPreset ? 'Preset Connection' : 'Gate Connection') - const id = conn.toLevelId ?? conn.id - - const span = (conn as any).span ?? ( - ((conn as any).v15 !== undefined && (conn as any).v16 !== undefined) - ? [(conn as any).v15, (conn as any).v16] - : undefined - ) - - if (side === 'north') { - if (span && span[0] !== span[1]) { - northVertices.push({ - x: Math.max(0, Math.min(tileW, span[0] - origin.x)), - y: 0, - direction: ALTDIR_NORTH, - dwFlags, - type: vType, - label: `${label} (Start)`, - id, - }) - northVertices.push({ - x: Math.max(0, Math.min(tileW, span[1] - origin.x)), - y: 0, - direction: ALTDIR_NORTH, - dwFlags, - type: vType, - label: `${label} (End)`, - id, - }) - } else { - const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX)) - northVertices.push({ - x, - y: 0, - direction: ALTDIR_NORTH, - dwFlags, - type: vType, - label, - id, - }) - } - } else if (side === 'east') { - if (span && span[0] !== span[1]) { - eastVertices.push({ - x: tileW, - y: Math.max(0, Math.min(tileH, span[0] - origin.y)), - direction: ALTDIR_EAST, - dwFlags, - type: vType, - label: `${label} (Start)`, - id, - }) - eastVertices.push({ - x: tileW, - y: Math.max(0, Math.min(tileH, span[1] - origin.y)), - direction: ALTDIR_EAST, - dwFlags, - type: vType, - label: `${label} (End)`, - id, - }) - } else { - const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY)) - eastVertices.push({ - x: tileW, - y, - direction: ALTDIR_EAST, - dwFlags, - type: vType, - label, - id, - }) - } - } else if (side === 'south') { - if (span && span[0] !== span[1]) { - southVertices.push({ - x: Math.max(0, Math.min(tileW, span[0] - origin.x)), - y: tileH, - direction: ALTDIR_SOUTH, - dwFlags, - type: vType, - label: `${label} (Start)`, - id, - }) - southVertices.push({ - x: Math.max(0, Math.min(tileW, span[1] - origin.x)), - y: tileH, - direction: ALTDIR_SOUTH, - dwFlags, - type: vType, - label: `${label} (End)`, - id, - }) - } else { - const x = Math.max(0, Math.min(tileW, span ? span[0] - origin.x : rawX)) - southVertices.push({ - x, - y: tileH, - direction: ALTDIR_SOUTH, - dwFlags, - type: vType, - label, - id, - }) - } - } else if (side === 'west') { - if (span && span[0] !== span[1]) { - westVertices.push({ - x: 0, - y: Math.max(0, Math.min(tileH, span[0] - origin.y)), - direction: ALTDIR_WEST, - dwFlags, - type: vType, - label: `${label} (Start)`, - id, - }) - westVertices.push({ - x: 0, - y: Math.max(0, Math.min(tileH, span[1] - origin.y)), - direction: ALTDIR_WEST, - dwFlags, - type: vType, - label: `${label} (End)`, - id, - }) - } else { - const y = Math.max(0, Math.min(tileH, span ? span[0] - origin.y : rawY)) - westVertices.push({ - x: 0, - y, - direction: ALTDIR_WEST, - dwFlags, - type: vType, - label, - id, - }) - } - } - } - - // Sort edges in clockwise order: - // North edge (V0 -> V1): X ascending - northVertices.sort((a, b) => a.x - b.x) - // East edge (V1 -> V2): Y ascending - eastVertices.sort((a, b) => a.y - b.y) - // South edge (V2 -> V3): X descending - southVertices.sort((a, b) => b.x - a.x) - // West edge (V3 -> V0): Y descending - westVertices.sort((a, b) => b.y - a.y) - - // Construct cyclic doubly linked list in clockwise order: - const cyclicVertices: DrlgVertex[] = [ - v0, - ...northVertices, - v1, - ...eastVertices, - v2, - ...southVertices, - v3, - ...westVertices, - ] - - for (let i = 0; i < cyclicVertices.length; i += 1) { - const curr = cyclicVertices[i]! - const next = cyclicVertices[(i + 1) % cyclicVertices.length]! - const prev = cyclicVertices[(i - 1 + cyclicVertices.length) % cyclicVertices.length]! - curr.next = next - curr.prev = prev - } - - // 3. Central Hub calculation (0 RNG rolls) - const hubInfo = DRLGOUTDOORS_CalculateHubCoordinates(plan, gridW, gridH, origin) - const hubCell = { x: hubInfo.x, y: hubInfo.y } - const hubVertex: DrlgVertex = { - x: hubCell.x, - y: hubCell.y, - type: 'hub', - label: 'Central Hub', - direction: 4, - dwFlags: 0, - } - - // 4. Collect all vertices: cyclic boundary vertices + hub + interior presets - const allVertices: DrlgVertex[] = [...cyclicVertices, hubVertex] - - // Add interior presets (if not already on the border) - for (const preset of plan.specialPresets) { - const px = preset.bx * TILES_PER_BLOCK + Math.floor((preset.blocksX * TILES_PER_BLOCK) / 2) + origin.x - const py = preset.by * TILES_PER_BLOCK + Math.floor((preset.blocksY * TILES_PER_BLOCK) / 2) + origin.y - const alreadyPresent = allVertices.some(v => v.label === preset.name || Math.hypot(v.x - px, v.y - py) < 4) - if (!alreadyPresent) { - allVertices.push({ - x: px, - y: py, - type: /waypoint/i.test(preset.name) ? 'waypoint' : 'preset', - label: preset.name, - }) - } - } - - for (const anchor of plan.anchors) { - if (anchor.kind === 'preset') { - const px = anchor.interiorCell.x - origin.x - const py = anchor.interiorCell.y - origin.y - const alreadyPresent = allVertices.some(v => Math.hypot(v.x - px, v.y - py) < 4) - if (!alreadyPresent) { - allVertices.push({ - x: px, - y: py, - type: /waypoint/i.test(anchor.label) ? 'waypoint' : 'preset', - label: anchor.label, - id: anchor.toLevelId, - }) - } - } - } - - // 5. Grid Path Connection (replacing sampleCatmullRomSpline, 0 RNG): - const polylines: { x: number; y: number }[][] = [] - - for (const anchor of plan.anchors) { - // Snap border vertex to 8-cell grid: - const dir = anchor.side === 'west' ? ALTDIR_WEST : - anchor.side === 'north' ? ALTDIR_NORTH : - anchor.side === 'east' ? ALTDIR_EAST : - anchor.side === 'south' ? ALTDIR_SOUTH : undefined - - let snappedStart = anchor.interiorCell - if (dir !== undefined) { - snappedStart = DRLGOUTDOORS_CalculatePathCoordinates({ - x: anchor.anchorCell.x, - y: anchor.anchorCell.y, - direction: dir, - }, origin) - } - - const startBx = Math.floor(snappedStart.x / TILES_PER_BLOCK) - const startBy = Math.floor(snappedStart.y / TILES_PER_BLOCK) - - // Deterministic A* block corridor avoiding obstacles - const blockPath = findClearBlockCorridor( - startBx, - startBy, - hubInfo.bx, - hubInfo.by, - gridW, - gridH, - plan.roadObstacleBlocks, - ) - - const waypoints: { x: number; y: number }[] = [ - anchor.anchorCell, - snappedStart, - ] - - if (blockPath.length > 2) { - for (let i = 1; i < blockPath.length - 1; i += 1) { - const b = blockPath[i]! - waypoints.push({ - x: b.bx * TILES_PER_BLOCK + 4 + origin.x, - y: b.by * TILES_PER_BLOCK + 4 + origin.y, - }) - } - } - waypoints.push(hubCell) - - // Rasterize waypoints with Bresenham lines into a continuous grid-level line (0 RNG) - const pathLine: { x: number; y: number }[] = [] - for (let i = 0; i < waypoints.length - 1; i += 1) { - const p0 = waypoints[i]! - const p1 = waypoints[i + 1]! - const seg = bresenhamLine(p0.x, p0.y, p1.x, p1.y) - for (const pt of seg) { - const clampedX = Math.max(0, Math.min(tileW - 1, pt.x)) - const clampedY = Math.max(0, Math.min(tileH - 1, pt.y)) - const last = pathLine[pathLine.length - 1] - if (!last || last.x !== clampedX || last.y !== clampedY) { - pathLine.push({ x: clampedX, y: clampedY }) - } - } - } - - polylines.push(pathLine) - } - - return Object.assign([...allVertices], { - vertices: allVertices, - polylines, - lines: polylines, - cyclicVertices, - head: v0, - }) as DrlgVertexResult -} - -export const DRLGVER_CreateVertices = createDrlgVertices - -/** - * Build deterministic road polylines connecting all anchors to a central hub - * (`DRLGVER_CreateVertices` / `DRLGOUTDOORS_SpawnAct1DirtPaths`). - */ -export function buildOutdoorRoadPolylines( - plan: Act1OutdoorPlan, - gridWidth: number, - gridHeight: number, - rng?: Rng, -): { x: number; y: number }[][] { - return createDrlgVertices(plan, gridWidth, gridHeight, rng).polylines -} - -export const buildRoadNetworkPolylines = buildOutdoorRoadPolylines - -/* ------------------------------------------------------------------------- * - * Substitutions - * ------------------------------------------------------------------------- */ - -/** - * Check whether placing a substitution cluster at `(bx, by)` of size - * `(blocksX, blocksY)` would collide with claimed special presets or dirt roads. - */ -function canPlaceSubstitutionCluster( - bx: number, - by: number, - blocksX: number, - blocksY: number, - claimedBlocks: boolean[][] | undefined, - dirtPathGrid: Uint8Array | undefined, - canvasWidth: number, -): boolean { - if (claimedBlocks !== undefined) { - for (let dy = 0; dy < blocksY; dy += 1) { - for (let dx = 0; dx < blocksX; dx += 1) { - if (claimedBlocks[by + dy]?.[bx + dx] === true) return false - } - } - } - if (dirtPathGrid !== undefined) { - const startX = bx * TILES_PER_BLOCK - const startY = by * TILES_PER_BLOCK - const endX = (bx + blocksX) * TILES_PER_BLOCK - const endY = (by + blocksY) * TILES_PER_BLOCK - for (let cy = startY; cy < endY; cy += 1) { - for (let cx = startX; cx < endX; cx += 1) { - if (dirtPathGrid[cy * canvasWidth + cx] === 1) return false - } - } - } - return true -} - -/** - * Scatter one `LvlSub` row's pieces across the level's interior. - * - * Avoids overwriting blocks claimed by special presets (`claimedBlocks`) or - * cells traversed by generated dirt roads (`dirtPathGrid`). - * - * @param canvas - the canvas. - * @param row - the `LvlSub` row. - * @param themeIndex - the clamped `Levels.txt.SubTheme`. - * @param gridWidth - blocks across. - * @param gridHeight - blocks down. - * @param rng - the level's random stream. - * @param claimedBlocks - optional 2D block claim grid. - * @param dirtPathGrid - optional cell-level dirt road bitmask grid. - * @returns how many clusters were stamped. - */ -/** - * Atomically replace a target canvas region with a substitution DS1 (`DRLGTILESUB_ReplaceSubPreset`). - * - * Synchronously updates all 4 visual/collision layers (floors, walls, roofs `type === 15`, and shadows) - * plus the substitution group tags and DS1 objects, clearing stale procedural clutter on cells - * modified by the substitution patch. - */ -export function stampSubstitutionPatch( - canvas: Canvas, - patch: Ds1, - originX: number, - originY: number, -): number { - let written = 0 - for (let y = 0; y < patch.height; y += 1) { - const cy = originY + y - if (cy < 0 || cy >= canvas.height) continue - const srcRow = patch.cells[y] - const dstRow = canvas.cells[cy] - if (srcRow === undefined || dstRow === undefined) continue - - for (let x = 0; x < patch.width; x += 1) { - const cx = originX + x - if (cx < 0 || cx >= canvas.width) continue - const srcCell = srcRow[x] - const dstCell = dstRow[cx] - if (srcCell === undefined || dstCell === undefined) continue - - const hasFloor = srcCell.floors.some(f => !f.hidden && f.prop1 !== 0) - const hasWallOrRoof = srcCell.walls.some(w => !w.hidden && w.prop1 !== 0) - const hasShadow = srcCell.shadows.some(s => !s.hidden && s.prop1 !== 0) - const hasSub = srcCell.substitutions.some(s => s.value !== 0) - - if (!hasFloor && !hasWallOrRoof && !hasShadow && !hasSub) continue - - const targetFloors = dstCell.floors as Ds1Floor[] - const targetWalls = dstCell.walls as Ds1Wall[] - const targetShadows = dstCell.shadows as Ds1Floor[] - const targetSubs = dstCell.substitutions as Ds1Substitution[] - - // Atomic 4-layer sync (Floor, Wall, Roof, Shadow): when a substitution cell overwrites - // a tile, replace existing floor/wall/roof/shadow slots cleanly so underlying procedural - // walls or shadows do not bleed into the substituted structure. - if (hasFloor) { - for (let i = 0; i < srcCell.floors.length; i += 1) { - const floor = srcCell.floors[i] - if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) { - if (i < targetFloors.length) targetFloors[i] = floor - else targetFloors.push(floor) - } - } - } - - if (hasWallOrRoof || hasFloor) { - // Clear pre-existing procedural walls/roofs on this substituted cell before applying patch walls/roofs - for (let i = 0; i < targetWalls.length; i += 1) { - targetWalls[i] = { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } - } - for (let i = 0; i < srcCell.walls.length; i += 1) { - const wall = srcCell.walls[i] - if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) { - if (i < targetWalls.length) targetWalls[i] = wall - else targetWalls.push(wall) - } - } - } - - if (hasShadow || hasFloor) { - for (let i = 0; i < targetShadows.length; i += 1) { - targetShadows[i] = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } - } - for (let i = 0; i < srcCell.shadows.length; i += 1) { - const shadow = srcCell.shadows[i] - if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) { - if (i < targetShadows.length) targetShadows[i] = shadow - else targetShadows.push(shadow) - } - } - } - - if (hasSub) { - for (let i = 0; i < srcCell.substitutions.length; i += 1) { - const sub = srcCell.substitutions[i] - if (sub !== undefined && sub.value !== 0) { - if (i < targetSubs.length) targetSubs[i] = sub - else targetSubs.push(sub) - } - } - } - - written += 1 - } - } - - // Copy DS1 objects from the substitution patch - const subOriginX = originX * SUB_TILES_PER_TILE - const subOriginY = originY * SUB_TILES_PER_TILE - const maxSubX = canvas.width * SUB_TILES_PER_TILE - const maxSubY = canvas.height * SUB_TILES_PER_TILE - for (const obj of patch.objects) { - const ox = subOriginX + obj.x - const oy = subOriginY + obj.y - if (ox >= 0 && ox < maxSubX && oy >= 0 && oy < maxSubY) { - canvas.objects.push({ ...obj, x: ox, y: oy }) - } - } - - return written -} - -/** - * Build a Fisher-Yates randomized permutation of all grid-aligned candidate offsets - * within `(spanX, spanY)` for `Trials == -1` exhaustive substitution scan (`DRLGTILESUB_DoSubstitutions`). - */ -export function buildSubstitutionCandidatePermutation( - spanX: number, - spanY: number, - gridSize: number, - rng: Rng, -): { ox: number; oy: number }[] { - const step = Math.max(1, Math.floor(gridSize)) - const candidates: { ox: number; oy: number }[] = [] - const snap = (value: number, span: number): number => { - const limit = Math.max(Math.floor((span - 1) / step), 0) - return Math.min(Math.floor(value / step), limit) * step - } - - for (let oy = 0; oy < spanY; oy += step) { - for (let ox = 0; ox < spanX; ox += step) { - candidates.push({ ox: snap(ox, spanX), oy: snap(oy, spanY) }) - } - } - - // Fisher-Yates shuffle using the level's deterministic PRNG - for (let i = candidates.length - 1; i > 0; i -= 1) { - const j = rng.int(0, i) - const tmp = candidates[i]! - candidates[i] = candidates[j]! - candidates[j] = tmp - } - - return candidates -} - -/** - * Scatter one `LvlSub` row's pieces across the level's interior (`DRLGTILESUB_DoSubstitutions`). - * - * Implements Blizzard D2 1.13c `DrlgTileSub.cpp`: - * 1. Reads theme-indexed `Max[themeIndex]` and `Trials[themeIndex]` (`0..4`). - * 2. When `Trials == -1`, shuffles all candidate grid positions via Fisher-Yates permutation - * and scans until up to `wantedMax` non-overlapping clusters are placed. - * 3. When `Trials > 0`, performs up to `Trials` random coordinate draws per cluster. - * 4. Selects randomly among `row.levels` variants (`DRLGTILESUB_PickSubPreset`) and atomically - * replaces floor, wall, roof, and shadow layers (`stampSubstitutionPatch`). - */ -export function applySubstitution( - canvas: Canvas, - row: WildernessSubstitution, - themeIndex: number, - gridWidth: number, - gridHeight: number, - rng: Rng, - claimedBlocks?: boolean[][], - dirtPathGrid?: Uint8Array, - maxOverride?: number, -): number { - if (row.levels.length === 0) return 0 - const baseLevel = row.levels[0]! - const rawMax = Math.max(0, Math.floor(row.max[themeIndex] ?? 0)) - const max = maxOverride !== undefined ? Math.min(rawMax, maxOverride) : rawMax - if (max === 0) return 0 - const trials = Math.floor(row.trials[themeIndex] ?? 0) - const gridSize = Math.max(1, Math.floor(row.gridSize)) - const blocksX = Math.max(Math.floor(baseLevel.width / TILES_PER_BLOCK), 1) - const blocksY = Math.max(Math.floor(baseLevel.height / TILES_PER_BLOCK), 1) - - // Keep scenery off the wall ring, so it can never open or seal the level. - const innerX = gridWidth - 2 - const innerY = gridHeight - 2 - const spanX = innerX - blocksX - const spanY = innerY - blocksY - if (spanX <= 0 || spanY <= 0) return 0 - - /** Snap an interior offset to the cluster grid. */ - const snap = (value: number, span: number): number => { - const limit = Math.max(Math.floor((span - 1) / gridSize), 0) - return Math.min(Math.floor(value / gridSize), limit) * gridSize - } - - const recordClaim = (bx: number, by: number, bw: number, bh: number): void => { - if (claimedBlocks === undefined) return - for (let dy = 0; dy < bh; dy += 1) { - for (let dx = 0; dx < bw; dx += 1) { - if (claimedBlocks[by + dy] !== undefined) { - claimedBlocks[by + dy]![bx + dx] = true - } - } - } - } - - const pickVariant = (): Ds1 => { - if (row.levels.length === 1) return row.levels[0]! - return row.levels[rng.int(0, row.levels.length - 1)]! - } - - let stamped = 0 - - if (trials === -1) { - // 1.13c DRLGTILESUB_DoSubstitutions: when nTrials == -1, permute all candidate positions - // and place up to `max` clusters without repeating failed slots. - const candidates = buildSubstitutionCandidatePermutation(spanX, spanY, gridSize, rng) - let candidateIdx = 0 - for (let cluster = 0; cluster < max && candidateIdx < candidates.length; cluster += 1) { - let placed = false - while (candidateIdx < candidates.length && !placed) { - const cand = candidates[candidateIdx]! - candidateIdx += 1 - const bx = 1 + cand.ox - const by = 1 + cand.oy - if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { - continue - } - const chosenPatch = pickVariant() - stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) - recordClaim(bx, by, blocksX, blocksY) - placed = true - } - if (placed) stamped += 1 - } - return stamped - } - - for (let cluster = 0; cluster < max; cluster += 1) { - let placed = false - for (let attempt = 0; attempt < trials && !placed; attempt += 1) { - const offsetX = snap(rng.int(0, Math.max(spanX - 1, 0)), spanX) - const offsetY = snap(rng.int(0, Math.max(spanY - 1, 0)), spanY) - const bx = 1 + offsetX - const by = 1 + offsetY - if (!canPlaceSubstitutionCluster(bx, by, blocksX, blocksY, claimedBlocks, dirtPathGrid, canvas.width)) { - continue - } - const chosenPatch = pickVariant() - stampSubstitutionPatch(canvas, chosenPatch, bx * TILES_PER_BLOCK, by * TILES_PER_BLOCK) - recordClaim(bx, by, blocksX, blocksY) - placed = true - } - if (placed) stamped += 1 - } - return stamped -} - -/** - * Run the `Prob` gate and then the cluster loop for every substitution row. - * - * @param canvas - the canvas. - * @param rows - the rows to run, in table order. - * @param themeIndex - the clamped `SubTheme` (`0..4`). - * @param gridWidth - blocks across. - * @param gridHeight - blocks down. - * @param rng - the level's random stream. - * @param stats - report collector. - * @param claimedBlocks - optional 2D block claim grid. - * @param dirtPathGrid - optional cell-level dirt road bitmask grid. - * @returns the number of clusters stamped. - */ -export function applySubstitutions( - canvas: Canvas, - rows: readonly WildernessSubstitution[], - themeIndex: number, - gridWidth: number, - gridHeight: number, - rng: Rng, - stats: WildernessStats, - claimedBlocks?: boolean[][], - dirtPathGrid?: Uint8Array, -): number { - let total = 0 - let sharedBudget = 1 - const clampedTheme = Math.max(0, Math.min(4, Math.floor(themeIndex))) - for (const row of rows) { - const role = classifySubstitutionRole(row.name, row.type) - const chance = row.prob[clampedTheme] ?? 0 - const enabled = chance > 0 && rng.int(0, 99) < chance - let clusters = 0 - if (enabled) { - // `BordType` 0 = one cluster for the whole level, 1 = one per row. - const allowance = row.bordType === 0 ? sharedBudget : row.bordType === 1 ? 1 : Number.POSITIVE_INFINITY - const rowMax = Math.max(0, Math.floor(row.max[clampedTheme] ?? 0)) - const wanted = Math.min(rowMax, allowance) - if (wanted > 0) { - const single = applySubstitution(canvas, row, clampedTheme, gridWidth, gridHeight, rng, claimedBlocks, dirtPathGrid, wanted) - clusters = Math.min(wanted, single) - if (row.bordType !== 0 && row.bordType !== 1) clusters = single - if (row.bordType === 0) sharedBudget = Math.max(0, sharedBudget - clusters) - total += clusters - } - } - stats.substitutions.push({ name: row.name, role, enabled, clusters }) - } - return total -} - -/* ------------------------------------------------------------------------- * - * Special presets - * ------------------------------------------------------------------------- */ - -/** - * Special preset piece lists stamped per level ID. - * - * Traceability to Blizzard DRLG outdoor logic: - * - Act 2 Desert levels (41-46): - * Corresponds to D2MOO `DRLGOUTDESR_AddExits` (0x6FD7D9F0) for level exits: - * - Level 41 (Rocky Waste): Stony Tomb (`Act 2 - Desert Tomb 1`) - * - Level 42 (Dry Hills): Halls of the Dead (`Act 2 - Desert Tomb 1`) - * - Level 43 (Far Oasis): Maggot Lair (`Act 2 - Desert Lair 1`, plus `DRLGOUTDESR_PlaceFillsInFarOasis`) - * - Level 44 (Lost City): Ancient Tunnels (`Act 2 - Desert Ruins Sewer`, plus `DRLGOUTDESR_PlaceRuinsInLostCity` Elder / 16x16 / 16x08 / 08x16) - * - Level 45 (Valley of Snakes): Claw Viper Temple (`Act 2 - Desert Tomb 2`) - * - Level 46 (Canyon of the Magi): Tal Rasha Tombs & Arcane Sanctuary warp (`DRLGOUTDESR_PlaceTombEntriesInCanyon`) - * - Act 3 Kurast levels (79-81): - * Corresponds to D2MOO `DRLGOUTJUNG_SpawnRandomPreset` (0x6FD80230) and Kurast interior presets - * called within `DRLGOUTJUNG_BuildLowerKurast` (0x6FD7FE50), `DRLGOUTJUNG_BuildKurastBazaar` (0x6FD7FFA0), - * and `DRLGOUTJUNG_BuildUpperKurast` (0x6FD800E0). - */ -export const SPECIAL_PRESETS_BY_LEVEL: Readonly> = { - 41: [ - 'Act 2 - Desert Tomb 1', - 'Act 2 - Desert Fill Wagon 1', - 'Act 2 - Desert Fill Bone 1', - 'Act 2 - Desert Fill Bone 2', - 'Act 2 - Desert Fill Berms 1', - 'Act 2 - Desert Fill Berms 2', - ], - 42: [ - 'Act 2 - Desert Tomb 1', - 'Act 2 - Desert Ruins 16X16', - 'Act 2 - Desert Fill Mesa 1', - 'Act 2 - Desert Fill Head 1', - 'Act 2 - Desert Fill Head 2', - ], - 43: [ - 'Act 2 - Desert Oasis 2', - 'Act 2 - Desert Oasis 3', - 'Act 2 - Desert Oasis 1', - 'Act 2 - Desert Lair 1', - 'Act 2 - Desert Fill Bone 1', - 'Act 2 - Desert Fill Berms 3', - ], - 44: [ - 'Act 2 - Desert Ruins Sewer', - 'Act 2 - Desert Ruins Elder', - 'Act 2 - Desert Ruins 16X16', - 'Act 2 - Desert Ruins 16X08', - 'Act 2 - Desert Ruins 08X16', - ], - 45: [ - 'Act 2 - Desert Tomb 2', - 'Act 2 - Desert Valley Ruin 1', - 'Act 2 - Desert Valley Ruin 2', - ], - 46: [ - 'Act 2 - Desert Valley Warp', - 'Act 2 - Desert Cliff Top King Tomb', - 'Act 2 - Desert Cliff Left King Tomb', - 'Act 2 - Desert Cliff Right King Tomb', - 'Act 2 - Desert Fill Mesa 1', - ], - 76: [ - 'Act 3 - Spider Cavern', - 'Act 3 - Spider Temple Entrance', - ], - 78: [ - 'Act 3 - Flayer Jungle Entrance', - 'Act 3 - Flayer Dungeon', - ], - 79: [ - 'Act 3 - Slums 16x16', - 'Act 3 - Slums 16x16', - 'Act 3 - Slums 16x08', - 'Act 3 - Slums 08x16', - 'Act 3 - Slums 08x08', - ], - 80: [ - 'Act 3 - Burbs Temple', - 'Act 3 - Burbs Temple', - 'Act 3 - Burbs Sewer', - 'Act 3 - Burbs Waypoint', - 'Act 3 - Burbs 16x16', - 'Act 3 - Burbs 16x08', - ], - 81: [ - 'Act 3 - MetroTemple', - 'Act 3 - MetroTemple', - 'Act 3 - Metro Sewer', - 'Act 3 - Metro 16x16', - 'Act 3 - Metro 16x08', - ], - 88: [ - 'Act 3 - Flayer Dungeon', - ], - 91: [ - 'Act 3 - Flayer Dungeon', - ], - 94: [ - 'Act 3 - Ruined Temple', - ], - 105: [ - 'Act 4 - Plains of Despair', - 'Act 4 - Izual', - ], - 106: [ - 'Act 4 - Hellforge', - 'Act 4 - Chaos Sanctuary Entrance', - ], - 107: [ - 'Act 4 - Hellforge', - 'Act 4 - Chaos Sanctuary Entrance', - ], - 108: [ - 'Act 4 - Chaos Sanctuary Entrance', - 'Act 4 - Hadriel', - ], - 110: [ - 'Act 5 - Barricade Prison 1', - 'Act 5 - Barricade Building', - ], - 111: [ - 'Act 5 - Barricade Waypoint Dirt', - 'Act 5 - Barricade Hell Portal N', - 'Act 5 - Barricade Prison 1', - 'Act 5 - Barricade Prison 2', - 'Act 5 - Barricade Prison 3', - 'Act 5 - Barricade 1', - 'Act 5 - Barricade 2', - 'Act 5 - Barricade 3', - 'Act 5 - Barricade 4', - 'Act 5 - Barricade Building', - 'Act 5 - Barricade Filler Treasure', - 'Act 5 - Barricade Ruins N', - 'Act 5 - Barricade Ruins W', - ], - 112: [ - 'Act 5 - Barricade Waypoint Snow', - 'Act 5 - Barricade Hell Portal N', - 'Act 5 - Barricade Snow Unique', - 'Act 5 - Barricade 1 Snow', - 'Act 5 - Barricade 2 Snow', - 'Act 5 - Barricade 3 Snow', - 'Act 5 - Barricade 4 Snow', - 'Act 5 - Barricade Snow Lake 1', - 'Act 5 - Barricade Snow Lake 2', - 'Act 5 - Barricade Ruins N Treasure', - 'Act 5 - Barricade Ruins W Treasure', - ], - 114: [ - 'Act 5 - Frozen River', - 'Act 5 - Anya', - ], - 115: [ - 'Act 5 - Glacial Trail', - 'Act 5 - Drifter Cavern', - ], - 117: [ - 'Act 5 - Barricade To Cave 32x16 Snow', - 'Act 5 - Barricade Hell Portal N', - 'Act 5 - Barricade Waypoint Snow', - 'Act 5 - Barricade 1 Snow', - 'Act 5 - Barricade 2 Snow', - 'Act 5 - Barricade 3 Snow', - 'Act 5 - Barricade Snow Lake 1', - ], - 124: [ - 'Act 5 - Halls of Vaught', - 'Act 5 - Nihlathak', - ], -} - -/** - * Scatter special presets across the level's interior. - * - * Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and - * `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates - * (excluding the outer 1-block border) are shuffled deterministically with the - * level's RNG, and each preset in sequence scans for the first free block - * rectangle where it can fit without overlapping already-occupied blocks or the - * perimeter. - * - * @param canvas - the mutable canvas. - * @param pieces - available fill pieces. - * @param presetNames - preset name prefixes to spawn in priority order. - * @param gridWidth - blocks across. - * @param gridHeight - blocks down. - * @param rng - seeded RNG. - * @param stats - report collector. - * @returns set of piece names that were placed. - */ -/** Bounding rectangle of a placed special preset in canvas tile coordinates. */ -export interface PlacedPresetRect { - readonly name: string - readonly x: number - readonly y: number - readonly w: number - readonly h: number -} - -/** - * Scatter special presets across the level's interior. - * - * Follows D2MOO's `DRLGOUTWILD_SpawnSpecialPresets` and - * `DRLGOUTDOORS_SpawnOutdoorLevelPreset`: candidate interior grid coordinates - * (excluding the outer 1-block border and any reserved main road corridors) are - * shuffled deterministically with the level's RNG, and each preset in sequence - * scans for the first free block rectangle where it can fit without overlapping - * already-occupied blocks or the perimeter. - */ -function spawnSpecialPresets( - canvas: Canvas, - pieces: readonly WildernessPiece[], - presetNames: readonly string[], - gridWidth: number, - gridHeight: number, - rng: Rng, - stats: WildernessStats, - reservedBlocks?: ReadonlySet, -): { placedPieces: Set; placedRects: PlacedPresetRect[] } { - const innerWidth = gridWidth - 2 - const innerHeight = gridHeight - 2 - const totalSlots = innerWidth * innerHeight - const placedPieces = new Set() - const placedRects: PlacedPresetRect[] = [] - if (totalSlots <= 0 || presetNames.length === 0) return { placedPieces, placedRects } - - const occupied: boolean[][] = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) - for (let x = 0; x < gridWidth; x += 1) { - occupied[0]![x] = true - occupied[gridHeight - 1]![x] = true - } - for (let y = 0; y < gridHeight; y += 1) { - occupied[y]![0] = true - occupied[y]![gridWidth - 1] = true - } - if (reservedBlocks && gridWidth >= 8 && gridHeight >= 8) { - for (const key of reservedBlocks) { - const [bxStr, byStr] = key.split(',') - const bx = Number(bxStr) - const by = Number(byStr) - if (occupied[by]?.[bx] !== undefined) { - occupied[by]![bx] = true - } - } - } - - for (const presetName of presetNames) { - const piece = pieces.find(p => p.name === presetName || p.name.startsWith(presetName)) - if (!piece || piece.levels.length === 0) continue - - const variantIndex = piece.levels.length > 1 ? rng.int(0, piece.levels.length - 1) : 0 - const ds1 = piece.levels[variantIndex]! - const blocksX = Math.max(1, Math.floor(ds1.width / TILES_PER_BLOCK)) - const blocksY = Math.max(1, Math.floor(ds1.height / TILES_PER_BLOCK)) - - const coords: { x: number; y: number }[] = [] - for (let i = 0; i < totalSlots; i += 1) { - coords.push({ x: i % innerWidth, y: Math.floor(i / innerWidth) }) - } - for (let i = 0; i < totalSlots; i += 1) { - const r1 = rng.int(0, totalSlots - 1) - const r2 = rng.int(0, totalSlots - 1) - const tmp = coords[r1]! - coords[r1] = coords[r2]! - coords[r2] = tmp - } - - let placed = false - for (let i = 0; i < totalSlots; i += 1) { - const bx = coords[i]!.x + 1 - const by = coords[i]!.y + 1 - - if (bx < 1 || by < 1 || bx + blocksX > gridWidth - 1 || by + blocksY > gridHeight - 1) continue - - let blocked = false - for (let dy = 0; dy < blocksY; dy += 1) { - for (let dx = 0; dx < blocksX; dx += 1) { - if (occupied[by + dy]![bx + dx]) { - blocked = true - break - } - } - if (blocked) break - } - if (blocked) continue - - const tileX = bx * TILES_PER_BLOCK - const tileY = by * TILES_PER_BLOCK - stampDs1(canvas, ds1, tileX, tileY) - - for (let dy = 0; dy < blocksY; dy += 1) { - for (let dx = 0; dx < blocksX; dx += 1) { - occupied[by + dy]![bx + dx] = true - } - } - - stats.substitutions.push({ - name: piece.name, - role: 'object', - enabled: true, - clusters: 1, - }) - placedPieces.add(piece.name) - placedRects.push({ - name: piece.name, - x: tileX, - y: tileY, - w: ds1.width, - h: ds1.height, - }) - placed = true - break - } - - if (!placed) { - stats.notes.push(`special preset "${presetName}" could not be placed (no free interior block)`) - } - } - - return { placedPieces, placedRects } -} - -/* ------------------------------------------------------------------------- * - * Entry point - * ------------------------------------------------------------------------- */ - -export class UnresolvedLevelSizeError extends Error { - constructor(levelId: number, levelName: string) { - super(`UnresolvedLevelSizeError: Level ${levelId} (${levelName}) has no pieces to derive size`) - this.name = 'UnresolvedLevelSizeError' - } -} - -/** - * Derive the block grid for a level whose `Levels.txt` size is unset. - */ -export function resolveUnsetSize(request: WildernessRequest): { sizeX: number; sizeY: number; source: string } { - if (request.sizeOverride !== undefined) { - return { sizeX: request.sizeOverride.x, sizeY: request.sizeOverride.y, source: 'sizeOverride' } - } - - const barricadePieces = request.pieces.filter(p => /barricade/i.test(p.name)) - if (barricadePieces.length === 0 && request.levelTypeName !== 'Act 5 - Barricade') { - throw new UnresolvedLevelSizeError(request.levelId, request.levelName) - } - if (request.pieces.length === 0 || barricadePieces.length === 0) { - throw new UnresolvedLevelSizeError(request.levelId, request.levelName) - } - - // Derive vertical corridor dimensions from request.pieces: - // - Entrance width (16) + border cliffs (16 each side) gives width sizeX in [48..64] (6..8 blocks of 8 tiles). - // - Entrance (32) + march sections + barricade walls (16 each) + exit (32) gives height sizeY in [160..192] (20..24 blocks of 8 tiles). - // - Ensure sizeY > sizeX unconditionally holds for Levels 111 and 112 (aspect ratio ~3:1). - const entrance = barricadePieces.find(p => /entrance/i.test(p.name))?.levels[0] - const exit = barricadePieces.find(p => /exit/i.test(p.name))?.levels[0] - - const baseWidth = entrance ? Math.max(entrance.width, 32) : 32 - const sizeX = Math.min(64, Math.max(48, Math.ceil((baseWidth + 32) / 8) * 8)) - - const entranceH = entrance ? Math.max(entrance.height, 32) : 32 - const exitH = exit ? Math.max(exit.height, 32) : 32 - const wallSpacing = 32 - const numBarricadeWalls = 2 - const rawHeight = entranceH + exitH + numBarricadeWalls * (16 + wallSpacing) + 32 - const sizeY = Math.min(192, Math.max(160, Math.ceil(rawHeight / 8) * 8)) - - return { sizeX, sizeY, source: 'LvlPrest Barricade corridor dynamic extent' } -} - - -/** - * Assemble the 6 presets for Act 3 Travincal (LevelId 83, 64x64). - */ -function stampAct3Travincal( - canvas: Canvas, - pieces: readonly WildernessPiece[], - stats: WildernessStats, - placedRects?: PlacedPresetRect[], -): Set { - const placements: Array<{ name: string; x: number; y: number }> = [ - { name: 'Act 3 - Travincal NW', x: 0, y: 0 }, - { name: 'Act 3 - Travincal N', x: 16, y: 0 }, - { name: 'Act 3 - Travincal NE', x: 48, y: 0 }, - { name: 'Act 3 - Travincal SW', x: 0, y: 32 }, - { name: 'Act 3 - Travincal S', x: 16, y: 32 }, - { name: 'Act 3 - Travincal SE', x: 48, y: 32 }, - ] - const placed = new Set() - for (const item of placements) { - const piece = pieces.find(p => p.name.trim() === item.name.trim()) - const ds1 = piece?.levels[0] - if (!ds1) continue - stampDs1(canvas, ds1, item.x, item.y) - placed.add(item.name) - placedRects?.push({ name: item.name, x: item.x, y: item.y, w: ds1.width, h: ds1.height }) - stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 }) - } - return placed -} - -/** - * Assemble Act 3 Jungles (Spider Forest 76, Great Marsh 77, Flayer Jungle 78) using - * the authentic `DRLG_GenerateJungles` river network and branching topology pipeline. - */ -function stampAct3Jungle( - canvas: Canvas, - levelId: number, - pieces: readonly WildernessPiece[], - rng: Rng, - stats: WildernessStats, - placedRects?: PlacedPresetRect[], -): Set { - return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1, placedRects) -} - -/** Theme configuration for outdoor road rasterization. */ -export interface OutdoorRoadTheme { - readonly roadFloor: (rng: Rng) => Ds1Floor - readonly isProtectedPreset?: (presetName: string) => boolean - readonly isIgnoredSpurPreset?: (presetName: string) => boolean -} - -/** Theme configuration for outdoor stone wall divider networks. */ -export interface OutdoorDividerTheme { - readonly wallStyle: number - readonly wallProp1?: number - readonly roadStyles?: readonly number[] -} - -/** - * General outdoor path and highway rasterizer connecting entrance, central crossroads, - * exit gates, and landmark presets across wilderness maps. - */ -export function stampThemedRoads( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - theme: OutdoorRoadTheme, - openGates?: ReadonlyMap, - stats?: WildernessStats, -): void { - const paintedRoadCells = new Set() - - const isInsideProtectedPreset = (x: number, y: number): boolean => { - for (const r of placedRects) { - if (theme.isProtectedPreset ? !theme.isProtectedPreset(r.name) : false) { - continue - } - if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) { - return true - } - } - return false - } - - const paintRoadTile = (x: number, y: number, clearStrayClutter: boolean): void => { - if (x < 4 || x >= canvas.width - 4 || y < 4 || y >= canvas.height - 4) return - if (isInsideProtectedPreset(x, y)) return - const cell = canvas.cells[y]?.[x] - if (!cell) return - const existingFloor = cell.floors[0] - // Never overwrite water (`style 2/3`) or wooden bridge planks (`style 4`) - if (existingFloor && (existingFloor.style === 2 || existingFloor.style === 3 || existingFloor.style === 4)) { - return - } - const belongsToPreset = placedRects.some(r => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) { - if (belongsToPreset) { - return - } - if (clearStrayClutter) { - // Clear random LvlSub bush/tree clutter on the main highway spine - ;(cell.walls as Ds1Wall[]).length = 0 - } else { - return - } - } - ;(cell.floors as Ds1Floor[])[0] = theme.roadFloor(rng) - paintedRoadCells.add(`${x},${y}`) - } - - const hubX = inX * TILES_PER_BLOCK + 4 - const hubY = outY * TILES_PER_BLOCK + 4 - const southGateY = (gridHeight - 1) * TILES_PER_BLOCK + 3 - const eastGateX = (gridWidth - 1) * TILES_PER_BLOCK + 3 - let northEnd = Math.max(12, Math.floor(canvas.height * 0.2)) - let westEnd = Math.max(12, Math.floor(canvas.width * 0.2)) - - if (openGates !== undefined) { - for (const key of openGates.keys()) { - const [bxStr, byStr] = key.split(',') - const bx = Number(bxStr) - const by = Number(byStr) - if (by === 0) northEnd = 4 - if (bx === 0) westEnd = 4 - } - } - - const paintSplinePath = ( - controlPoints: readonly { readonly x: number; readonly y: number }[], - brushRadius: number, - clearStrayClutter: boolean, - ): void => { - if (controlPoints.length < 2) return - - for (let i = 0; i < controlPoints.length - 1; i += 1) { - const p0 = i > 0 ? controlPoints[i - 1]! : { - x: 2 * controlPoints[0]!.x - controlPoints[1]!.x, - y: 2 * controlPoints[0]!.y - controlPoints[1]!.y, - } - const p1 = controlPoints[i]! - const p2 = controlPoints[i + 1]! - const p3 = i + 2 < controlPoints.length ? controlPoints[i + 2]! : { - x: 2 * controlPoints[controlPoints.length - 1]!.x - controlPoints[controlPoints.length - 2]!.x, - y: 2 * controlPoints[controlPoints.length - 1]!.y - controlPoints[controlPoints.length - 2]!.y, - } - - const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y) - const steps = Math.max(3, Math.ceil(dist / 1.0)) - for (let s = 0; s <= steps; s += 1) { - const t = s / steps - const pt = catmullRomPoint(p0, p1, p2, p3, t) - const cx = Math.round(pt.x) - const cy = Math.round(pt.y) - - if (brushRadius >= 1.5) { - for (let dy = -1; dy <= 1; dy += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - paintRoadTile(cx + dx, cy + dy, clearStrayClutter) - } - } - } else { - for (let dy = 0; dy <= 1; dy += 1) { - for (let dx = 0; dx <= 1; dx += 1) { - paintRoadTile(cx + dx, cy + dy, clearStrayClutter) - } - } - } - } - } - } - - // 1. 3-tile-wide South-to-North main highway curved via Catmull-Rom spline - const midNY = Math.round((northEnd + hubY) / 2) - const mNorth = rng.int(-2, 2) - const cpNorth = { x: hubX + mNorth, y: midNY } - if (isInsideProtectedPreset(cpNorth.x, cpNorth.y)) cpNorth.x = hubX - - const midSY = Math.round((hubY + southGateY) / 2) - const mSouth = rng.int(-2, 2) - const cpSouth = { x: hubX + mSouth, y: midSY } - if (isInsideProtectedPreset(cpSouth.x, cpSouth.y)) cpSouth.x = hubX - - paintSplinePath([ - { x: hubX, y: northEnd }, - cpNorth, - { x: hubX, y: hubY }, - cpSouth, - { x: hubX, y: southGateY }, - ], 1.5, true) - - // 2. 3-tile-wide West-to-East main highway curved via Catmull-Rom spline - const midWX = Math.round((westEnd + hubX) / 2) - const mWest = rng.int(-2, 2) - const cpWest = { x: midWX, y: hubY + mWest } - if (isInsideProtectedPreset(cpWest.x, cpWest.y)) cpWest.y = hubY - - const midEX = Math.round((hubX + eastGateX) / 2) - const mEast = rng.int(-2, 2) - const cpEast = { x: midEX, y: hubY + mEast } - if (isInsideProtectedPreset(cpEast.x, cpEast.y)) cpEast.y = hubY - - paintSplinePath([ - { x: westEnd, y: hubY }, - cpWest, - { x: hubX, y: hubY }, - cpEast, - { x: eastGateX, y: hubY }, - ], 1.5, true) - - // If openGates contains non-standard gate blocks, connect them into the highway network via smooth spline - if (openGates !== undefined) { - for (const key of openGates.keys()) { - const [bxStr, byStr] = key.split(',') - const bx = Number(bxStr) - const by = Number(byStr) - const gateX = bx * TILES_PER_BLOCK + 4 - const gateY = by * TILES_PER_BLOCK + 4 - if (by === 0 && Math.abs(gateX - hubX) > 2) { - // North gate branch to hub - const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1) - const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1) - paintSplinePath([ - { x: gateX, y: 4 }, - { x: midX, y: midY }, - { x: hubX, y: hubY }, - ], 1.5, true) - } else if (bx === 0 && Math.abs(gateY - hubY) > 2) { - // West gate branch to hub - const midX = Math.round((gateX + hubX) / 2) + rng.int(-1, 1) - const midY = Math.round((gateY + hubY) / 2) + rng.int(-1, 1) - paintSplinePath([ - { x: 4, y: gateY }, - { x: midX, y: midY }, - { x: hubX, y: hubY }, - ], 1.5, true) - } - } - } - - // 3. 2-tile-wide spur roads connecting each major spawned landmark to the main highway via smooth spline - for (const r of placedRects) { - if (theme.isIgnoredSpurPreset ? theme.isIgnoredSpurPreset(r.name) : /River|Bridge|Fill|Berms/i.test(r.name)) { - continue - } - const centerX = Math.floor(r.x + r.w / 2) - const centerY = Math.floor(r.y + r.h / 2) - // Pick the edge of the preset closest to the main crossroads - const distToNS = Math.abs(centerX - hubX) - const distToEW = Math.abs(centerY - hubY) - let doorX: number - let doorY: number - let targetX: number - let targetY: number - if (distToNS <= distToEW) { - doorX = centerX < hubX ? r.x + r.w : r.x - 1 - doorY = Math.max(r.y + 2, Math.min(r.y + r.h - 3, centerY)) - targetX = hubX - targetY = doorY - } else { - doorX = Math.max(r.x + 2, Math.min(r.x + r.w - 3, centerX)) - doorY = centerY < hubY ? r.y + r.h : r.y - 1 - targetX = doorX - targetY = hubY - } - - const midX = Math.round((doorX + targetX) / 2) + rng.int(-1, 1) - const midY = Math.round((doorY + targetY) / 2) + rng.int(-1, 1) - paintSplinePath([ - { x: doorX, y: doorY }, - { x: midX, y: midY }, - { x: targetX, y: targetY }, - ], 1.0, false) - } - - if (stats) { - stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + paintedRoadCells.size - } -} - -/** - * General outdoor divider network. - * @deprecated Procedural cross divider networks were removed in Issue #65 to restore - * Blizzard DRLG fidelity. In original Diablo II, wilderness barriers are formed naturally - * by cliff borders, water bodies, and LvlSub substitutions without artificial cross stone walls. - */ -export function stampThemedDividers( - _canvas: Canvas, - _gridWidth: number, - _gridHeight: number, - _inX: number, - _outY: number, - _placedRects: readonly PlacedPresetRect[], - _rng: Rng, - _theme: OutdoorDividerTheme, - stats?: WildernessStats, -): void { - if (stats) { - stats.proceduralDividerCells = 0 - } -} - -/** - * Act 2: Desert sandstone caravan routes connecting entrance to exit gates across dunes. - */ -export function stampAct2DesertRoads( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - openGates?: ReadonlyMap, - stats?: WildernessStats, -): void { - stampThemedRoads( - canvas, - gridWidth, - gridHeight, - inX, - outY, - placedRects, - rng, - { - roadFloor: (r) => ({ - prop1: 194, - sequence: r.int(0, 5) === 0 ? 1 : 0, - style: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }), - isProtectedPreset: (name) => name !== 'Act 1 - River', - isIgnoredSpurPreset: (name) => /Fill|Berms/i.test(name), - }, - openGates, - stats, - ) -} - -/** - * Act 2: Sandstone divider berms (style=1, prop1=129) separating desert dunes. - */ -export function stampAct2SandstoneBerms( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - stats?: WildernessStats, -): void { - stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { - wallStyle: 1, - wallProp1: 129, - roadStyles: [1], - }, stats) -} - -/** - * Act 3: Kurast flagstone avenues across temples, plazas, and causeways. - */ -export function stampAct3KurastAvenues( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - openGates?: ReadonlyMap, - stats?: WildernessStats, -): void { - stampThemedRoads( - canvas, - gridWidth, - gridHeight, - inX, - outY, - placedRects, - rng, - { - roadFloor: (r) => ({ - prop1: 194, - sequence: r.int(0, 5) === 0 ? 1 : 0, - style: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }), - isProtectedPreset: (name) => name !== 'Act 1 - River', - isIgnoredSpurPreset: (name) => /Fill/i.test(name), - }, - openGates, - stats, - ) -} - -/** - * Act 3: Kurast balustrades across temples and causeways (style=1, prop1=129). - */ -export function stampAct3KurastBalustrades( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - stats?: WildernessStats, -): void { - stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { - wallStyle: 1, - wallProp1: 129, - roadStyles: [1], - }, stats) -} - -/** - * Act 5: Snow march roads in Frigid Highlands & Arreat Plateau. - */ -export function stampAct5SnowRoads( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - openGates?: ReadonlyMap, - stats?: WildernessStats, -): void { - stampThemedRoads( - canvas, - gridWidth, - gridHeight, - inX, - outY, - placedRects, - rng, - { - roadFloor: (r) => ({ - prop1: 194, - sequence: r.int(0, 5) === 0 ? 1 : 0, - style: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }), - isProtectedPreset: (name) => name !== 'Act 1 - River', - isIgnoredSpurPreset: (name) => /Fill|Filler/i.test(name), - }, - openGates, - stats, - ) -} - -/** - * Act 5: Frozen trail divides in Frigid Highlands & Arreat Plateau (style=2, prop1=129). - */ -export function stampAct5FrozenTrailDivides( - canvas: Canvas, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - stats?: WildernessStats, -): void { - stampThemedDividers(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, { - wallStyle: 2, - wallProp1: 129, - roadStyles: [1], - }, stats) -} - -/** - * Dispatch outdoor road networks based on act, levelId, and levelTypeName. - */ -export function stampOutdoorRoads( - canvas: Canvas, - act: number, - levelId: number, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - levelTypeName?: string, - openGates?: ReadonlyMap, - levelsTable?: D2Table, - stats?: WildernessStats, -): void { - // Level ID takes highest precedence - if (levelId >= 41 && levelId <= 46) { - stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - return - } else if (levelId >= 79 && levelId <= 82) { - stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - return - } else if (levelId === 111 || levelId === 112 || levelId === 117) { - stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - return - // Level type name takes second precedence - } else if (levelTypeName === 'Act 2 - Desert') { - stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - return - } else if (levelTypeName === 'Act 3 - Kurast') { - stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - return - } else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') { - stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - return - } - - // Unified actOfLevel fallback - const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable) - if (effectiveAct === 2) { - stampAct2DesertRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - } else if (effectiveAct === 3) { - stampAct3KurastAvenues(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - } else if (effectiveAct === 5) { - stampAct5SnowRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, openGates, stats) - } else if (effectiveAct === 1) { - throw new Error(`stampOutdoorRoads: level ${levelId} is an Act I outdoor level; those come from the DRLG port (src/game/drlg)`) - } -} - -/** - * Dispatch outdoor boundary divider networks based on act, levelId, and levelTypeName. - */ -export function stampOutdoorDividers( - canvas: Canvas, - act: number, - levelId: number, - gridWidth: number, - gridHeight: number, - inX: number, - outY: number, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - levelTypeName?: string, - levelsTable?: D2Table, - stats?: WildernessStats, -): void { - // Level ID takes highest precedence - if (levelId >= 41 && levelId <= 46) { - stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - return - } else if (levelId >= 79 && levelId <= 82) { - stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - return - } else if (levelId === 111 || levelId === 112 || levelId === 117) { - stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - return - } - - // Level type name takes second precedence - if (levelTypeName === 'Act 2 - Desert') { - stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - return - } else if (levelTypeName === 'Act 3 - Kurast') { - stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - return - } else if (levelTypeName === 'Act 5 - Barricade' || levelTypeName === 'Act 5 - Siege') { - stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - return - } - - // Unified actOfLevel fallback - const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable) - if (effectiveAct === 2) { - stampAct2SandstoneBerms(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - } else if (effectiveAct === 3) { - stampAct3KurastBalustrades(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - } else if (effectiveAct === 5) { - stampAct5FrozenTrailDivides(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng, stats) - } else if (effectiveAct === 1) { - throw new Error(`stampOutdoorDividers: level ${levelId} is an Act I outdoor level; those come from the DRLG port (src/game/drlg)`) - } -} - -/* ------------------------------------------------------------------------- * - * Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie - * (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`) - * ------------------------------------------------------------------------- */ - -export interface ActPopulationConfig { - readonly waypointId: number - readonly shrinePool: readonly number[] - readonly wellId: number - readonly chestPool: readonly number[] - readonly scatterPool: readonly number[] -} - -/** - * Population object ID pool per Act for outdoor wilderness generation (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`). - * - * Each ID is a local DS1 Type 2 object record ID mapped through `object-lookup-data.ts` to Blizzard's `Objects.txt`: - * - `waypointId`: - * - Act 1: 37 -> Objects.txt row 119 (`Waypoint`, token WP) - * - Act 2: 10 -> Objects.txt row 156 (`Waypoint`, token WM) - * - Act 3: 1 -> Objects.txt row 237 (`Waypoint`, token WZ) - * - Act 4: 0 -> Objects.txt row 238 (`Waypoint`, token WV) - * - Act 5: 12 -> Objects.txt row 38 (`Waypoint`, token WT) - * - `wellId`: - * - Act 1: 31 -> Objects.txt row 84 (Healing Well, token HW) - * - Acts 2..5: 234 -> Objects.txt row 234 (Outdoor Well, token QY) - * - `shrinePool`: - * - Act 1: 29 (Shrine SF), 82 (Shrine SH), 83 (Shrine BC), 84 (Dummy SG) - * - Act 2: 54 (Z1), 55 (Z2), 103 (FL), 104 (A3), 105 (A4), 126 (IS) - * - Act 3: 14 (S1), 85 (BC), 111 (F3), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO) - * - Act 4: 56 (Z5), 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO) - * - Act 5: 152 (HA), 227 (QX), 231 (HT), 235 (QZ), 236 (HO) - * - `chestPool`: - * - Act 1: 5 (chest L1), 89..94 (caskets C1..C6) - * - Act 2: 32 (CL), 33 (A1), 44..46 (R5, S1, CT) - * - Act 3: 65 (X1), 66 (X2), 67 (1R), 80 (OB), 81 (AF) - * - Act 4: 19 (XQ), 20 (QS), 24 (HO), 25 (HN) - * - Act 5: 24 (YP), 25 (6A), 26 (YZ), 27 (6Q), 28 (6B), 29 (6C) - * - `scatterPool`: - * - Act 1: 17 (BR), 18 (BF), 25 (TT) - * - Act 2: 14 (TT), 48 (BR), 122 (TT) - * - Act 3: 4 (FB), 5 (TO), 48 (DE), 179 (BR) - * - Act 4: 40 (HG), 63 (FB), 179 (BR) - * - Act 5: 13 (FB), 179 (BR), 4 (B6) - */ -export const ACT_POPULATION_CONFIG: Readonly> = { - 1: { - waypointId: 37, - shrinePool: [29, 82, 83, 84], - wellId: 31, - chestPool: [5, 89, 90, 91, 92, 93, 94], - scatterPool: [17, 18, 25], - }, - 2: { - waypointId: 10, - shrinePool: [54, 55, 103, 104, 105, 126], - wellId: 234, - chestPool: [32, 33, 44, 45, 46], - scatterPool: [14, 48, 122], - }, - 3: { - waypointId: 1, - shrinePool: [14, 85, 111, 152, 227, 231, 235, 236], - wellId: 234, - chestPool: [65, 66, 67, 80, 81], - scatterPool: [4, 5, 48, 179], - }, - 4: { - waypointId: 0, - shrinePool: [56, 152, 227, 231, 235, 236], - wellId: 234, - chestPool: [19, 20, 24, 25], - scatterPool: [40, 63, 179], - }, - 5: { - waypointId: 12, - shrinePool: [152, 227, 231, 235, 236], - wellId: 234, - chestPool: [24, 25, 26, 27, 28, 29], - scatterPool: [13, 179, 4], - }, -} - -/** - * Validates outdoor population configuration against object-lookup metadata or an optional loaded Objects.txt table. - */ -export function validateActPopulationConfig( - config: ActPopulationConfig, - act: number, - objectsTable?: ObjectsTable | D2Table, -): { valid: boolean; totalChecked: number; unmapped: number[]; errors: string[] } { - const unmapped: number[] = [] - let totalChecked = 0 - const check = (id: number) => { - totalChecked += 1 - const entry = lookupObject(act, 2, id) - if (!entry) { - unmapped.push(id) - } else if (objectsTable) { - if ('byId' in objectsTable) { - if (entry.objectsTxtId >= 0 && !objectsTable.byId.has(entry.objectsTxtId)) { - unmapped.push(id) - } - } else if ('header' in objectsTable && 'rows' in objectsTable) { - const idCol = objectsTable.header.indexOf('Id') - const found = idCol !== -1 && objectsTable.rows.some(row => Number(row[idCol]) === entry.objectsTxtId) - if (entry.objectsTxtId >= 0 && !found) { - unmapped.push(id) - } - } - } - } - - check(config.waypointId) - check(config.wellId) - for (const id of config.shrinePool) check(id) - for (const id of config.chestPool) check(id) - for (const id of config.scatterPool) check(id) - - const errors = unmapped.map(id => `DS1 Type 2 object ID ${id} is unmapped or missing in Objects.txt`) - return { valid: unmapped.length === 0, totalChecked, unmapped, errors } -} - -function isTileClear( - canvas: Canvas, - tx: number, - ty: number, - placedRects: readonly PlacedPresetRect[], - padding = 1, -): boolean { - if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) { - return false - } - for (const r of placedRects) { - if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) { - return false - } - } - for (let dy = -padding; dy <= padding; dy += 1) { - const row = canvas.cells[ty + dy] - if (!row) return false - for (let dx = -padding; dx <= padding; dx += 1) { - const cell = row[tx + dx] - if (!cell) return false - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false - if (dx === 0 && dy === 0 && !cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false - } - } - return true -} - -function isNearDirtRoad( - dirtPathGrid: Uint8Array | undefined, - width: number, - height: number, - tx: number, - ty: number, - minDist: number, - maxDist: number, -): boolean { - if (!dirtPathGrid) return false - let minDistFound = 999 - for (let dy = -maxDist; dy <= maxDist; dy += 1) { - const ny = ty + dy - if (ny < 0 || ny >= height) continue - for (let dx = -maxDist; dx <= maxDist; dx += 1) { - const nx = tx + dx - if (nx < 0 || nx >= width) continue - if (dirtPathGrid[ny * width + nx] !== 0) { - const d = Math.max(Math.abs(dx), Math.abs(dy)) - if (d < minDistFound) minDistFound = d - } - } - } - return minDistFound >= minDist && minDistFound <= maxDist -} - -function validateWaypointLandingBuffer( - canvas: Canvas, - tx: number, - ty: number, - placedRects: readonly PlacedPresetRect[], - padding = 2, -): boolean { - if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) { - return false - } - - for (const r of placedRects) { - const isOpenPreset = /(clearing|camp|bivouac)/i.test(r.name) - if (!isOpenPreset) { - if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) { - return false - } - } - } - - for (let dy = -padding; dy <= padding; dy += 1) { - const row = canvas.cells[ty + dy] - if (!row) return false - for (let dx = -padding; dx <= padding; dx += 1) { - const cell = row[tx + dx] - if (!cell) return false - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false - if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) return false - } - } - - for (const obj of canvas.objects) { - const ox = Math.floor(obj.x / SUB_TILES_PER_TILE) - const oy = Math.floor(obj.y / SUB_TILES_PER_TILE) - if (Math.abs(ox - tx) <= padding && Math.abs(oy - ty) <= padding) { - return false - } - } - - return true -} - -/** - * 1.13c DRLGOUTDOORS cell validity mask for waypoint spawning (D2Common.dll ≈0x6FD92700). - * Cell flags tested against 0x1B81: (flags & 0x1B81) === 0 indicates cell is valid. - */ -export const DRLG_GRID_SPAWN_INVALID_MASK = 0x1b81 - -/** Flag set on pGrid[1] when a general outdoor waypoint is placed (0x10000). */ -export const DRLG_GRID_FLAG_WAYPOINT = 0x10000 - -/** Flag set on pGrid[1] when Cold Plains special waypoint is placed (0x20000). */ -export const DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS = 0x20000 - -/** - * Rolls a limited random number in [0, max - 1] matching 1.13c SEED_RollLimitedRandomNumber. - * Each call consumes exactly 1 RNG roll. - */ -export function rollLimited(rng: Rng, max: number): number { - if (max <= 0) return 0 - return Math.floor(rng.next() * max) -} - -/** - * Tests if an 8x8 cell grid block is valid for spawning an outdoor waypoint - * matching 1.13c DRLGOUTDOORS inline test (D2Common.dll ≈0x6FD92700): - * `test [grid], 0x1b81` => `(flags & 0x1B81) === 0`. - * - * @param grid - Grid containing per-cell 32-bit flags (2D number array, 1D array/Uint32Array, or grid object). - * @param x - Grid block X coordinate (1..nGridWidth-2). - * @param y - Grid block Y coordinate (1..nGridHeight-2). - * @param width - Optional width for 1D grid layouts. - * @returns true if valid for spawn ((flags & 0x1B81) === 0), false otherwise. - */ -export function TestGridCellSpawnValid( - grid: { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] } | readonly (readonly number[])[] | Uint32Array | readonly number[], - x: number, - y: number, - width?: number, -): boolean { - let flags = 0 - if (Array.isArray(grid)) { - if (grid.length > 0 && Array.isArray(grid[0])) { - const row = (grid as readonly (readonly number[])[])[y] - if (!row || x < 0 || x >= row.length) return false - flags = row[x] ?? 0 - } else { - const w = width ?? 1 - if (x < 0 || y < 0) return false - const idx = y * w + x - flags = (grid as readonly number[])[idx] ?? 0 - } - } else if (grid instanceof Uint32Array) { - const w = width ?? 1 - if (x < 0 || y < 0) return false - const idx = y * w + x - flags = grid[idx] ?? 0 - } else if (grid && typeof grid === 'object' && ('flags' in grid || 'width' in grid)) { - const g = grid as { width?: number; height?: number; flags?: Uint32Array | readonly number[] | readonly (readonly number[])[] } - const w = g.width ?? width ?? 1 - const gFlags = g.flags - if (Array.isArray(gFlags)) { - if (gFlags.length > 0 && Array.isArray(gFlags[0])) { - const row = (gFlags as readonly (readonly number[])[])[y] - if (!row || x < 0 || x >= row.length) return false - flags = row[x] ?? 0 - } else { - if (x < 0 || y < 0) return false - flags = (gFlags as readonly number[])[y * w + x] ?? 0 - } - } else if (gFlags instanceof Uint32Array) { - if (x < 0 || y < 0) return false - flags = gFlags[y * w + x] ?? 0 - } - } - - // 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0 - return (flags & DRLG_GRID_SPAWN_INVALID_MASK) === 0 -} - -/** - * Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`) - * matching 1.13c disassembly (PE timestamp 0x4B95C439, D2Common.dll ≈0x6FD92700). - * - * Priority 1: Check if a preset already placed a Waypoint object. - * - * Branch A (Cold Plains special, levelId == 3, LEVEL_COLDPLAINS): - * - Find connection to LEVEL_BLOODMOOR (levelId 2). - * - Find the boundary grid cell linking to Blood Moor. - * - Boundary clamp: - * if (j == 0) j = 1; - * if (i == 0) i = 1; - * if (j == nGridWidth - 1) j -= 1; - * if (i == nGridHeight - 1) i -= 1; - * - Set flag 0x20000 on pGrid[1]. - * - Return immediately, consuming exactly 0 RNG rolls. - * - * Branch B (General path): - * - nWidth = nGridWidth - 2; nArea = nWidth * (nGridHeight - 2); - * - Build coordinate array: pCoord[i] = (i % nWidth, Math.floor(i / nWidth)) - * - Shuffle: exactly nArea rounds, each round 2 rolls: - * r1 = rollLimited(rng, nArea) - * r2 = rollLimited(rng, nArea) - * swap(pCoord[r1], pCoord[r2]) - * (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle! - * Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.) - * - Iterate through shuffled pCoord: - * nX = pCoord[i].x + 1; nY = pCoord[i].y + 1; - * if (TestGridCellSpawnValid(grid, nX, nY)): - * Place waypoint here (flag 0x10000); - * break; - */ -export function spawnOutdoorWaypoint( - canvas: Canvas, - levelId: number, - act: number, - dirtPathGrid: Uint8Array | undefined, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - stats: WildernessStats, - levelTypeName?: string, - levelsTable?: D2Table, - seamConstraints?: readonly SeamConstraint[], -): void { - if (!hasOutdoorWaypoint(levelId, levelsTable)) return - - const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable) - - // Priority 1: Check if a preset already placed a Waypoint object - const existingWp = canvas.objects.find( - o => isWaypointDs1Object(effectiveAct, o.type, o.id), - ) - if (existingWp !== undefined) { - stats.waypointsSpawned = 1 - stats.waypointTile = { - x: Math.floor(existingWp.x / SUB_TILES_PER_TILE), - y: Math.floor(existingWp.y / SUB_TILES_PER_TILE), - } - return - } - - const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]! - const wpId = config.waypointId - - const nGridWidth = Math.floor(canvas.width / TILES_PER_BLOCK) - const nGridHeight = Math.floor(canvas.height / TILES_PER_BLOCK) - if (nGridWidth < 3 || nGridHeight < 3) return - - // Allocate 8x8 cell grid (pGrid[1] flags) - const grid: number[][] = Array.from({ length: nGridHeight }, () => new Array(nGridWidth).fill(0)) - - // Mark outer perimeter border blocks with 0x0080 (border flag, part of 0x1B81) - for (let x = 0; x < nGridWidth; x += 1) { - grid[0]![x] |= 0x0080 - grid[nGridHeight - 1]![x] |= 0x0080 - } - for (let y = 0; y < nGridHeight; y += 1) { - grid[y]![0] |= 0x0080 - grid[y]![nGridWidth - 1] |= 0x0080 - } - - // Mark blocks overlapping placed preset rectangles with 0x0001 (occupied by preset) - for (const rect of placedRects) { - const bx0 = Math.max(0, Math.floor(rect.x / TILES_PER_BLOCK)) - const by0 = Math.max(0, Math.floor(rect.y / TILES_PER_BLOCK)) - const bx1 = Math.min(nGridWidth - 1, Math.floor((rect.x + rect.w - 1) / TILES_PER_BLOCK)) - const by1 = Math.min(nGridHeight - 1, Math.floor((rect.y + rect.h - 1) / TILES_PER_BLOCK)) - for (let by = by0; by <= by1; by += 1) { - for (let bx = bx0; bx <= bx1; bx += 1) { - grid[by]![bx] |= 0x0001 - } - } - } - - // Mark blocks with walls or missing floors as 0x1000 (impassable/collision) - for (let by = 1; by < nGridHeight - 1; by += 1) { - for (let bx = 1; bx < nGridWidth - 1; bx += 1) { - let blocked = false - for (let dy = 0; dy < TILES_PER_BLOCK; dy += 1) { - const ty = by * TILES_PER_BLOCK + dy - const row = canvas.cells[ty] - if (!row) { - blocked = true - break - } - for (let dx = 0; dx < TILES_PER_BLOCK; dx += 1) { - const tx = bx * TILES_PER_BLOCK + dx - const cell = row[tx] - if (!cell) { - blocked = true - break - } - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) { - blocked = true - break - } - if (!cell.floors.some(f => !f.hidden && f.prop1 !== 0)) { - blocked = true - break - } - } - if (blocked) break - } - if (blocked) { - grid[by]![bx] |= 0x1000 - } - } - } - - // Mark blocks containing pre-existing objects as 0x0001 - for (const obj of canvas.objects) { - const obx = Math.floor(obj.x / (SUB_TILES_PER_TILE * TILES_PER_BLOCK)) - const oby = Math.floor(obj.y / (SUB_TILES_PER_TILE * TILES_PER_BLOCK)) - if (oby >= 0 && oby < nGridHeight && obx >= 0 && obx < nGridWidth) { - grid[oby]![obx] |= 0x0001 - } - } - - // Branch A: Cold Plains special (levelId == 3, LEVEL_COLDPLAINS) - if (levelId === 3) { - // 1. Find connection to LEVEL_BLOODMOOR (levelId 2). - // 2. Find the boundary grid cell linking to Blood Moor. - let j = 0 - let i = Math.floor(nGridHeight / 2) - - const gate = stats.gateOpenings?.find(g => g.toLevelId === 2) - if (gate !== undefined) { - if (gate.side === 'west') { - j = 0 - i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2)) - } else if (gate.side === 'east') { - j = nGridWidth - 1 - i = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2)) - } else if (gate.side === 'north') { - i = 0 - j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2)) - } else if (gate.side === 'south') { - i = nGridHeight - 1 - j = gate.blockCoord ?? (gate.tileCoord ? Math.floor(gate.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2)) - } - } else { - const sc = seamConstraints?.find(c => c.toLevelId === 2 || c.fromLevelId === 2) - if (sc !== undefined) { - if (sc.side === 'west') { - j = 0 - i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2)) - } else if (sc.side === 'east') { - j = nGridWidth - 1 - i = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.y / TILES_PER_BLOCK) : Math.floor(nGridHeight / 2)) - } else if (sc.side === 'north') { - i = 0 - j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2)) - } else if (sc.side === 'south') { - i = nGridHeight - 1 - j = sc.blockCoord ?? (sc.tileCoord ? Math.floor(sc.tileCoord.x / TILES_PER_BLOCK) : Math.floor(nGridWidth / 2)) - } - } - } - - // 3. Boundary clamp: - if (j === 0) j = 1 - if (i === 0) i = 1 - if (j === nGridWidth - 1) j -= 1 - if (i === nGridHeight - 1) i -= 1 - - j = Math.max(1, Math.min(nGridWidth - 2, j)) - i = Math.max(1, Math.min(nGridHeight - 2, i)) - - // 4. Set flag 0x20000 on pGrid[1] - grid[i]![j] |= DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS - - let chosenX = j * TILES_PER_BLOCK + 4 - let chosenY = i * TILES_PER_BLOCK + 4 - if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) { - let found = false - for (let dy = 2; dy <= 5; dy += 1) { - for (let dx = 2; dx <= 5; dx += 1) { - const tx = j * TILES_PER_BLOCK + dx - const ty = i * TILES_PER_BLOCK + dy - if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) { - chosenX = tx - chosenY = ty - found = true - break - } - } - if (found) break - } - } - - canvas.objects.push({ - type: 2, - id: wpId, - x: chosenX * SUB_TILES_PER_TILE + 2, - y: chosenY * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - stats.waypointsSpawned = 1 - stats.waypointTile = { x: chosenX, y: chosenY } - stats.notes.push( - `spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [Cold Plains special grid cell (${String(j)}, ${String(i)})]`, - ) - // 5. Return immediately! This consumes 0 RNG rolls. - return - } - - // Branch B: General path - const nWidth = nGridWidth - 2 - const nHeight = nGridHeight - 2 - const nArea = nWidth * nHeight - if (nArea <= 0) return - - // Build coordinate array: for i in 0..nArea-1: pCoord[i] = (i % nWidth, Math.floor(i / nWidth)) - const pCoord: { x: number; y: number }[] = [] - for (let i = 0; i < nArea; i += 1) { - pCoord.push({ - x: i % nWidth, - y: Math.floor(i / nWidth), - }) - } - - // Shuffle: exactly nArea rounds, each round 2 rolls: - // r1 = rollLimited(rng, nArea) - // r2 = rollLimited(rng, nArea) - // swap(pCoord[r1], pCoord[r2]) - // (Notice: this is NOT Fisher-Yates, it is Blizzard's 2*nArea swap shuffle! - // Do not change it to Fisher-Yates, comment that it's intentional for 1.13c parity.) - for (let round = 0; round < nArea; round += 1) { - const r1 = rollLimited(rng, nArea) - const r2 = rollLimited(rng, nArea) - const temp = pCoord[r1]! - pCoord[r1] = pCoord[r2]! - pCoord[r2] = temp - } - - // Iterate through shuffled pCoord: - // nX = pCoord[i].x + 1; nY = pCoord[i].y + 1; - // if (TestGridCellSpawnValid(grid, nX, nY)): // 1.13c inline mask: test [grid], 0x1b81 => (flags & 0x1B81) === 0 - // Place waypoint here (flag 0x10000); - // break; - let placed = false - for (let i = 0; i < nArea; i += 1) { - const nX = pCoord[i]!.x + 1 - const nY = pCoord[i]!.y + 1 - if (TestGridCellSpawnValid(grid, nX, nY)) { - grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT - - let chosenX = nX * TILES_PER_BLOCK + 4 - let chosenY = nY * TILES_PER_BLOCK + 4 - if (!validateWaypointLandingBuffer(canvas, chosenX, chosenY, placedRects, 2)) { - let found = false - for (let dy = 2; dy <= 5; dy += 1) { - for (let dx = 2; dx <= 5; dx += 1) { - const tx = nX * TILES_PER_BLOCK + dx - const ty = nY * TILES_PER_BLOCK + dy - if (validateWaypointLandingBuffer(canvas, tx, ty, placedRects, 1)) { - chosenX = tx - chosenY = ty - found = true - break - } - } - if (found) break - } - } - - canvas.objects.push({ - type: 2, - id: wpId, - x: chosenX * SUB_TILES_PER_TILE + 2, - y: chosenY * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - stats.waypointsSpawned = 1 - stats.waypointTile = { x: chosenX, y: chosenY } - stats.notes.push( - `spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosenX)}, ${String(chosenY)}) [grid cell (${String(nX)}, ${String(nY)})]`, - ) - placed = true - break - } - } - - // Fallback if no cell passed TestGridCellSpawnValid - if (!placed && pCoord.length > 0) { - const fallbackCoord = pCoord[0]! - const nX = fallbackCoord.x + 1 - const nY = fallbackCoord.y + 1 - grid[nY]![nX] |= DRLG_GRID_FLAG_WAYPOINT - const chosenX = nX * TILES_PER_BLOCK + 4 - const chosenY = nY * TILES_PER_BLOCK + 4 - canvas.objects.push({ - type: 2, - id: wpId, - x: chosenX * SUB_TILES_PER_TILE + 2, - y: chosenY * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - stats.waypointsSpawned = 1 - stats.waypointTile = { x: chosenX, y: chosenY } - } -} - -/** - * Scatter outdoor shrines, healing wells, companion chests/scatter, and standalone interactives - * across Acts 1..5 (`DRLGOUTDOORS_SpawnAct12Shrines`). - * - * Implements: - * 1. Authentic thematic clustering: 60% chance for an accompanying companion object 2..4 tiles from each shrine. - * 2. Dispersed spatial distribution: Shrines spaced >= 10 tiles apart (relaxing to >= 7 if space constrained). - * 3. Act-specific shrine, well, chest, and scatter IDs. - * 4. Scaling based on `Levels.txt.SubShrine` / `LvlSub.txt` max clusters when provided. - */ -export function spawnOutdoorShrines( - canvas: Canvas, - levelId: number, - act: number, - dirtPathGrid: Uint8Array | undefined, - placedRects: readonly PlacedPresetRect[], - rng: Rng, - stats: WildernessStats, - shrineSubstitutions?: readonly WildernessSubstitution[], - subTheme = 0, - levelTypeName?: string, - levelsTable?: D2Table, -): void { - if (levelId <= 0 || levelId === 108 || levelId === 110) return - - let isOutdoorLevel = - (levelId >= 2 && levelId <= 7) || levelId === 39 || - (levelId >= 41 && levelId <= 46) || - (levelId >= 76 && levelId <= 83) || - (levelId >= 104 && levelId <= 107) || - (levelId >= 111 && levelId <= 112) || levelId === 117 || - (levelTypeName !== undefined && /wilderness|desert|jungle|kurast|mesa|lava|siege|barricade/i.test(levelTypeName)) - - if (levelsTable) { - const row = levelsTable.rows.find(candidate => Number(cell(levelsTable, candidate, 'Id')) === levelId) - if (row && cell(levelsTable, row, 'DrlgType') === '3') { - const isTown = [1, 40, 75, 103, 109].includes(levelId) - if (!isTown) isOutdoorLevel = true - } - } - - if (!isOutdoorLevel) return - - const effectiveAct = actOfLevel(levelId, levelTypeName, act, levelsTable) - const config = ACT_POPULATION_CONFIG[effectiveAct] ?? ACT_POPULATION_CONFIG[1]! - - const isJungle = levelId >= 76 && levelId <= 78 - const marginX = isJungle ? 4 : Math.min(10, Math.floor(canvas.width / 5)) - const marginY = Math.min(10, Math.floor(canvas.height / 5)) - const candidates: { x: number; y: number }[] = [] - for (let y = marginY; y < canvas.height - marginY; y += 2) { - for (let x = marginX; x < canvas.width - marginX; x += 2) { - if (!isTileClear(canvas, x, y, placedRects, 1)) continue - candidates.push({ x, y }) - } - } - if (candidates.length === 0) { - for (let y = marginY; y < canvas.height - marginY; y += 1) { - for (let x = marginX; x < canvas.width - marginX; x += 1) { - if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y }) - } - } - } - if (candidates.length === 0) return - - const allPlacedObjects: { x: number; y: number }[] = canvas.objects.map(o => ({ - x: Math.floor(o.x / SUB_TILES_PER_TILE), - y: Math.floor(o.y / SUB_TILES_PER_TILE), - })) - const placedShrineTiles: { x: number; y: number }[] = [] - - let shrinesSpawned = 0 - let interactivesSpawned = 0 - - // 1. Determine target shrine count based on LvlSub / SubShrine max - let targetShrineCount = rng.int(1, 2) - if (shrineSubstitutions && shrineSubstitutions.length > 0) { - let maxFromSub = 0 - for (const sub of shrineSubstitutions) { - const m = sub.max[subTheme] ?? sub.max[0] ?? 0 - if (m > maxFromSub) maxFromSub = m - } - if (maxFromSub > 0) { - const cap = Math.min(4, maxFromSub) - targetShrineCount = rng.int(Math.min(2, cap), cap) - } - } - - // Secondary exploration paths: avoid the main road and its immediate shoulder (within 3 tiles) - const secondaryCandidates = dirtPathGrid !== undefined - ? candidates.filter(c => !isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, c.x, c.y, 0, 3)) - : candidates - - const pickShrineSpot = (candidateList: readonly { x: number; y: number }[], minDist: number): { x: number; y: number } | null => { - const indices = Array.from({ length: candidateList.length }, (_, i) => i) - for (let i = indices.length - 1; i > 0; i -= 1) { - const j = rng.int(0, i) - const tmp = indices[i]! - indices[i] = indices[j]! - indices[j] = tmp - } - for (const idx of indices) { - const spot = candidateList[idx]! - const tooCloseToShrine = placedShrineTiles.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist) - if (tooCloseToShrine) continue - const tooCloseToObject = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4) - if (tooCloseToObject) continue - return spot - } - return null - } - - // 2. Spawn Shrines with Dispersed Distribution along secondary exploration paths (>= 10 tiles, relaxing to >= 7 if constrained) - for (let i = 0; i < targetShrineCount; i += 1) { - const spot = (secondaryCandidates.length > 0 - ? (pickShrineSpot(secondaryCandidates, 10) ?? pickShrineSpot(secondaryCandidates, 7)) - : null) ?? (pickShrineSpot(candidates, 10) ?? pickShrineSpot(candidates, 7)) - if (!spot) break - - const shrineId = config.shrinePool[rng.int(0, config.shrinePool.length - 1)]! - canvas.objects.push({ - type: 2, - id: shrineId, - x: spot.x * SUB_TILES_PER_TILE + 2, - y: spot.y * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - placedShrineTiles.push(spot) - allPlacedObjects.push(spot) - shrinesSpawned += 1 - - // 3. Thematic Clustering: 60% chance to spawn an accompanying companion object 2..4 tiles away - if (rng.int(0, 99) < 60) { - const isChest = rng.int(0, 99) < 40 - const pool = isChest ? config.chestPool : config.scatterPool - const companionId = pool[rng.int(0, pool.length - 1)]! - - const companionOffsets: { dx: number; dy: number }[] = [] - for (let dy = -4; dy <= 4; dy += 1) { - for (let dx = -4; dx <= 4; dx += 1) { - const d = Math.hypot(dx, dy) - if (d >= 2 && d <= 4) { - companionOffsets.push({ dx, dy }) - } - } - } - for (let oi = companionOffsets.length - 1; oi > 0; oi -= 1) { - const oj = rng.int(0, oi) - const tmp = companionOffsets[oi]! - companionOffsets[oi] = companionOffsets[oj]! - companionOffsets[oj] = tmp - } - - for (const off of companionOffsets) { - const cx = spot.x + off.dx - const cy = spot.y + off.dy - if (isTileClear(canvas, cx, cy, placedRects, 1)) { - const occupied = allPlacedObjects.some(p => p.x === cx && p.y === cy) - if (!occupied) { - canvas.objects.push({ - type: 2, - id: companionId, - x: cx * SUB_TILES_PER_TILE + 2, - y: cy * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - allPlacedObjects.push({ x: cx, y: cy }) - interactivesSpawned += 1 - break - } - } - } - } - } - - // 4. Healing Well (1 well placed with >= 8 tiles distance from shrines) - const pickWellSpot = (): { x: number; y: number } | null => { - const indices = Array.from({ length: candidates.length }, (_, i) => i) - for (let i = indices.length - 1; i > 0; i -= 1) { - const j = rng.int(0, i) - const tmp = indices[i]! - indices[i] = indices[j]! - indices[j] = tmp - } - for (const idx of indices) { - const spot = candidates[idx]! - const farFromShrines = placedShrineTiles.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 8) - if (!farFromShrines) continue - const farFromObjects = allPlacedObjects.every(p => Math.hypot(p.x - spot.x, p.y - spot.y) >= 4) - if (!farFromObjects) continue - return spot - } - return null - } - - const wellSpot = pickWellSpot() - if (wellSpot) { - canvas.objects.push({ - type: 2, - id: config.wellId, - x: wellSpot.x * SUB_TILES_PER_TILE + 2, - y: wellSpot.y * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - allPlacedObjects.push(wellSpot) - shrinesSpawned += 1 - } - - // 5. Standalone Chests (1..2) - const pickStandaloneSpot = (minDist: number): { x: number; y: number } | null => { - for (let attempt = 0; attempt < 24; attempt += 1) { - const spot = candidates[rng.int(0, candidates.length - 1)]! - const tooClose = allPlacedObjects.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < minDist) - if (!tooClose) { - allPlacedObjects.push(spot) - return spot - } - } - return null - } - - const chestCount = rng.int(1, 2) - for (let i = 0; i < chestCount; i += 1) { - const spot = pickStandaloneSpot(4) - if (!spot) break - const chestId = config.chestPool[rng.int(0, config.chestPool.length - 1)]! - canvas.objects.push({ - type: 2, - id: chestId, - x: spot.x * SUB_TILES_PER_TILE + 2, - y: spot.y * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - interactivesSpawned += 1 - } - - // 6. Standalone Scatter: Barrels / Campfires / Stash Rocks (2..3) - const scatterCount = rng.int(2, 3) - for (let i = 0; i < scatterCount; i += 1) { - const spot = pickStandaloneSpot(4) - if (!spot) break - const scatterId = config.scatterPool[rng.int(0, config.scatterPool.length - 1)]! - canvas.objects.push({ - type: 2, - id: scatterId, - x: spot.x * SUB_TILES_PER_TILE + 2, - y: spot.y * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - interactivesSpawned += 1 - } - - stats.shrinesSpawned = shrinesSpawned - stats.interactivesSpawned = interactivesSpawned -} - -export const DRLGOUTDOORS_SpawnAct12Waypoint = spawnOutdoorWaypoint -export const spawnAct12Waypoint = spawnOutdoorWaypoint -export const DRLGOUTDOORS_SpawnAct12Shrines = spawnOutdoorShrines -export const spawnAct12Shrines = spawnOutdoorShrines - -/** Specification of a landmark entity definition for data-driven spawning. */ -export interface LandmarkEntityDef { - readonly id: string - readonly name: string - readonly role: 'superunique' | 'npc_guard' - readonly act: number - readonly levelIds: readonly number[] - readonly monsterId: string - readonly presetMatch?: RegExp - readonly defaultLandmark: string - readonly modifiers: readonly string[] - readonly minionCount: readonly [number, number] - readonly minionMonsterId: string - readonly objectId: number - readonly defaultRelativePos?: { readonly x: number; readonly y: number } - readonly resolvePosition?: ( - canvas: Canvas, - levelId: number, - act1Plan: Act1OutdoorPlan | undefined, - placedRects: readonly PlacedPresetRect[], - gridWidth: number, - gridHeight: number, - ) => { tileX: number; tileY: number; landmark: string } | null -} - -/** - * Canonical landmark SuperUniques and special NPCs across Acts 2-5 (Act I outdoor levels come - * from the DRLG port, src/game/drlg, whose preset units place them). - */ -export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [ - // Act 2 - { - id: 'Creeping Feature', - name: 'Creeping Feature', - role: 'superunique', - act: 2, - levelIds: [41, 55, 59], - monsterId: 'mummy1', - presetMatch: /Desert Tomb 1|Stony Tomb/i, - defaultLandmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minionCount: [4, 6], - minionMonsterId: 'mummy1', - objectId: 75, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - { - id: 'Bloodwitch the Wild', - name: 'Bloodwitch the Wild', - role: 'superunique', - act: 2, - levelIds: [45, 58, 60], - monsterId: 'pantherwoman1', - presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i, - defaultLandmark: 'Act 2 - Desert Tomb 2', - modifiers: ['cursed', 'strong'], - minionCount: [4, 6], - minionMonsterId: 'pantherwoman1', - objectId: 76, - defaultRelativePos: { x: 0.48, y: 0.52 }, - }, - { - id: 'Beetleburst', - name: 'Beetleburst', - role: 'superunique', - act: 2, - levelIds: [43], - monsterId: 'scarab2', - presetMatch: /Desert Oasis|Desert Lair/i, - defaultLandmark: 'Act 2 - Desert Oasis 1', - modifiers: ['magicresistant'], - minionCount: [4, 6], - minionMonsterId: 'scarab2', - objectId: 77, - defaultRelativePos: { x: 0.52, y: 0.48 }, - }, - { - id: 'Dark Elder', - name: 'Dark Elder', - role: 'superunique', - act: 2, - levelIds: [44], - monsterId: 'zombie5', - presetMatch: /Desert Ruins Elder/i, - defaultLandmark: 'Act 2 - Desert Ruins Elder', - modifiers: ['fast', 'magicresistant'], - minionCount: [4, 6], - minionMonsterId: 'zombie5', - objectId: 78, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - { - id: 'Fangskin', - name: 'Fangskin', - role: 'superunique', - act: 2, - levelIds: [45, 61], - monsterId: 'clawviper3', - presetMatch: /Desert Valley Ruin 2|Valley Warp|Claw Viper Altar/i, - defaultLandmark: 'Act 2 - Desert Valley Ruin 2', - modifiers: ['lightenchant', 'fast'], - minionCount: [4, 6], - minionMonsterId: 'clawviper3', - objectId: 79, - defaultRelativePos: { x: 0.55, y: 0.45 }, - }, - // Act 3 - { - id: 'Stormtree', - name: 'Stormtree', - role: 'superunique', - act: 3, - levelIds: [78], - monsterId: 'thornhulk3', - presetMatch: /Flayer Jungle Entrance|Jungle Gate|Flayer Entrance/i, - defaultLandmark: 'Act 3 - Flayer Jungle Entrance', - modifiers: ['lightenchant', 'fast'], - minionCount: [4, 6], - minionMonsterId: 'thornhulk3', - objectId: 80, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - { - id: 'Sszark the Burning', - name: 'Sszark the Burning', - role: 'superunique', - act: 3, - levelIds: [76, 84], - monsterId: 'arach4', - presetMatch: /Spider Cavern|Spider/i, - defaultLandmark: 'Act 3 - Spider Cavern', - modifiers: ['strong', 'cursed'], - minionCount: [4, 6], - minionMonsterId: 'arach4', - objectId: 81, - defaultRelativePos: { x: 0.48, y: 0.52 }, - }, - { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - role: 'superunique', - act: 3, - levelIds: [78, 88, 91], - monsterId: 'fetishshaman4', - presetMatch: /Flayer Dungeon/i, - defaultLandmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minionCount: [4, 6], - minionMonsterId: 'fetish4', - objectId: 82, - defaultRelativePos: { x: 0.52, y: 0.48 }, - }, - { - id: 'Battlemaid Sarina', - name: 'Battlemaid Sarina', - role: 'superunique', - act: 3, - levelIds: [80, 94], - monsterId: 'corruptrogue5', - presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i, - defaultLandmark: 'Act 3 - Burbs Temple', - modifiers: ['fast', 'spectralhit'], - minionCount: [4, 6], - minionMonsterId: 'corruptrogue5', - objectId: 83, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - // Act 4 - { - id: 'Izual', - name: 'Izual', - role: 'superunique', - act: 4, - levelIds: [105], - monsterId: 'izual', - presetMatch: /Plains of Despair|Izual/i, - defaultLandmark: 'Act 4 - Plains of Despair', - modifiers: ['coldenchant'], - minionCount: [0, 0], - minionMonsterId: 'izual', - objectId: 84, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - { - id: 'Hephasto the Armorer', - name: 'Hephasto the Armorer', - role: 'superunique', - act: 4, - levelIds: [106, 107], - monsterId: 'hephasto', - presetMatch: /Hellforge/i, - defaultLandmark: 'Act 4 - Hellforge', - modifiers: ['spectralhit', 'aura'], - minionCount: [0, 0], - minionMonsterId: 'hephasto', - objectId: 85, - defaultRelativePos: { x: 0.52, y: 0.48 }, - }, - { - id: 'Hadriel', - name: 'Hadriel', - role: 'npc_guard', - act: 4, - levelIds: [106, 107, 108], - monsterId: 'hadriel', - presetMatch: /Chaos Sanctuary Entrance|Hadriel/i, - defaultLandmark: 'Act 4 - Chaos Sanctuary Entrance', - modifiers: [], - minionCount: [0, 0], - minionMonsterId: '', - objectId: 86, - defaultRelativePos: { x: 0.48, y: 0.52 }, - }, - // Act 5 - { - id: 'Dac Farren', - name: 'Dac Farren', - role: 'superunique', - act: 5, - levelIds: [110], - monsterId: 'imp3', - presetMatch: /Barricade Prison 1|Barricade Ruins/i, - defaultLandmark: 'Act 5 - Barricade Prison 1', - modifiers: ['coldenchant'], - minionCount: [4, 6], - minionMonsterId: 'imp3', - objectId: 87, - defaultRelativePos: { x: 0.35, y: 0.5 }, - }, - { - id: 'Shenk the Overseer', - name: 'Shenk the Overseer', - role: 'superunique', - act: 5, - levelIds: [110, 111], - monsterId: 'overseer2', - presetMatch: /Barricade Building|Shenk|Siege Strip 15|Siege To Barricade/i, - defaultLandmark: 'Act 5 - Barricade Building', - modifiers: ['strong'], - minionCount: [15, 20], - minionMonsterId: 'minion1', - objectId: 88, - defaultRelativePos: { x: 0.75, y: 0.5 }, - }, - { - id: 'Eyeback the Unleashed', - name: 'Eyeback the Unleashed', - role: 'superunique', - act: 5, - levelIds: [111], - monsterId: 'bloodlord2', - presetMatch: /Barricade Hell Portal N|Barricade 1/i, - defaultLandmark: 'Act 5 - Barricade Hell Portal N', - modifiers: ['fast', 'strong'], - minionCount: [3, 5], - minionMonsterId: 'bloodlord2', - objectId: 89, - defaultRelativePos: { x: 0.45, y: 0.55 }, - }, - { - id: 'Thresh Socket', - name: 'Thresh Socket', - role: 'superunique', - act: 5, - levelIds: [112], - monsterId: 'siegebeast1', - presetMatch: /Barricade Snow Unique/i, - defaultLandmark: 'Act 5 - Barricade Snow Unique', - modifiers: ['cursed'], - minionCount: [2, 4], - minionMonsterId: 'siegebeast1', - objectId: 90, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - { - id: 'Bonesaw Breaker', - name: 'Bonesaw Breaker', - role: 'superunique', - act: 5, - levelIds: [115], - monsterId: 'reanimatedhorde2', - presetMatch: /Glacial Trail|Drifter Cavern/i, - defaultLandmark: 'Act 5 - Glacial Trail', - modifiers: ['strong', 'magicresistant'], - minionCount: [4, 6], - minionMonsterId: 'reanimatedhorde2', - objectId: 91, - defaultRelativePos: { x: 0.52, y: 0.48 }, - }, - { - id: 'Frozenstein', - name: 'Frozenstein', - role: 'superunique', - act: 5, - levelIds: [114], - monsterId: 'snowyeti4', - presetMatch: /Frozen River|Anya/i, - defaultLandmark: 'Act 5 - Frozen River', - modifiers: ['coldenchant', 'manahit'], - minionCount: [4, 6], - minionMonsterId: 'snowyeti4', - objectId: 92, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - { - id: 'Nihlathak', - name: 'Nihlathak', - role: 'superunique', - act: 5, - levelIds: [124], - monsterId: 'nihlathakboss', - presetMatch: /Halls of Vaught|Nihlathak/i, - defaultLandmark: 'Act 5 - Halls of Vaught', - modifiers: ['coldenchant'], - minionCount: [6, 8], - minionMonsterId: 'minion6', - objectId: 93, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, -] - -/** - * Refactor landmark and outdoor boss generation into a table/data-driven pipeline - * based on `SuperUniques.txt` and `MonPreset.txt`. - */ -export function buildLandmarkEntitiesFromTables( - superuniques?: D2Table, - monpreset?: D2Table, - baseDefs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES, -): LandmarkEntityDef[] { - if (!superuniques && !monpreset) { - return [...baseDefs] - } - - const suMap = new Map() - if (superuniques) { - const suList = readSuperUniques(superuniques) - for (const su of suList) { - suMap.set(su.id.toLowerCase(), su) - if (su.nameKey) { - suMap.set(su.nameKey.toLowerCase(), su) - } - } - } - - const actPlaceIndex = new Map() - if (monpreset) { - const actIdx = monpreset.header.findIndex(h => /^act$/i.test(h.trim())) - const placeIdx = monpreset.header.findIndex(h => /^place$/i.test(h.trim())) - const objectIdIdx = monpreset.header.findIndex(h => /^(objectid|id|object)$/i.test(h.trim())) - if (actIdx !== -1 && placeIdx !== -1) { - const perActCount = new Map() - for (const row of monpreset.rows) { - const actVal = Number(row[actIdx]) - const placeVal = row[placeIdx]?.trim() - if (Number.isFinite(actVal) && placeVal) { - const currentIdx = perActCount.get(actVal) ?? 0 - perActCount.set(actVal, currentIdx + 1) - const customId = objectIdIdx !== -1 ? Number(row[objectIdIdx]) : currentIdx - actPlaceIndex.set( - `${actVal}:${placeVal.toLowerCase()}`, - Number.isFinite(customId) ? customId : currentIdx, - ) - } - } - } - } - - return baseDefs.map(def => { - let monsterId = def.monsterId - let name = def.name - let modifiers = def.modifiers - let minionCount = def.minionCount - let minionMonsterId = def.minionMonsterId - let objectId = def.objectId - - if (superuniques) { - const su = suMap.get(def.id.toLowerCase()) ?? suMap.get(def.name.toLowerCase()) - if (su) { - if (su.monsterId) { - monsterId = su.monsterId - if (!minionMonsterId) { - minionMonsterId = su.monsterId - } - } - if (su.nameKey && su.nameKey.trim() !== '') { - name = su.nameKey.trim() - } - if (su.modifiers.length > 0) { - const mappedMods: string[] = [] - for (const modId of su.modifiers) { - const found = CANONICAL_ELITE_MODIFIERS.find(m => m.id === modId) - if (found) mappedMods.push(found.name) - } - if (mappedMods.length > 0) { - modifiers = mappedMods - } - } - minionCount = [su.minMinions, su.maxMinions] - } - } - - if (monpreset) { - const candidates = [ - def.id.toLowerCase(), - def.name.toLowerCase(), - def.monsterId.toLowerCase(), - def.defaultLandmark.toLowerCase(), - ] - for (const [key, id] of actPlaceIndex) { - const [actStr, placeName] = key.split(':') - if (Number(actStr) === def.act) { - if ( - candidates.some(c => c.includes(placeName) || placeName.includes(c)) || - (def.presetMatch && def.presetMatch.test(placeName)) - ) { - objectId = id - break - } - } - } - } - - return { - ...def, - monsterId, - name, - modifiers, - minionCount, - minionMonsterId, - objectId, - } - }) -} - -/** - * Anchor outdoor SuperUniques and special NPCs to their landmark presets or border gates. - */ -export function spawnLandmarkEntities( - canvas: Canvas, - levelId: number, - act1Plan: Act1OutdoorPlan | undefined, - placedRects: readonly PlacedPresetRect[], - gridWidth: number, - gridHeight: number, - stats: WildernessStats, - defs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES, -): void { - const entities: LandmarkEntity[] = [] - - for (const def of defs) { - let tileX: number - let tileY: number - let landmark: string - - if (def.resolvePosition) { - const resolved = def.resolvePosition(canvas, levelId, act1Plan, placedRects, gridWidth, gridHeight) - if (!resolved) continue - tileX = resolved.tileX - tileY = resolved.tileY - landmark = resolved.landmark - } else { - const rect = def.presetMatch ? placedRects.find(r => def.presetMatch!.test(r.name)) : undefined - if (rect !== undefined) { - tileX = Math.floor(rect.x + rect.w / 2) - tileY = Math.floor(rect.y + rect.h / 2) - landmark = rect.name - } else if (def.levelIds.includes(levelId)) { - tileX = Math.floor(canvas.width * (def.defaultRelativePos?.x ?? 0.5)) - tileY = Math.floor(canvas.height * (def.defaultRelativePos?.y ?? 0.5)) - landmark = def.defaultLandmark - } else { - continue - } - } - - const subtileX = tileX * SUB_TILES_PER_TILE + 2 - const subtileY = tileY * SUB_TILES_PER_TILE + 2 - - entities.push({ - role: def.role, - id: def.id, - monsterId: def.monsterId, - name: def.name, - landmark, - tileX, - tileY, - subtileX, - subtileY, - levelId, - modifiers: def.modifiers, - minionCount: def.minionCount, - minionMonsterId: def.minionMonsterId, - objectId: def.objectId, - }) - - canvas.objects.push({ - type: 1, - id: def.objectId, - x: subtileX, - y: subtileY, - flags: 0, - }) - } - - stats.landmarkEntities = entities -} - -export function generateWilderness(request: WildernessRequest): WildernessResult { - const where = `level ${String(request.levelId)} (${request.levelName})` - // Act I's outdoor levels come from the 1:1 port of D2's DRLG (src/game/drlg, baked by the 'drlg' - // jobs of scripts/pack-act-assets.ts). This generator no longer builds any of them. - if (request.levelId >= 1 && request.levelId < 1024 && DRLG_GetActNoFromLevelId(request.levelId) === 0) { - throw new Error(`${where}: Act I outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness`) - } - if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`) - const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed - - let sizeX = request.sizeX - let sizeY = request.sizeY - let sizeSource = 'Levels.txt' - if (!Number.isFinite(sizeX) || !Number.isFinite(sizeY) || sizeX <= 0 || sizeY <= 0) { - const resolved = resolveUnsetSize(request) - sizeX = resolved.sizeX - sizeY = resolved.sizeY - sizeSource = resolved.source - } - - const gridWidth = Math.floor(sizeX / TILES_PER_BLOCK) - const gridHeight = Math.floor(sizeY / TILES_PER_BLOCK) - - let groundTiles: WeightedGroundTile[] | null = null - - const isSnowBarricade = request.subType === 11 || /snow|tundra/i.test(request.levelName) - const effectiveMask = wildernessDt1Mask(request.levelTypeName, request.subType, request.levelName) - - if (request.groundTiles && request.groundTiles.length > 0) { - groundTiles = [...request.groundTiles] - } else if (request.groundTile) { - if (isTileArray(request.groundTile)) { - groundTiles = [...(request.groundTile as readonly WeightedGroundTile[])] - } else { - const single = request.groundTile as { readonly style: number; readonly sequence: number } - groundTiles = [{ style: single.style, sequence: single.sequence, weight: 1 }] - } - } else if (request.dt1Libraries && request.dt1Libraries.length > 0) { - const fromDt1 = deriveGroundTilesFromDt1Libraries(request.dt1Libraries, effectiveMask) - if (fromDt1.length > 0) groundTiles = fromDt1 - } - - if (groundTiles === null || groundTiles.length === 0) { - const isSnow = request.levelTypeName === 'Act 5 - Barricade' && isSnowBarricade - const poolKey = isSnow ? 'Act 5 - Barricade Snow' : request.levelTypeName - const canonicalPool = CANONICAL_GROUND_POOLS[poolKey] ?? CANONICAL_GROUND_POOLS[request.levelTypeName] - if (canonicalPool && canonicalPool.length > 0) { - groundTiles = [...canonicalPool] - } else { - const canonical = isSnow - ? (CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'] ?? { style: 6, sequence: 0 }) - : CANONICAL_GROUND_TILES[request.levelTypeName] - if (canonical !== undefined) { - groundTiles = [{ style: canonical.style, sequence: canonical.sequence, weight: 1 }] - } else { - const fromPieces = deriveGroundTiles(request.pieces) - if (fromPieces.length > 0) groundTiles = fromPieces - } - } - } - - const primaryGroundTile = groundTiles && groundTiles.length > 0 - ? { style: groundTiles[0]!.style, sequence: groundTiles[0]!.sequence } - : null - const groundTile = primaryGroundTile - - if ((gridWidth < 3 || gridHeight < 3) && request.levelId !== 82) { - const bridgePiece = request.pieces.find(p => p.levels.some(l => l.width >= gridWidth * TILES_PER_BLOCK && l.height >= gridHeight * TILES_PER_BLOCK)) ?? request.pieces[0] - if (bridgePiece && bridgePiece.levels[0]) { - const bridgeDs1 = bridgePiece.levels[0] - const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, bridgeDs1.wallLayers, bridgeDs1.floorLayers, bridgeDs1.substitutionType === 1 || bridgeDs1.substitutionType === 2 ? 1 : 0) - if (groundTiles !== null) fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed }) - stampDs1(canvas, bridgeDs1, 0, 0) - return { - level: { - version: bridgeDs1.version, - width: canvas.width, - height: canvas.height, - act: bridgeDs1.act, - substitutionType: bridgeDs1.substitutionType, - wallLayers: canvas.wallLayers, - floorLayers: canvas.floorLayers, - cells: canvas.cells, - objects: canvas.objects, - npcPathOffset: null, - }, - stats: { - levelId: request.levelId, - levelName: request.levelName, - levelTypeName: request.levelTypeName, - seed: request.seed, - sizeX, - sizeY, - sizeSource, - tilesPerBlock: TILES_PER_BLOCK, - blockGrid: { width: gridWidth, height: gridHeight }, - dt1Mask: effectiveMask, - subType: request.subType, - subTheme: Math.max(0, Math.min(4, Math.floor(request.subTheme))), - groundTile: primaryGroundTile, - groundTiles: groundTiles ?? undefined, - groundCells: canvas.width * canvas.height, - borderPiecesAvailable: 0, - borderBlocks: 0, - borderUsage: {}, - roadCells: 0, - roadSegments: 0, - anchors: 0, - specialPresets: [], - entrances: [], - gateOpenings: [], - proceduralRoadCells: 0, - proceduralDividerCells: 0, - proceduralWaterCells: 0, - proceduralWallCells: 0, - substitutions: [], - substitutedClusters: 0, - objects: canvas.objects.length, - cells: canvas.width * canvas.height, - unresolved: [], - unimplementedPasses: UNIMPLEMENTED_PASSES, - notes: ['stamped full connector piece for narrow level'], - }, - } - } - throw new Error(`${where}: ${String(sizeX)}x${String(sizeY)} cells is only ${String(gridWidth)}x${String(gridHeight)} blocks, too small for a bordered level`) - } - - const stats: WildernessStats = { - substitutions: [], borderPieces: {}, unresolved: [], notes: [], - borderStamped: 0, groundCells: 0, groundTile: null, groundTiles: undefined, sizeSource, - roadCells: 0, roadSegments: 0, anchors: 0, specialPresets: [], entrances: [], - proceduralRoadCells: 0, proceduralDividerCells: 0, proceduralWaterCells: 0, proceduralWallCells: 0, - gateOpenings: [], - } - - let wallLayers = 1 - let floorLayers = 1 - let substitutionType = 0 - let version = 0 - const inferredAct = actOfLevel(request.levelId, request.levelTypeName, undefined, request.levelsTable) - let act = inferredAct - const allLevels: Ds1[] = [] - for (const piece of request.pieces) for (const level of piece.levels) allLevels.push(level) - for (const row of [...request.substitutions, ...(request.shrineSubstitutions ?? [])]) { - for (const level of row.levels) allLevels.push(level) - } - for (const level of allLevels) { - wallLayers = Math.max(wallLayers, level.wallLayers) - floorLayers = Math.max(floorLayers, level.floorLayers) - substitutionType = Math.max(substitutionType, level.substitutionType) - version = Math.max(version, level.version) - if (level.act > 0) act = level.act - } - if (act === 1 && inferredAct > 1) { - act = inferredAct - } - const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0 - - const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers) - const rng = new Rng(request.seed) - const landmarkDefs = buildLandmarkEntitiesFromTables( - request.superuniques, - request.monpreset, - request.customLandmarks ?? CANONICAL_LANDMARK_ENTITIES, - ) - - stats.groundTile = primaryGroundTile - stats.groundTiles = groundTiles ?? undefined - if (primaryGroundTile === null || groundTiles === null || groundTiles.length === 0) { - throw new Error(`${where}: no piece carries a floor tile to use as ground`) - } - stats.groundCells = fillGround(canvas, groundTiles, 0, 0, canvas.width, canvas.height, { seed: request.seed }) - - // Special case: Act 5 Bloody Foothills (110), - // Act 3 Travincal (83), Act 3 Kurast Causeway (82), and Act 3 Jungles (76, 77, 78) are assembled from - // deterministic multi-preset layouts rather than rectangular perimeter borders. - if ( - request.levelId === 110 || - request.levelTypeName === 'Act 5 - Siege' || - request.levelId === 83 || - request.levelId === 82 || - (request.levelId >= 76 && request.levelId <= 78) - ) { - const specialPlacedRects: PlacedPresetRect[] = [] - let placedSpecial: Set - if (request.levelId === 83) { - placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects) - } else if (request.levelId === 82) { - placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, rng, stats, specialPlacedRects) - } else if (request.levelId >= 76 && request.levelId <= 78) { - placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects) - const populationRng = new Rng(request.seed ^ 0x6c07) - spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName, request.levelsTable, request.seamConstraints) - spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName, request.levelsTable) - } else { - placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats) - } - spawnLandmarkEntities( - canvas, - request.levelId, - undefined, - specialPlacedRects, - gridWidth, - gridHeight, - stats, - landmarkDefs, - ) - - let finalGroundCells = 0 - for (let y = 0; y < canvas.height; y += 1) { - const row = canvas.cells[y] - if (row === undefined) continue - for (let x = 0; x < canvas.width; x += 1) { - const c = row[x] - if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) finalGroundCells += 1 - } - } - stats.groundCells = finalGroundCells - return { - level: { - version: version === 0 ? 18 : version, - width: canvas.width, - height: canvas.height, - act, - substitutionType, - wallLayers: canvas.wallLayers, - floorLayers: canvas.floorLayers, - cells: canvas.cells, - objects: canvas.objects, - npcPathOffset: null, - }, - stats: { - levelId: request.levelId, - levelName: request.levelName, - levelTypeName: request.levelTypeName, - seed: request.seed, - sizeX, - sizeY, - sizeSource, - tilesPerBlock: TILES_PER_BLOCK, - blockGrid: { width: gridWidth, height: gridHeight }, - dt1Mask: effectiveMask, - subType: request.subType, - // 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, - borderPiecesAvailable: 0, - borderBlocks: 0, - borderUsage: {}, - roadCells: stats.roadCells, - roadSegments: stats.roadSegments, - anchors: stats.anchors, - entrances: stats.entrances, - specialPresets: stats.specialPresets, - proceduralRoadCells: stats.proceduralRoadCells ?? 0, - proceduralDividerCells: stats.proceduralDividerCells ?? 0, - proceduralWaterCells: stats.proceduralWaterCells ?? 0, - proceduralWallCells: stats.proceduralWallCells ?? 0, - substitutions: stats.substitutions, - substitutedClusters: placedSpecial.size, - objects: canvas.objects.length, - act2Topography: stats.act2Topography, - act3Topography: stats.act3Topography, - act4Topography: stats.act4Topography, - act5Topography: stats.act5Topography, - waypointsSpawned: stats.waypointsSpawned ?? 0, - waypointTile: stats.waypointTile ?? null, - shrinesSpawned: stats.shrinesSpawned ?? 0, - interactivesSpawned: stats.interactivesSpawned ?? 0, - landmarkEntities: stats.landmarkEntities ?? [], - cells: canvas.width * canvas.height, - unresolved: stats.unresolved, - unimplementedPasses: UNIMPLEMENTED_PASSES, - notes: stats.notes, - ...(stats.jungleLayout ? { jungleLayout: stats.jungleLayout } : {}), - ...(stats.jungleTopology ? { jungleTopology: stats.jungleTopology } : {}), - ...(stats.bridgesPlaced !== undefined ? { bridgesPlaced: stats.bridgesPlaced } : {}), - ...(stats.riverIslands !== undefined ? { riverIslands: stats.riverIslands } : {}), - ...(stats.riverForks !== undefined ? { riverForks: stats.riverForks } : {}), - ...(stats.meanderCount !== undefined ? { meanderCount: stats.meanderCount } : {}), - ...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}), - }, - } - } - - // Filter pieces for Act 3 Kurast districts (79 Slums, 80 Burbs, 81 Metro) and Act 5 dirt (111) vs snow (112, 117) - let levelPieces = request.pieces - if (request.levelId === 79) { - const slumsOnly = request.pieces.filter(p => /slums/i.test(p.name)) - if (slumsOnly.length > 0) levelPieces = slumsOnly - } else if (request.levelId === 80) { - const burbsOnly = request.pieces.filter(p => /burbs/i.test(p.name)) - if (burbsOnly.length > 0) levelPieces = burbsOnly - } else if (request.levelId === 81) { - const metroOnly = request.pieces.filter(p => /metro/i.test(p.name)) - if (metroOnly.length > 0) levelPieces = metroOnly - } else if (request.levelId === 111 || request.levelId === 112) { - levelPieces = request.pieces.filter(p => !/snow/i.test(p.name)) - } else if (request.levelId === 117) { - const snowOnly = request.pieces.filter(p => /snow/i.test(p.name) || /ruins/i.test(p.name)) - if (snowOnly.length > 0) levelPieces = snowOnly - } - - const borderPieces = levelPieces.filter(piece => piece.border) - const fillPieces = levelPieces.filter(piece => !piece.border) - - // Step 1-3: anchor & road planning from the seams the world graph supplies. - // - // The planner was written for Act 1, but nothing in it is Act 1 specific once the caller supplies - // the seams: `request.gates` comes from the world graph, so every act is planned the same way. - let act1Plan: Act1OutdoorPlan | undefined - if (request.gates !== undefined || request.seamConstraints !== undefined) { - act1Plan = planAct1OutdoorLevel(request, gridWidth, gridHeight, rng, stats) - stats.anchors = act1Plan.anchors.length - stats.entrances = toEntrances(act1Plan.anchors) - if (act1Plan.gateOpenings !== undefined) { - stats.gateOpenings = act1Plan.gateOpenings - } - const vertexResult = createDrlgVertices(act1Plan, gridWidth, gridHeight, rng) - stats.drlgVertices = vertexResult.vertices - stats.roadPolylines = vertexResult.polylines - } - - // Step 4A: Lay directional border around perimeter (respecting open gates & Town Transitions) - DRLGOUTPLACE_PlaceAct1245OutdoorBorders( - canvas, - borderPieces, - gridWidth, - gridHeight, - rng, - stats, - request.levelId, - request.openGates ?? act1Plan?.openGates, - request.claimedBorderBlocks ?? act1Plan?.claimedBorderBlocks, - request.gates, - request.seamConstraints, - ) - - // Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.) - const allPlacedRects: PlacedPresetRect[] = [] - if (act1Plan !== undefined) { - for (const preset of act1Plan.specialPresets) { - const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0)) - const originY = Math.min(preset.by * TILES_PER_BLOCK, Math.max(canvas.height - preset.ds1.height, 0)) - stampDs1(canvas, preset.ds1, originX, originY) - stats.specialPresets.push(preset.name) - stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 }) - allPlacedRects.push({ - name: preset.name, - x: originX, - y: originY, - w: preset.ds1.width, - h: preset.ds1.height, - }) - } - } - - let specialPresetNames = request.specialPresets ?? SPECIAL_PRESETS_BY_LEVEL[request.levelId] ?? [] - if (act1Plan !== undefined) { - specialPresetNames = specialPresetNames.filter(name => !stats.specialPresets.includes(name)) - } - - const inX = Math.floor(gridWidth / 2) - const outY = Math.floor(gridHeight / 2) - const reservedRoadBlocks = new Set() - if ( - (request.levelId >= 41 && request.levelId <= 46) || - (request.levelId >= 79 && request.levelId <= 82) || - request.levelId === 111 || - request.levelId === 112 || - request.levelId === 117 || - request.levelTypeName === 'Act 2 - Desert' || - request.levelTypeName === 'Act 3 - Kurast' || - request.levelTypeName === 'Act 5 - Barricade' || - request.levelTypeName === 'Act 5 - Siege' || - act === 2 || - act === 3 || - act === 5 - ) { - for (let by = 1; by < gridHeight - 1; by += 1) reservedRoadBlocks.add(`${String(inX)},${String(by)}`) - for (let bx = 1; bx < gridWidth - 1; bx += 1) reservedRoadBlocks.add(`${String(bx)},${String(outY)}`) - } - if (act1Plan !== undefined) { - for (let by = 0; by < gridHeight; by += 1) { - for (let bx = 0; bx < gridWidth; bx += 1) { - if (act1Plan.claimedBlocks[by]?.[bx]) { - reservedRoadBlocks.add(`${String(bx)},${String(by)}`) - } - } - } - } - - const { placedPieces: placedSpecialPieces, placedRects } = spawnSpecialPresets( - canvas, - fillPieces, - specialPresetNames, - gridWidth, - gridHeight, - rng, - stats, - reservedRoadBlocks, - ) - allPlacedRects.push(...placedRects) - - // Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance - const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme))) - const objectRows = request.substitutions.filter(row => classifySubstitutionRole(row.name, row.type) !== 'border') - const borderRows = [...request.substitutions, ...(request.shrineSubstitutions ?? [])] - .filter(row => classifySubstitutionRole(row.name, row.type) === 'border') - const substituted = applySubstitutions( - canvas, - [...borderRows, ...objectRows, ...(request.shrineSubstitutions ?? []).filter(row => classifySubstitutionRole(row.name, row.type) !== 'border')], - themeIndex, - gridWidth, - gridHeight, - rng, - stats, - act1Plan?.claimedBlocks, - ) - - // Stamp outdoor road networks and boundary divider lines across the wilderness - // after random clutter substitutions. - stampOutdoorRoads( - canvas, - act, - request.levelId, - gridWidth, - gridHeight, - inX, - outY, - allPlacedRects, - rng, - request.levelTypeName, - act1Plan?.openGates, - request.levelsTable, - stats, - ) - - // Act 2 Desert Topography: Canyons, oasis pools, sand caravan trails, tomb entrances (DRLGOUTDESR_InitAct2OutdoorLevel) - if ( - (request.levelId >= 41 && request.levelId <= 46) || - request.levelTypeName === 'Act 2 - Desert' - ) { - initAct2OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) - } - - // Act 3 Kurast Urban Districts Topography: Avenues, canals, bridges (DRLGOUTPLACE_InitAct3OutdoorLevel) - if ( - (request.levelId >= 79 && request.levelId <= 81) || - request.levelTypeName === 'Act 3 - Kurast' - ) { - initAct3OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) - } - - // Act 4 Mesa / Lava Topography: Lava chasms, rocky bridges, plateaus (DRLGOUTDOORS_InitAct4OutdoorLevel) - if ( - (request.levelId >= 104 && request.levelId <= 106) || - request.levelTypeName === 'Act 4 - Mesa' || - request.levelTypeName === 'Act 4 - Lava' - ) { - initAct4OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) - } - - // Act 5 Barricade Topography: Barricades, trenches, frozen lakes (DRLGOUTSIEGE_InitAct5OutdoorLevel). - // Ground Truth Invariant (D2Common.dll VA 0x6fdb98b5): - // cmp edx, 0x6f (111); jl -> jump to 0x6fdb89c4 (Level 110 Bloody Foothills 15-strip assembly, handled above). - // Levels 111 (Frigid Highlands), 112 (Arreat Plateau), and 117 (Frozen Tundra) enter 0x6fdb98df here. - // Level 110 and Levels 111/112/117 are strictly mutually exclusive branches in 1.13c. - if ( - (request.levelId >= 111 && request.levelId <= 117) || - request.levelTypeName === 'Act 5 - Barricade' - ) { - initAct5OutdoorLevel(canvas, request, gridWidth, gridHeight, inX, outY, allPlacedRects, rng, stats) - } - - // Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie) - const populationRng = new Rng(request.seed ^ 0x6c07) - // No dirt-path grid: only Act I built one, and Act I outdoor levels now come - // from the DRLG port (src/game/drlg). - spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, allPlacedRects, populationRng, stats, request.levelTypeName, request.levelsTable, request.seamConstraints) - spawnOutdoorShrines(canvas, request.levelId, act, undefined, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName, request.levelsTable) - spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs) - - for (const piece of fillPieces) { - if (!placedSpecialPieces.has(piece.name)) { - stats.notes.push(`fill piece "${piece.name}" is not placed by this port`) - } - } - - let finalGroundCells = 0 - for (let y = 0; y < canvas.height; y += 1) { - const row = canvas.cells[y] - if (row === undefined) continue - for (let x = 0; x < canvas.width; x += 1) { - const c = row[x] - if (c !== undefined) { - if (!c.floors.some(f => !f.hidden && f.prop1 !== 0)) { - const floor = c.floors[0] - if (floor !== undefined) { - const tile = groundTiles && groundTiles.length > 1 - ? sampleGroundTile(groundTiles, x, y, { seed: request.seed }) - : primaryGroundTile! - Object.assign(floor, { prop1: 2, style: tile.style, sequence: tile.sequence, hidden: false }) - } - } - finalGroundCells += 1 - } - } - } - stats.groundCells = finalGroundCells - - if (!Number.isFinite(canvas.width * canvas.height) || canvas.width * canvas.height > (1 << 22)) { - throw new Error(`${where}: synthesized map is ${String(canvas.width)}x${String(canvas.height)} cells, outside the supported bound`) - } - - return { - level: { - version: version === 0 ? 18 : version, - width: canvas.width, - height: canvas.height, - act, - substitutionType, - wallLayers: canvas.wallLayers, - floorLayers: canvas.floorLayers, - cells: canvas.cells, - objects: canvas.objects, - npcPathOffset: null, - }, - stats: { - levelId: request.levelId, - levelName: request.levelName, - levelTypeName: request.levelTypeName, - seed: request.seed, - sizeX, - sizeY, - sizeSource, - tilesPerBlock: TILES_PER_BLOCK, - blockGrid: { width: gridWidth, height: gridHeight }, - dt1Mask: effectiveMask, - subType: request.subType, - subTheme: themeIndex, - groundTile: primaryGroundTile, - groundTiles: groundTiles ?? undefined, - groundCells: stats.groundCells, - borderPiecesAvailable: borderPieces.length, - borderBlocks: stats.borderStamped, - borderUsage: stats.borderPieces, - roadCells: stats.roadCells, - roadSegments: stats.roadSegments, - anchors: stats.anchors, - entrances: stats.entrances, - gateOpenings: stats.gateOpenings ?? act1Plan?.gateOpenings ?? [], - specialPresets: stats.specialPresets, - drlgVertices: stats.drlgVertices, - roadPolylines: stats.roadPolylines, - substitutions: stats.substitutions, - substitutedClusters: substituted + placedSpecialPieces.size, - objects: canvas.objects.length, - waypointsSpawned: stats.waypointsSpawned ?? 0, - waypointTile: stats.waypointTile ?? null, - shrinesSpawned: stats.shrinesSpawned ?? 0, - interactivesSpawned: stats.interactivesSpawned ?? 0, - landmarkEntities: stats.landmarkEntities ?? [], - act2Topography: stats.act2Topography, - act3Topography: stats.act3Topography, - act4Topography: stats.act4Topography, - act5Topography: stats.act5Topography, - proceduralRoadCells: stats.proceduralRoadCells ?? 0, - proceduralDividerCells: stats.proceduralDividerCells ?? 0, - proceduralWaterCells: stats.proceduralWaterCells ?? 0, - proceduralWallCells: stats.proceduralWallCells ?? 0, - cells: canvas.width * canvas.height, - unresolved: stats.unresolved, - unimplementedPasses: UNIMPLEMENTED_PASSES, - notes: stats.notes, - ...(stats.siegeTopography ? { siegeTopography: stats.siegeTopography } : {}), - ...(animSpeed !== undefined ? { animSpeed } : {}), - }, - ...(animSpeed !== undefined ? { animSpeed } : {}), - } -} diff --git a/src/game/world-graph.ts b/src/game/world-graph.ts index ab50d15..f69d42f 100644 --- a/src/game/world-graph.ts +++ b/src/game/world-graph.ts @@ -46,7 +46,6 @@ * {@link Rng} so the same seed replays the same world. */ import type { D2Table } from './acts.ts' -import { Rng } from './rng.ts' /** How the player gets from one level to the next. */ export type LinkKind = @@ -211,11 +210,12 @@ export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [ // 78 Flayer Jungle, 79 Lower Kurast, 80 Kurast Bazaar, 81 Upper Kurast, // 82 Kurast Causeway, 83 Travincal. // - // Canonical throughway progression: 75 -> 76 -> 77 -> 78 -> 79. - // The procedural Great Marsh skip (76 -> 78) is handled dynamically in - // wilderness-jungles.ts; SEAMLESS_ADJACENCY defines the canonical planar throughway. + // DRLGOUTPLACE_InitAct3OutdoorLevel (D2Common.0x6FDD0130) attaches Flayer + // Jungle (78) either to Great Marsh (77, linear: 75->76->77->78->79) or + // directly to Spider Forest (76, branching: 76->77 dead-end and 76->78->79). [75, 76], [76, 77], + [76, 78], [77, 78], [78, 79], [79, 80], @@ -240,39 +240,38 @@ export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [ ] /** - * Seams whose side is fixed by a preset rather than chosen per seed. + * Seams involving a preset or maze level whose side is fixed by hand-authored + * DS1 geometry rather than chosen per seed by the outdoor DRLG placer. * - * Everywhere else the outdoor placer re-picks which edge an opening sits on for - * every seed, so hard-coding a side would be stating a coincidence as a law. - * These are the exceptions: the opening is part of a hand-authored preset whose - * geometry cannot move. - * - * The two `Depend` values in the whole of `Levels.txt` corroborate two of them: - * `27` depends on `26` at offset `(0, -40)`, and `33` depends on `32` at - * `(-4, -34)` — in both cases the dependent level sits directly north. + * Outdoor-to-outdoor DRLG levels (`DrlgType === 3`) report their exact seam + * sides and spans per seed from `pLevelLink` in `buildDrlgLevelMap`. Preset + * (`DrlgType === 2`) and maze (`DrlgType === 1`) levels have fixed border + * openings (with single-seam towns like `TownN1`/`TownE1`/`TownW1` overriding + * their default side when a variant's border opening sits on another edge). * * Keyed `"lowId:highId"`; the value is the side belonging to the *lower* id. */ -export const PINNED_SIDES: ReadonlyMap = new Map([ - // The Rogue Encampment's gate faces south onto the Blood Moor. +export const PRESET_SEAM_SIDES: ReadonlyMap = new Map([ + // Rogue Encampment default gate faces south (TownN1/E1/W1 override via inset 0 scan). ['1:2', 'south'], - // Tamoe Highland runs east into the Monastery Gate. - ['7:26', 'east'], + // Tamoe Highland (7) is south of Monastery Gate (26, facade1.ds1): 7->26 is north, 26->7 is south. + ['7:26', 'north'], // Courtyard 1 sits 40 cells north of the Monastery Gate (`Depend = 26`). ['26:27', 'north'], // The Barracks gateway continues north out of the Outer Cloister. ['27:28', 'north'], // The Cathedral sits 34 cells north of the Inner Cloister (`Depend = 32`). ['32:33', 'north'], - // The Kurast Causeway is a 48x16 bridge: its openings are the short ends. - ['81:82', 'east'], - ['82:83', 'east'], - // Act 5: Harrogath gate faces west onto the Bloody Foothills. + // Lut Gholein default gate faces west (TownN1 overrides via inset 0 scan). + ['40:41', 'west'], + // Kurast Docks opens north onto Spider Forest. + ['75:76', 'north'], + // Pandemonium Fortress opens east onto Outer Steppes. + ['103:104', 'east'], + // River of Flame (107) opens north onto Chaos Sanctuary (108). + ['107:108', 'north'], + // Harrogath gate faces west onto Bloody Foothills. ['109:110', 'west'], - // Bloody Foothills marches west into the Frigid Highlands. - ['110:111', 'west'], - // Frigid Highlands marches north into the Arreat Plateau. - ['111:112', 'north'], ]) /** A hard-coded portal, stair or act transition. */ @@ -436,8 +435,9 @@ function edgeKey(from: number, to: number): string { * the coarser one — and every edge is emitted in both directions. * * @param rows - the output of {@link parseLevelRows}. - * @returns the graph, with `sideFrom`/`sideTo` still null; call - * {@link assignGateSides} to fill them. + * @returns the graph, with `sideFrom`/`sideTo` populated for fixed preset/maze + * seams ({@link PRESET_SEAM_SIDES}) and null on outdoor DRLG seams (which + * come from the DRLG level link per seed). */ export function buildWorldGraph(rows: readonly LevelRow[]): WorldGraph { const levels = new Map() @@ -494,7 +494,16 @@ export function buildWorldGraph(rows: readonly LevelRow[]): WorldGraph { const key = edgeKey(from, to) if (emitted.has(key)) return emitted.add(key) - edges.push({ from, to, kind, warps, warpSlots, sideFrom: null, sideTo: null, source }) + let sideFrom: Side | null = null + let sideTo: Side | null = null + if (kind === 'seamless') { + const presetSide = PRESET_SEAM_SIDES.get(pairKey(from, to)) + if (presetSide !== undefined) { + sideFrom = from < to ? presetSide : oppositeSide(presetSide) + sideTo = oppositeSide(sideFrom) + } + } + edges.push({ from, to, kind, warps, warpSlots, sideFrom, sideTo, source }) } // 1. Hard-coded outdoor stitching. Highest priority: where a pair is both @@ -553,110 +562,6 @@ export function seamlessPairs(graph: WorldGraph): (readonly [number, number])[] return pairs } -/** - * Choose which edge of each level every seam sits on. - * - * Diablo II re-picks these per seed — the documented constraint is that in the - * Cold Plains the Blood Moor entrance and the two exits may not share an edge — - * so this is a constraint solve, not a table. Two rules: - * - * 1. **Opposite sides.** If the seam leaves A heading east it must arrive on - * B's west edge, or the two rectangles do not abut. - * 2. **No sharing.** Two seams of the same level may not use the same edge, or - * the two neighbours would occupy the same strip of ground. - * - * Preset-anchored seams ({@link PINNED_SIDES}) are placed first and never moved. - * The rest are assigned greedily in a shuffled order, retrying with a fresh - * shuffle when the greedy pass paints itself into a corner; with a maximum - * degree of three this converges immediately, and the retry loop is there so a - * future adjacency addition fails loudly rather than silently sharing an edge. - * - * The choice is deliberately **not** made per pack variant. A level's three - * baked variants all share one set of gate sides, so any variant of A docks - * against any variant of B; only the interior differs. - * - * @param graph - the graph to annotate. - * @param seed - the act layout seed. - * @returns a copy of the graph with `sideFrom`/`sideTo` filled on every - * seamless edge. - * @throws when no assignment satisfies the constraints. - */ -export function assignGateSides(graph: WorldGraph, seed: number): WorldGraph { - const pairs = seamlessPairs(graph) - const maxAttempts = 64 - - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const rng = new Rng(seed).fork(`gate-sides:${String(attempt)}`) - /** level id -> the sides already spoken for. */ - const used = new Map>() - const taken = (level: number): Set => { - let set = used.get(level) - if (set === undefined) { - set = new Set() - used.set(level, set) - } - return set - } - - const chosen = new Map() - let failed = false - - // Pinned seams first: they cannot move, so everything else works around - // them rather than the other way round. - for (const [low, high] of pairs) { - const key = pairKey(low, high) - const pinned = PINNED_SIDES.get(key) - if (pinned === undefined) continue - const lowUsed = taken(low) - const highUsed = taken(high) - if (lowUsed.has(pinned) || highUsed.has(oppositeSide(pinned))) { - // Two pinned seams contradict each other; no shuffle can fix that. - throw new Error(`pinned sides conflict at ${key}`) - } - lowUsed.add(pinned) - highUsed.add(oppositeSide(pinned)) - chosen.set(key, pinned) - } - - // The rest, in a shuffled order so no level systematically gets first pick. - const free = pairs.filter(([low, high]) => !chosen.has(pairKey(low, high))) - for (let index = free.length - 1; index > 0; index -= 1) { - const swap = rng.int(0, index) - const hold = free[index]! - free[index] = free[swap]! - free[swap] = hold - } - - for (const [low, high] of free) { - const lowUsed = taken(low) - const highUsed = taken(high) - const candidates = SIDES.filter(side => !lowUsed.has(side) && !highUsed.has(oppositeSide(side))) - const pick = rng.pick(candidates) - if (pick === undefined) { - failed = true - break - } - lowUsed.add(pick) - highUsed.add(oppositeSide(pick)) - chosen.set(pairKey(low, high), pick) - } - - if (failed) continue - - const edges = graph.edges.map((edge): WorldEdge => { - if (edge.kind !== 'seamless') return edge - const low = Math.min(edge.from, edge.to) - const side = chosen.get(pairKey(edge.from, edge.to)) - if (side === undefined) return edge - const sideFrom = edge.from === low ? side : oppositeSide(side) - return { ...edge, sideFrom, sideTo: oppositeSide(sideFrom) } - }) - return { levels: graph.levels, edges, waypoints: graph.waypoints } - } - - throw new Error(`could not assign gate sides after ${String(maxAttempts)} attempts`) -} - /** * The edges leaving one level. * @@ -829,6 +734,3 @@ export function isClickableWarp(warp: WarpGeometry): boolean { return warp.selectDX > 0 && warp.selectDY > 0 } -export type { WorldOffset } from './world-placement.ts' -export { resolveWorldOffsets } from './world-placement.ts' - diff --git a/src/game/world-placement.ts b/src/game/world-placement.ts deleted file mode 100644 index c890b5d..0000000 --- a/src/game/world-placement.ts +++ /dev/null @@ -1,4912 +0,0 @@ -/** - * World coordinate placement and chaining for Diablo II levels. - * - * Resolves seamless adjacency along SEAMLESS_ADJACENCY and propagates coordinates - * from anchor levels (seeded from Levels.txt), aligning automap bounding boxes - * with zero gaps, zero overlaps, and zero tears. - */ -import type { WorldGraph, LevelRow, WorldEdge } from './world-graph.ts' -import { assignGateSides, buildWorldGraph } from './world-graph.ts' - -export interface WorldOffset { - levelId: number - offsetX: number - offsetY: number - sizeX: number - sizeY: number -} - -interface AnchorSpec { - readonly levelId: number - readonly offsetX: number - readonly offsetY: number - readonly sizeX?: number - readonly sizeY?: number -} - -/** - * Anchor level coordinates seeded from Levels.txt / level definitions. - * Keyed by 0-based act index (0..4). - */ -export const ACT_ANCHORS: Readonly> = { - // Act 1: Cold Plains (Level 3) at (1000, 1000); the Monastery Gate (Level 26) at its Levels.txt - // offset (3000, 1000), anchoring the second level group (26, 7, 6, 5), which D2 places apart from the - // first (DrlgOutPlace.cpp gAct1MonasteryDrlgLink @ D2Common.0x6FDCFF30). - 0: [ - { levelId: 3, offsetX: 1000, offsetY: 1000, sizeX: 80, sizeY: 80 }, - { levelId: 26, offsetX: 3000, offsetY: 1000, sizeX: 64, sizeY: 18 }, - ], - // Act 2: Lut Gholein (Level 40) at (1000, 1000), Canyon of Magi (Level 46) at (2500, 1000) - 1: [ - { levelId: 40, offsetX: 1000, offsetY: 1000, sizeX: 56, sizeY: 56 }, - { levelId: 46, offsetX: 2500, offsetY: 1000, sizeX: 80, sizeY: 80 }, - ], - // Act 3: Kurast Docks (Level 75) at (1000, 1000) - 2: [{ levelId: 75, offsetX: 1000, offsetY: 1000, sizeX: 64, sizeY: 48 }], - // Act 4: Pandemonium Fortress (Level 103) at (1000, 1000) - 3: [{ levelId: 103, offsetX: 1000, offsetY: 1000, sizeX: 32, sizeY: 24 }], - // Act 5: Harrogath (Level 109) at (1000, 1000), Bloody Foothills (Level 110) at (760, 1000) - 4: [ - { levelId: 109, offsetX: 1000, offsetY: 1000, sizeX: 40, sizeY: 40 }, - { levelId: 110, offsetX: 760, offsetY: 1000, sizeX: 240, sizeY: 48 }, - ], -} - -/** Pre-baked canonical LevelRow records for all 136 game levels. */ -export const CANONICAL_LEVEL_ROWS: readonly LevelRow[] = [ - { - "id": 1, - "act": 0, - "name": "Act 1 - Town", - "drlgType": 2, - "sizeX": 56, - "sizeY": 40, - "waypoint": 0, - "position": 1, - "portal": 1, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 2, - "act": 0, - "name": "Act 1 - Wilderness 1", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 8, - 8, - 8, - 8, - 0 - ], - "warp": [ - -1, - -1, - -1, - 0, - 1, - 2, - 3, - -1 - ] - }, - { - "id": 3, - "act": 0, - "name": "Act 1 - Wilderness 2", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 1, - "position": 0, - "portal": 1, - "offsetX": 1000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 9, - 9, - 9, - 9, - 0 - ], - "warp": [ - -1, - -1, - -1, - 0, - 1, - 2, - 3, - -1 - ] - }, - { - "id": 4, - "act": 0, - "name": "Act 1 - Wilderness 3", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 2, - "position": 0, - "portal": 0, - "offsetX": 1000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 10, - 10, - 10, - 10, - 0 - ], - "warp": [ - -1, - -1, - -1, - 0, - 1, - 2, - 3, - -1 - ] - }, - { - "id": 5, - "act": 0, - "name": "Act 1 - Wilderness 4", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 3, - "position": 0, - "portal": 1, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 10, - 10, - 10, - 10, - 0 - ], - "warp": [ - -1, - -1, - -1, - 0, - 1, - 2, - 3, - -1 - ] - }, - { - "id": 6, - "act": 0, - "name": "Act 1 - Wilderness 5", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 4, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 20, - 11, - 11, - 11, - 11, - 0 - ], - "warp": [ - -1, - -1, - 10, - 0, - 1, - 2, - 3, - -1 - ] - }, - { - "id": 7, - "act": 0, - "name": "Act 1 - Wilderness 6", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 255, - "position": 0, - "portal": 1, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 12, - 12, - 12, - 12, - 0 - ], - "warp": [ - -1, - -1, - -1, - 0, - 1, - 2, - 3, - -1 - ] - }, - { - "id": 8, - "act": 0, - "name": "Act 1 - Cave 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 4, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 9, - "act": 0, - "name": "Act 1 - Cave 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 3, - 0, - 0, - 0, - 13, - 0, - 0, - 0 - ], - "warp": [ - 4, - -1, - -1, - -1, - 5, - -1, - -1, - -1 - ] - }, - { - "id": 10, - "act": 0, - "name": "Act 1 - Cave 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 4, - 5, - 0, - 0, - 14, - 0, - 0, - 0 - ], - "warp": [ - 4, - 4, - -1, - -1, - 5, - -1, - -1, - -1 - ] - }, - { - "id": 11, - "act": 0, - "name": "Act 1 - Cave 4", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1900, - "depend": 0, - "vis": [ - 6, - 0, - 0, - 0, - 15, - 0, - 0, - 0 - ], - "warp": [ - 4, - -1, - -1, - -1, - 5, - -1, - -1, - -1 - ] - }, - { - "id": 12, - "act": 0, - "name": "Act 1 - Cave 5", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 2200, - "depend": 0, - "vis": [ - 7, - 0, - 0, - 0, - 16, - 0, - 0, - 0 - ], - "warp": [ - 4, - -1, - -1, - -1, - 5, - -1, - -1, - -1 - ] - }, - { - "id": 13, - "act": 0, - "name": "Act 1 - Cave 2 Treasure", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 2500, - "depend": 0, - "vis": [ - 0, - 9, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 4, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 14, - "act": 0, - "name": "Act 1 - Cave 3 Treasure", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 2624, - "depend": 0, - "vis": [ - 0, - 10, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 4, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 15, - "act": 0, - "name": "Act 1 - Cave 4 Treasure", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 2748, - "depend": 0, - "vis": [ - 0, - 11, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 4, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 16, - "act": 0, - "name": "Act 1 - Cave 5 Treasure", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 2872, - "depend": 0, - "vis": [ - 0, - 12, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 4, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 17, - "act": 0, - "name": "Act 1 - Graveyard", - "drlgType": 3, - "sizeX": 40, - "sizeY": 48, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 18, - 19, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 6, - 7, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 18, - "act": 0, - "name": "Act 1 - Crypt 1 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 17, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 19, - "act": 0, - "name": "Act 1 - Crypt 2 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1300, - "depend": 0, - "vis": [ - 17, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 20, - "act": 0, - "name": "Act 1 - Tower 2", - "drlgType": 2, - "sizeX": 8, - "sizeY": 8, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1600, - "depend": 0, - "vis": [ - 6, - 21, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 11, - 12, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 21, - "act": 0, - "name": "Act 1 - Crypt 3 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 20, - 22, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - 9, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 22, - "act": 0, - "name": "Act 1 - Crypt 3 B", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 21, - 23, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - 9, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 23, - "act": 0, - "name": "Act 1 - Crypt 3 C", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 22, - 24, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - 9, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 24, - "act": 0, - "name": "Act 1 - Crypt 3 D", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1900, - "depend": 0, - "vis": [ - 23, - 25, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - 9, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 25, - "act": 0, - "name": "Act 1 - Crypt 3 E", - "drlgType": 2, - "sizeX": 30, - "sizeY": 30, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 2200, - "depend": 0, - "vis": [ - 24, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 26, - "act": 0, - "name": "Act 1 - Monastery", - "drlgType": 2, - "sizeX": 64, - "sizeY": 18, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 27, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 27, - "act": 0, - "name": "Act 1 - Courtyard 1", - "drlgType": 2, - "sizeX": 56, - "sizeY": 40, - "waypoint": 5, - "position": 0, - "portal": 1, - "offsetX": 0, - "offsetY": -40, - "depend": 26, - "vis": [ - 26, - 28, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 28, - "act": 0, - "name": "Act 1 - Barracks", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 27, - 29, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 14, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 29, - "act": 0, - "name": "Act 1 - Jail 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 6, - "position": 0, - "portal": 1, - "offsetX": 3500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 28, - 30, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 13, - 14, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 30, - "act": 0, - "name": "Act 1 - Jail 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 29, - 31, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 13, - 14, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 31, - "act": 0, - "name": "Act 1 - Jail 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 30, - 32, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 13, - 13, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 32, - "act": 0, - "name": "Act 1 - Courtyard 2", - "drlgType": 2, - "sizeX": 18, - "sizeY": 20, - "waypoint": 7, - "position": 0, - "portal": 0, - "offsetX": 4000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 31, - 33, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 14, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 33, - "act": 0, - "name": "Act 1 - Cathedral", - "drlgType": 2, - "sizeX": 28, - "sizeY": 34, - "waypoint": 255, - "position": 0, - "portal": 1, - "offsetX": -4, - "offsetY": -34, - "depend": 32, - "vis": [ - 32, - 34, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 15, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 34, - "act": 0, - "name": "Act 1 - Catacombs 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 33, - 35, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 16, - 18, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 35, - "act": 0, - "name": "Act 1 - Catacombs 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 8, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 34, - 36, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 17, - 18, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 36, - "act": 0, - "name": "Act 1 - Catacombs 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 1, - "offsetX": 4500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 35, - 37, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 17, - 18, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 37, - "act": 0, - "name": "Act 1 - Catacombs 4", - "drlgType": 2, - "sizeX": 22, - "sizeY": 31, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1900, - "depend": 0, - "vis": [ - 36, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 17, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 38, - "act": 0, - "name": "Act 1 - Tristram", - "drlgType": 2, - "sizeX": 43, - "sizeY": 48, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 5000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 39, - "act": 0, - "name": "Act 1 - Moo Moo Farm", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 5000, - "offsetY": 1148, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 40, - "act": 1, - "name": "Act 2 - Town", - "drlgType": 2, - "sizeX": 56, - "sizeY": 56, - "waypoint": 9, - "position": 1, - "portal": 1, - "offsetX": 1000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 47, - 47, - 50, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - 19, - 20, - 24, - -1, - -1, - -1 - ] - }, - { - "id": 41, - "act": 1, - "name": "Act 2 - Desert 1", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 55, - 55, - 55, - 55, - 0, - 0, - 0, - 0 - ], - "warp": [ - 33, - 34, - 35, - 36, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 42, - "act": 1, - "name": "Act 2 - Desert 2", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 11, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 56, - 56, - 56, - 56, - 0, - 0, - 0, - 0 - ], - "warp": [ - 33, - 34, - 35, - 36, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 43, - "act": 1, - "name": "Act 2 - Desert 3", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 13, - "position": 0, - "portal": 1, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 62, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - 47, - -1, - -1, - -1 - ] - }, - { - "id": 44, - "act": 1, - "name": "Act 2 - Desert 4", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 14, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 65, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - 50, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 45, - "act": 1, - "name": "Act 2 - Desert 5", - "drlgType": 3, - "sizeX": 32, - "sizeY": 32, - "waypoint": 255, - "position": 0, - "portal": 1, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 58, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 37, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 46, - "act": 1, - "name": "Act 2 - Valley of the Kings", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 17, - "position": 1, - "portal": 1, - "offsetX": 2500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 66, - 67, - 68, - 69, - 70, - 71, - 72 - ], - "warp": [ - -1, - 38, - 39, - 40, - 41, - 42, - 43, - 44 - ] - }, - { - "id": 47, - "act": 1, - "name": "Act 2 - Sewer 1 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 40, - 40, - 48, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 22, - 21, - 23, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 48, - "act": 1, - "name": "Act 2 - Sewer 1 B", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 10, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 47, - 0, - 49, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 22, - -1, - 23, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 49, - "act": 1, - "name": "Act 2 - Sewer 1 C", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 48, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 22, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 50, - "act": 1, - "name": "Act 2 - Harem", - "drlgType": 2, - "sizeX": 16, - "sizeY": 19, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 40, - 0, - 51, - 51, - 0, - 0, - 0, - 0 - ], - "warp": [ - 25, - -1, - 28, - 29, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 51, - "act": 1, - "name": "Act 2 - Corrupt Harem 1", - "drlgType": 1, - "sizeX": 100, - "sizeY": 100, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1119, - "depend": 0, - "vis": [ - 50, - 50, - 52, - 52, - 0, - 0, - 0, - 0 - ], - "warp": [ - 26, - 27, - 28, - 29, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 52, - "act": 1, - "name": "Act 2 - Basement 1", - "drlgType": 1, - "sizeX": 100, - "sizeY": 100, - "waypoint": 15, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1319, - "depend": 0, - "vis": [ - 51, - 51, - 53, - 53, - 0, - 0, - 0, - 0 - ], - "warp": [ - 26, - 27, - 32, - 32, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 53, - "act": 1, - "name": "Act 2 - Basement 2", - "drlgType": 1, - "sizeX": 100, - "sizeY": 100, - "waypoint": 255, - "position": 0, - "portal": 1, - "offsetX": 2000, - "offsetY": 1519, - "depend": 0, - "vis": [ - 52, - 52, - 54, - 54, - 0, - 0, - 0, - 0 - ], - "warp": [ - 30, - 31, - 32, - 32, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 54, - "act": 1, - "name": "Act 2 - Basement 3", - "drlgType": 1, - "sizeX": 100, - "sizeY": 100, - "waypoint": 255, - "position": 1, - "portal": 1, - "offsetX": 2000, - "offsetY": 1719, - "depend": 0, - "vis": [ - 53, - 53, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 30, - 31, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 55, - "act": 1, - "name": "Act 2 - Tomb 1 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 41, - 0, - 0, - 0, - 59, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - 46, - -1, - -1, - -1 - ] - }, - { - "id": 56, - "act": 1, - "name": "Act 2 - Tomb 2 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1300, - "depend": 0, - "vis": [ - 42, - 0, - 0, - 0, - 57, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - 46, - -1, - -1, - -1 - ] - }, - { - "id": 57, - "act": 1, - "name": "Act 2 - Tomb 2 B", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 12, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1600, - "depend": 0, - "vis": [ - 56, - 0, - 0, - 0, - 60, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - 46, - -1, - -1, - -1 - ] - }, - { - "id": 58, - "act": 1, - "name": "Act 2 - Tomb 3 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1900, - "depend": 0, - "vis": [ - 45, - 0, - 0, - 0, - 61, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - 46, - -1, - -1, - -1 - ] - }, - { - "id": 59, - "act": 1, - "name": "Act 2 - Tomb 1 Treasure", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 2200, - "depend": 0, - "vis": [ - 55, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 60, - "act": 1, - "name": "Act 2 - Tomb 2 Treasure", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 2500, - "depend": 0, - "vis": [ - 57, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 61, - "act": 1, - "name": "Act 2 - Tomb 3 Treasure", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 2800, - "depend": 0, - "vis": [ - 58, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 62, - "act": 1, - "name": "Act 2 - Lair 1 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 43, - 63, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 48, - 49, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 63, - "act": 1, - "name": "Act 2 - Lair 1 B", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 62, - 64, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 48, - 49, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 64, - "act": 1, - "name": "Act 2 - Lair 1 Treasure", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 63, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 48, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 65, - "act": 1, - "name": "Act 2 - Sewer 2 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 44, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 22, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 66, - "act": 1, - "name": "Act 2 - Tomb Tal 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 67, - "act": 1, - "name": "Act 2 - Tomb Tal 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 68, - "act": 1, - "name": "Act 2 - Tomb Tal 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 69, - "act": 1, - "name": "Act 2 - Tomb Tal 4", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 1900, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 70, - "act": 1, - "name": "Act 2 - Tomb Tal 5", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 2200, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 71, - "act": 1, - "name": "Act 2 - Tomb Tal 6", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 2500, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 72, - "act": 1, - "name": "Act 2 - Tomb Tal 7", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4500, - "offsetY": 2800, - "depend": 0, - "vis": [ - 46, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 45, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 73, - "act": 1, - "name": "Act 2 - Duriel's Lair", - "drlgType": 2, - "sizeX": 32, - "sizeY": 47, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 4500, - "offsetY": 3100, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 74, - "act": 1, - "name": "Act 2 - Arcane", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 16, - "position": 1, - "portal": 1, - "offsetX": 5000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 75, - "act": 2, - "name": "Act 3 - Town", - "drlgType": 2, - "sizeX": 64, - "sizeY": 48, - "waypoint": 18, - "position": 1, - "portal": 0, - "offsetX": 1000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 76, - "act": 2, - "name": "Act 3 - Jungle 1", - "drlgType": 3, - "sizeX": 64, - "sizeY": 192, - "waypoint": 19, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 84, - 85, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 51, - 51, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 77, - "act": 2, - "name": "Act 3 - Jungle 2", - "drlgType": 3, - "sizeX": 64, - "sizeY": 192, - "waypoint": 20, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 78, - "act": 2, - "name": "Act 3 - Jungle 3", - "drlgType": 3, - "sizeX": 64, - "sizeY": 192, - "waypoint": 21, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 86, - 88, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 53, - 54, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 79, - "act": 2, - "name": "Act 3 - Kurast 1", - "drlgType": 3, - "sizeX": 80, - "sizeY": 64, - "waypoint": 22, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 80, - "act": 2, - "name": "Act 3 - Kurast 2", - "drlgType": 3, - "sizeX": 80, - "sizeY": 64, - "waypoint": 23, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 92, - 92, - 94, - 95, - 0, - 0, - 0, - 0 - ], - "warp": [ - 57, - 57, - 61, - 61, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 81, - "act": 2, - "name": "Act 3 - Kurast 3", - "drlgType": 3, - "sizeX": 80, - "sizeY": 64, - "waypoint": 24, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 92, - 92, - 96, - 97, - 0, - 0, - 0, - 0 - ], - "warp": [ - 57, - 57, - 61, - 61, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 82, - "act": 2, - "name": "Act 3 - Kurast 4", - "drlgType": 3, - "sizeX": 48, - "sizeY": 16, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 98, - 99, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - 61, - 61, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 83, - "act": 2, - "name": "Act 3 - Travincal", - "drlgType": 3, - "sizeX": 64, - "sizeY": 64, - "waypoint": 25, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 100, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 64, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 84, - "act": 2, - "name": "Act 3 - Spider 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 76, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 52, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 85, - "act": 2, - "name": "Act 3 - Spider 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 0, - 76, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 52, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 86, - "act": 2, - "name": "Act 3 - Dungeon 1 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 87, - 78, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 56, - 55, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 87, - "act": 2, - "name": "Act 3 - Dungeon 1 B", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1300, - "depend": 0, - "vis": [ - 90, - 86, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 56, - 55, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 88, - "act": 2, - "name": "Act 3 - Dungeon 2 A", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1600, - "depend": 0, - "vis": [ - 89, - 78, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 56, - 55, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 89, - "act": 2, - "name": "Act 3 - Dungeon 2 B", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1900, - "depend": 0, - "vis": [ - 91, - 88, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 56, - 55, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 90, - "act": 2, - "name": "Act 3 - Dungeon 1 Treasure", - "drlgType": 2, - "sizeX": 40, - "sizeY": 40, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2200, - "depend": 0, - "vis": [ - 0, - 87, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 55, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 91, - "act": 2, - "name": "Act 3 - Dungeon 2 Treasure", - "drlgType": 2, - "sizeX": 40, - "sizeY": 40, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2340, - "depend": 0, - "vis": [ - 0, - 89, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 55, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 92, - "act": 2, - "name": "Act 3 - Sewer 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 80, - 80, - 81, - 81, - 93, - 0, - 0, - 0 - ], - "warp": [ - 58, - 59, - 58, - 59, - 60, - -1, - -1, - -1 - ] - }, - { - "id": 93, - "act": 2, - "name": "Act 3 - Sewer 2", - "drlgType": 2, - "sizeX": 18, - "sizeY": 22, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 92, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 58, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 94, - "act": 2, - "name": "Act 3 - Temple 1", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 80, - 80, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 62, - 63, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 95, - "act": 2, - "name": "Act 3 - Temple 2", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1124, - "depend": 0, - "vis": [ - 80, - 80, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 62, - 63, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 96, - "act": 2, - "name": "Act 3 - Temple 3", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1248, - "depend": 0, - "vis": [ - 81, - 81, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 62, - 63, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 97, - "act": 2, - "name": "Act 3 - Temple 4", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1372, - "depend": 0, - "vis": [ - 81, - 81, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 62, - 63, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 98, - "act": 2, - "name": "Act 3 - Temple 5", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1496, - "depend": 0, - "vis": [ - 82, - 82, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 62, - 63, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 99, - "act": 2, - "name": "Act 3 - Temple 6", - "drlgType": 2, - "sizeX": 24, - "sizeY": 24, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1620, - "depend": 0, - "vis": [ - 82, - 82, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 62, - 63, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 100, - "act": 2, - "name": "Act 3 - Mephisto 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 101, - 101, - 83, - 83, - 0, - 0, - 0, - 0 - ], - "warp": [ - 67, - 68, - 65, - 66, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 101, - "act": 2, - "name": "Act 3 - Mephisto 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 26, - "position": 0, - "portal": 0, - "offsetX": 3500, - "offsetY": 1300, - "depend": 0, - "vis": [ - 102, - 102, - 100, - 100, - 0, - 0, - 0, - 0 - ], - "warp": [ - 67, - 68, - 65, - 66, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 102, - "act": 2, - "name": "Act 3 - Mephisto 3", - "drlgType": 2, - "sizeX": 41, - "sizeY": 29, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 3500, - "offsetY": 1600, - "depend": 0, - "vis": [ - 0, - 0, - 101, - 101, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - 65, - 66, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 103, - "act": 3, - "name": "Act 4 - Town", - "drlgType": 2, - "sizeX": 32, - "sizeY": 24, - "waypoint": 27, - "position": 1, - "portal": 0, - "offsetX": 1000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 104, - "act": 3, - "name": "Act 4 - Mesa 1", - "drlgType": 3, - "sizeX": 80, - "sizeY": 64, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 105, - "act": 3, - "name": "Act 4 - Mesa 2", - "drlgType": 3, - "sizeX": 64, - "sizeY": 80, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 106, - "act": 3, - "name": "Act 4 - Mesa 3", - "drlgType": 3, - "sizeX": 80, - "sizeY": 64, - "waypoint": 28, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 107, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 69, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 107, - "act": 3, - "name": "Act 4 - Lava 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 29, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 106, - 108, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 70, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 108, - "act": 3, - "name": "Act 4 - Diablo 1", - "drlgType": 3, - "sizeX": 120, - "sizeY": 120, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 1500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 107, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 109, - "act": 4, - "name": "Act 5 - Town", - "drlgType": 2, - "sizeX": 40, - "sizeY": 40, - "waypoint": 30, - "position": 1, - "portal": 0, - "offsetX": 1000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 110, - "act": 4, - "name": "Act 5 - Siege 1", - "drlgType": 3, - "sizeX": 240, - "sizeY": 48, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 760, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 111, - "act": 4, - "name": "Act 5 - Barricade 1", - "drlgType": 3, - "sizeX": -1, - "sizeY": -1, - "waypoint": 31, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 112, - "act": 4, - "name": "Act 5 - Barricade 2", - "drlgType": 3, - "sizeX": -1, - "sizeY": -1, - "waypoint": 32, - "position": 0, - "portal": 0, - "offsetX": -1, - "offsetY": -1, - "depend": 0, - "vis": [ - 0, - 0, - 113, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - 71, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 113, - "act": 4, - "name": "Act 5 - Ice Cave 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 33, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 112, - 115, - 114, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 73, - 74, - 75, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 114, - "act": 4, - "name": "Act 5 - Ice Cave 1A", - "drlgType": 1, - "sizeX": 64, - "sizeY": 64, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1300, - "depend": 0, - "vis": [ - 113, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 73, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 115, - "act": 4, - "name": "Act 5 - Ice Cave 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 34, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1464, - "depend": 0, - "vis": [ - 113, - 117, - 116, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 73, - 74, - 75, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 116, - "act": 4, - "name": "Act 5 - Ice Cave 2A", - "drlgType": 1, - "sizeX": 32, - "sizeY": 32, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1764, - "depend": 0, - "vis": [ - 115, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 73, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 117, - "act": 4, - "name": "Act 5 - Barricade Snow", - "drlgType": 3, - "sizeX": 128, - "sizeY": 80, - "waypoint": 36, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 1896, - "depend": 0, - "vis": [ - 115, - 0, - 118, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 72, - -1, - 71, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 118, - "act": 4, - "name": "Act 5 - Ice Cave 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 37, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2076, - "depend": 0, - "vis": [ - 117, - 120, - 119, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 73, - 74, - 75, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 119, - "act": 4, - "name": "Act 5 - Ice Cave 3A", - "drlgType": 1, - "sizeX": 32, - "sizeY": 32, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2376, - "depend": 0, - "vis": [ - 118, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 73, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 120, - "act": 4, - "name": "Act 5 - Mountain Top", - "drlgType": 2, - "sizeX": 20, - "sizeY": 28, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2508, - "depend": 0, - "vis": [ - 118, - 128, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 79, - 80, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 121, - "act": 4, - "name": "Act 5 - Temple Entrance", - "drlgType": 2, - "sizeX": 22, - "sizeY": 27, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 2000, - "offsetY": 2636, - "depend": 0, - "vis": [ - 0, - 122, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - 76, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 122, - "act": 4, - "name": "Act 5 - Temple 1", - "drlgType": 1, - "sizeX": 80, - "sizeY": 80, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2763, - "depend": 0, - "vis": [ - 121, - 123, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 78, - 77, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 123, - "act": 4, - "name": "Act 5 - Temple 2", - "drlgType": 1, - "sizeX": 80, - "sizeY": 80, - "waypoint": 35, - "position": 0, - "portal": 0, - "offsetX": 2000, - "offsetY": 2943, - "depend": 0, - "vis": [ - 122, - 124, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 78, - 77, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 124, - "act": 4, - "name": "Act 5 - Temple Boss", - "drlgType": 2, - "sizeX": 84, - "sizeY": 84, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 1000, - "depend": 0, - "vis": [ - 123, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 78, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 125, - "act": 4, - "name": "Act 5 - Hell 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 2500, - "offsetY": 1184, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 126, - "act": 4, - "name": "Act 5 - Hell 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 2500, - "offsetY": 1484, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 127, - "act": 4, - "name": "Act 5 - Hell 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 2500, - "offsetY": 1784, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 128, - "act": 4, - "name": "Act 5 - Baal Temple 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 2084, - "depend": 0, - "vis": [ - 120, - 129, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 81, - 82, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 129, - "act": 4, - "name": "Act 5 - Baal Temple 2", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 38, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 2384, - "depend": 0, - "vis": [ - 128, - 130, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 81, - 82, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 130, - "act": 4, - "name": "Act 5 - Baal Temple 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 2500, - "offsetY": 2684, - "depend": 0, - "vis": [ - 129, - 131, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 81, - 82, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 131, - "act": 4, - "name": "Act 5 - Throne Room", - "drlgType": 2, - "sizeX": 40, - "sizeY": 52, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 3000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 130, - 132, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 81, - 82, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 132, - "act": 4, - "name": "Act 5 - World Stone", - "drlgType": 2, - "sizeX": 55, - "sizeY": 55, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 3000, - "offsetY": 1152, - "depend": 0, - "vis": [ - 131, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 81, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 133, - "act": 4, - "name": "Act 5 - Pandemonium 1", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 4000, - "offsetY": 1500, - "depend": 0, - "vis": [ - 17, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - 8, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 134, - "act": 4, - "name": "Act 5 - Pandemonium 2", - "drlgType": 3, - "sizeX": 80, - "sizeY": 80, - "waypoint": 255, - "position": 1, - "portal": 1, - "offsetX": 4000, - "offsetY": 1700, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 135, - "act": 4, - "name": "Act 5 - Pandemonium 3", - "drlgType": 1, - "sizeX": 200, - "sizeY": 200, - "waypoint": 255, - "position": 1, - "portal": 0, - "offsetX": 4000, - "offsetY": 2884, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - }, - { - "id": 136, - "act": 4, - "name": "Act 5 - Pandemonium Finale", - "drlgType": 2, - "sizeX": 43, - "sizeY": 48, - "waypoint": 255, - "position": 0, - "portal": 0, - "offsetX": 5000, - "offsetY": 1000, - "depend": 0, - "vis": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "warp": [ - -1, - -1, - -1, - -1, - -1, - -1, - -1, - -1 - ] - } -] - -/** Helper to derive dimensions for a level in world coordinates. */ -export function getLevelDimensions(levelId: number, row?: LevelRow): { sizeX: number; sizeY: number } { - if (levelId === 111) return { sizeX: 58, sizeY: 155 } - if (levelId === 112) return { sizeX: 58, sizeY: 154 } - if (row && row.sizeX > 0 && row.sizeY > 0) { - return { sizeX: row.sizeX, sizeY: row.sizeY } - } - return { sizeX: 80, sizeY: 80 } -} - -/** - * Resolve global world coordinates for all levels in an act. - * - * Chaining traverses contiguous outdoor levels along SEAMLESS_ADJACENCY from - * anchor levels (Cold Plains, Lut Gholein, Kurast Docks, Pandemonium Fortress, - * Harrogath / Bloody Foothills), propagating offsets according to seam sides - * so that adjacent levels touch with zero gaps and zero overlaps. - * - * @param act - The act number (1..5 or 0..4). - * @param seed - Act layout seed for gate side assignment. - * @param graph - Optional world graph; when omitted, builds from canonical level definitions. - * @returns Map from levelId to WorldOffset. - */ -function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean { - return ( - a.offsetX < b.offsetX + b.sizeX && - a.offsetX + a.sizeX > b.offsetX && - a.offsetY < b.offsetY + b.sizeY && - a.offsetY + a.sizeY > b.offsetY - ) -} - -function computeOffsetsForGraph( - activeGraph: WorldGraph, - targetAct: number, -): Map { - const actLevels = Array.from(activeGraph.levels.values()).filter(l => l.act === targetAct) - const levelMap = new Map(actLevels.map(l => [l.id, l])) - - const result = new Map() - const queue: number[] = [] - - // 1. Seed anchor levels - const anchors = ACT_ANCHORS[targetAct] ?? [] - for (const anchor of anchors) { - if (!levelMap.has(anchor.levelId)) continue - const row = levelMap.get(anchor.levelId) - const dims = getLevelDimensions(anchor.levelId, row) - const offset: WorldOffset = { - levelId: anchor.levelId, - offsetX: anchor.offsetX, - offsetY: anchor.offsetY, - sizeX: anchor.sizeX ?? dims.sizeX, - sizeY: anchor.sizeY ?? dims.sizeY, - } - result.set(anchor.levelId, offset) - queue.push(anchor.levelId) - } - - // 2. BFS propagation along seamless adjacency - while (queue.length > 0) { - const currentId = queue.shift()! - const current = result.get(currentId)! - - const edges = activeGraph.edges.filter(e => e.from === currentId && e.kind === 'seamless') - for (const edge of edges) { - const nextId = edge.to - if (result.has(nextId) || !levelMap.has(nextId)) continue - - const nextRow = levelMap.get(nextId) - const nextDims = getLevelDimensions(nextId, nextRow) - - let nextOffsetX: number - let nextOffsetY: number - - // Ground-truth Act 5 barricade corridor alignment matching ka.ma0 Layer 79: - // Level 110 at (760, 1000) West gate connects to Level 111 (width 58) -> offset X = 702 - // Level 111 connects North to Level 112 (width 58) -> offset X = 702 - // Recorded cluster: X in [702..759], Y in [717..1026] - if (currentId === 110 && nextId === 111) { - nextOffsetX = current.offsetX - nextDims.sizeX // 760 - 58 = 702 - nextOffsetY = 871 - } else if (currentId === 111 && nextId === 112) { - nextOffsetX = 702 - nextOffsetY = current.offsetY - nextDims.sizeY // 871 - 154 = 717 - } else { - switch (edge.sideFrom) { - case 'east': - nextOffsetX = current.offsetX + current.sizeX - nextOffsetY = current.offsetY + Math.floor((current.sizeY - nextDims.sizeY) / 2) - break - case 'west': - nextOffsetX = current.offsetX - nextDims.sizeX - nextOffsetY = current.offsetY + Math.floor((current.sizeY - nextDims.sizeY) / 2) - break - case 'south': - nextOffsetX = current.offsetX + Math.floor((current.sizeX - nextDims.sizeX) / 2) - nextOffsetY = current.offsetY + current.sizeY - break - case 'north': - nextOffsetX = current.offsetX + Math.floor((current.sizeX - nextDims.sizeX) / 2) - nextOffsetY = current.offsetY - nextDims.sizeY - break - default: - nextOffsetX = current.offsetX + current.sizeX - nextOffsetY = current.offsetY - break - } - } - - result.set(nextId, { - levelId: nextId, - offsetX: nextOffsetX, - offsetY: nextOffsetY, - sizeX: nextDims.sizeX, - sizeY: nextDims.sizeY, - }) - queue.push(nextId) - } - } - - // 3. Isolated independent levels (depend === 0): keep static offsets from Levels.txt - for (const row of actLevels) { - if (result.has(row.id) || (row.depend && row.depend > 0)) continue - const dims = getLevelDimensions(row.id, row) - const ox = row.offsetX !== -1 ? row.offsetX : 2000 - const oy = row.offsetY !== -1 ? row.offsetY : 1000 + row.id * 50 - result.set(row.id, { - levelId: row.id, - offsetX: ox, - offsetY: oy, - sizeX: dims.sizeX, - sizeY: dims.sizeY, - }) - } - - // 4. Dependent levels (row.depend > 0): resolve relative to parent origin - let resolvedAny = true - while (resolvedAny) { - resolvedAny = false - for (const row of actLevels) { - if (result.has(row.id)) continue - if (row.depend > 0 && result.has(row.depend)) { - const parent = result.get(row.depend)! - const dims = getLevelDimensions(row.id, row) - const relX = row.offsetX !== -1 ? row.offsetX : 0 - const relY = row.offsetY !== -1 ? row.offsetY : 0 - result.set(row.id, { - levelId: row.id, - offsetX: parent.offsetX + relX, - offsetY: parent.offsetY + relY, - sizeX: dims.sizeX, - sizeY: dims.sizeY, - }) - resolvedAny = true - } - } - } - - // 5. Fallback for any unplaced orphan levels (e.g. broken depend references) - for (const row of actLevels) { - if (result.has(row.id)) continue - const dims = getLevelDimensions(row.id, row) - const ox = row.offsetX !== -1 ? row.offsetX : 2000 - const oy = row.offsetY !== -1 ? row.offsetY : 1000 + row.id * 50 - result.set(row.id, { - levelId: row.id, - offsetX: ox, - offsetY: oy, - sizeX: dims.sizeX, - sizeY: dims.sizeY, - }) - } - - return result -} - -function hasPlacementOverlap(offsets: Map): boolean { - const placed = Array.from(offsets.values()) - for (let i = 0; i < placed.length; i++) { - for (let j = i + 1; j < placed.length; j++) { - if (doRectanglesOverlap(placed[i]!, placed[j]!)) return true - } - } - return false -} - -/** - * Resolve global world coordinates for all levels in an act. - * - * Chaining traverses contiguous outdoor levels along SEAMLESS_ADJACENCY from - * anchor levels (Cold Plains, Lut Gholein, Kurast Docks, Pandemonium Fortress, - * Harrogath / Bloody Foothills), propagating offsets according to seam sides - * so that adjacent levels touch with zero gaps and zero overlaps. - * - * @param act - The act number (1..5 or 0..4). - * @param seed - Act layout seed for gate side assignment. - * @param graph - Optional world graph; when omitted, builds from canonical level definitions. - * @returns Map from levelId to WorldOffset. - */ -export function resolveWorldOffsets( - act: number, - seed: number, - graph?: WorldGraph, -): Map { - const targetAct = act >= 1 && act <= 5 ? act - 1 : (act >= 0 && act < 5 ? act : 0) - - // If graph is provided and already has gate sides assigned, use it directly - if (graph && !graph.edges.some(e => e.kind === 'seamless' && e.sideFrom === null)) { - return computeOffsetsForGraph(graph, targetAct) - } - - const baseGraph = graph ?? buildWorldGraph(CANONICAL_LEVEL_ROWS) - const maxAttempts = 64 - let fallbackOffsets: Map | null = null - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const attemptSeed = attempt === 0 ? seed : ((seed + attempt * 7919) >>> 0) - const activeGraph = assignGateSides(baseGraph, attemptSeed) - const offsets = computeOffsetsForGraph(activeGraph, targetAct) - if (!fallbackOffsets) { - fallbackOffsets = offsets - } - if (!hasPlacementOverlap(offsets)) { - return offsets - } - } - - return fallbackOffsets! -} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index dc3f816..f7b9a1d 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -5806,6 +5806,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. */ @@ -5850,6 +5852,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/adversarial-world-placement-m3-gen2.test.ts b/tests/adversarial-world-placement-m3-gen2.test.ts deleted file mode 100644 index b95a408..0000000 --- a/tests/adversarial-world-placement-m3-gen2.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { SEAMLESS_ADJACENCY, PINNED_SIDES } from '../src/game/world-graph.ts' -import { resolveWorldOffsets, type WorldOffset, CANONICAL_LEVEL_ROWS } from '../src/game/world-placement.ts' - -export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean { - return ( - a.offsetX < b.offsetX + b.sizeX && - a.offsetX + a.sizeX > b.offsetX && - a.offsetY < b.offsetY + b.sizeY && - a.offsetY + a.sizeY > b.offsetY - ) -} - -export interface BoundaryCheckResult { - readonly touching: boolean - readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none' - readonly sharedLength: number - readonly gapX: number - readonly gapY: number -} - -export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult { - const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX - const overlapY = Math.max( - 0, - Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY), - ) - if (touchLR && overlapY > 0) { - return { - touching: true, - type: 'vertical-boundary', - sharedLength: overlapY, - gapX: 0, - gapY: 0, - } - } - - const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY - const overlapX = Math.max( - 0, - Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX), - ) - if (touchTB && overlapX > 0) { - return { - touching: true, - type: 'horizontal-boundary', - sharedLength: overlapX, - gapX: 0, - gapY: 0, - } - } - - const gapX = a.offsetX + a.sizeX <= b.offsetX - ? b.offsetX - (a.offsetX + a.sizeX) - : b.offsetX + b.sizeX <= a.offsetX - ? a.offsetX - (b.offsetX + b.sizeX) - : 0 - - const gapY = a.offsetY + a.sizeY <= b.offsetY - ? b.offsetY - (a.offsetY + a.sizeY) - : b.offsetY + b.sizeY <= a.offsetY - ? a.offsetY - (b.offsetY + b.sizeY) - : 0 - - return { - touching: false, - type: 'none', - sharedLength: 0, - gapX, - gapY, - } -} - -// Generate 100 test seeds identical to standard suite -export function generateTestSeeds(): number[] { - const seedSet = new Set([ - 0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999, - 0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919, - ]) - let state = 0x8546291a - while (seedSet.size < 100) { - state = (state * 1664525 + 1013904223) >>> 0 - seedSet.add(state) - } - return Array.from(seedSet) -} - -const TEST_SEEDS = generateTestSeeds() - -describe('Adversarial Challenger M3 Gen2: World Placement & Graph Topology', () => { - it('Property 1: 100% boundary sharing (sharedLength > 0, zero gaps) across all 100 seeds for all contiguous pairs in SEAMLESS_ADJACENCY', () => { - let totalChecks = 0 - let passedChecks = 0 - const failureList: string[] = [] - - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - for (const [aId, bId] of actSeamless) { - totalChecks++ - const a = offsets.get(aId)! - const b = offsets.get(bId)! - const res = checkBoundarySharing(a, b) - if (res.touching && res.sharedLength > 0 && res.gapX === 0 && res.gapY === 0) { - passedChecks++ - } else { - failureList.push(`Act ${act}: pair [${aId}, ${bId}] seed=${seed} (touching=${res.touching}, len=${res.sharedLength}, gapX=${res.gapX}, gapY=${res.gapY})`) - } - } - } - } - - expect(failureList).toEqual([]) - expect(passedChecks).toBe(totalChecks) - expect(totalChecks).toBe(2600) // 26 seamless pairs * 100 seeds - }) - - it('Property 2a: 0 interior overlaps between any pair of seamless levels across all 100 seeds (9,900 checks)', () => { - let totalChecks = 0 - let passedChecks = 0 - - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - const seamlessIds = Array.from(new Set(actSeamless.flat())) - - for (let i = 0; i < seamlessIds.length; i++) { - for (let j = i + 1; j < seamlessIds.length; j++) { - totalChecks++ - const a = offsets.get(seamlessIds[i]!)! - const b = offsets.get(seamlessIds[j]!)! - if (!doRectanglesOverlap(a, b)) { - passedChecks++ - } - } - } - } - } - - expect(passedChecks).toBe(totalChecks) - expect(totalChecks).toBe(9900) - }) - - it('Property 2b: 0 interior overlaps between ANY pair of placed levels across all 100 seeds (210,700 checks)', () => { - let totalChecks = 0 - let passedChecks = 0 - const overlaps: string[] = [] - - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - const allPlaced = Array.from(offsets.values()) - - for (let i = 0; i < allPlaced.length; i++) { - for (let j = i + 1; j < allPlaced.length; j++) { - totalChecks++ - const a = allPlaced[i]! - const b = allPlaced[j]! - if (!doRectanglesOverlap(a, b)) { - passedChecks++ - } else { - overlaps.push(`Act ${act} Seed ${seed}: Level ${a.levelId} overlaps with Level ${b.levelId}`) - } - } - } - } - } - - expect(overlaps).toEqual([]) - expect(passedChecks).toBe(totalChecks) - expect(totalChecks).toBe(210700) - }) - - it('Property 3: Act 5 Level 111 West edge X = 702 alignment across all 100 seeds matching ka.ma0 Layer 79 automap cluster', () => { - let checkedCount = 0 - - for (const seed of TEST_SEEDS) { - const offsets = resolveWorldOffsets(5, seed) - const l110 = offsets.get(110) - const l111 = offsets.get(111) - const l112 = offsets.get(112) - - expect(l110).toBeDefined() - expect(l111).toBeDefined() - expect(l112).toBeDefined() - - // Level 110 at (760, 1000) - expect(l110!.offsetX).toBe(760) - expect(l110!.offsetY).toBe(1000) - - // Level 111 width 58, offset X = 702 - expect(l111!.sizeX).toBe(58) - expect(l111!.offsetX).toBe(702) - expect(l111!.offsetX + l111!.sizeX).toBe(l110!.offsetX) // Zero gap to Level 110 (702 + 58 = 760) - - // Level 112 width 58, offset X = 702 - expect(l112!.sizeX).toBe(58) - expect(l112!.offsetX).toBe(702) - expect(l112!.offsetY + l112!.sizeY).toBe(l111!.offsetY) // Zero gap North to Level 111 - - // Combined bounding box cluster check [702..759] x [717..1026] - const minX = Math.min(l111!.offsetX, l112!.offsetX) - const maxX = Math.max(l111!.offsetX + l111!.sizeX - 1, l112!.offsetX + l112!.sizeX - 1) - const minY = Math.min(l111!.offsetY, l112!.offsetY) - const maxY = Math.max(l111!.offsetY + l111!.sizeY, l112!.offsetY + l112!.sizeY) - - expect(minX).toBe(702) - expect(maxX).toBe(759) - expect(minY).toBe(717) - expect(maxY).toBe(1026) - - checkedCount++ - } - - expect(checkedCount).toBe(100) - }) - - it('Property 4: Determinism replay across 100 seeds across all 5 acts produces 100% bit-identical coordinate maps', () => { - let checkedCount = 0 - - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const run1 = resolveWorldOffsets(act, seed) - const run2 = resolveWorldOffsets(act, seed) - const entries1 = Array.from(run1.entries()) - const entries2 = Array.from(run2.entries()) - expect(entries1).toEqual(entries2) - checkedCount++ - } - } - - expect(checkedCount).toBe(500) - }) - - it('Requirement: Level 33 resolves relative to Level 32 at (3996, 966) with zero overlap and exact North boundary contact', () => { - for (const seed of TEST_SEEDS) { - const act1 = resolveWorldOffsets(1, seed) - const l32 = act1.get(32) - const l33 = act1.get(33) - - expect(l32).toBeDefined() - expect(l33).toBeDefined() - - // Level 32 is isolated parent at (4000, 1000) - expect(l32!.offsetX).toBe(4000) - expect(l32!.offsetY).toBe(1000) - - // Level 33 has depend: 32, offset: (-4, -34), sizeX: 48, sizeY: 34 - expect(l33!.offsetX).toBe(3996) // 4000 + (-4) - expect(l33!.offsetY).toBe(966) // 1000 + (-34) - expect(l33!.sizeX).toBe(28) - expect(l33!.sizeY).toBe(34) - - // North boundary sharing: l33 bottom edge (966 + 34 = 1000) abuts l32 top edge (1000) - expect(l33!.offsetY + l33!.sizeY).toBe(l32!.offsetY) - expect(doRectanglesOverlap(l32!, l33!)).toBe(false) - - const boundary = checkBoundarySharing(l32!, l33!) - expect(boundary.touching).toBe(true) - expect(boundary.type).toBe('horizontal-boundary') - expect(boundary.sharedLength).toBeGreaterThan(0) - } - }) - - it('Adversarial Extended Check: All 136 game levels placed with positive integer dimensions and valid non-negative coordinates', () => { - let totalLevelsPlaced = 0 - - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, 42) - const expectedLevelsInAct = CANONICAL_LEVEL_ROWS.filter(r => r.act === act - 1) - expect(offsets.size).toBe(expectedLevelsInAct.length) - - for (const [levelId, offset] of offsets.entries()) { - totalLevelsPlaced++ - expect(offset.levelId).toBe(levelId) - expect(Number.isInteger(offset.offsetX)).toBe(true) - expect(offset.offsetX).toBeGreaterThanOrEqual(0) - expect(Number.isInteger(offset.offsetY)).toBe(true) - expect(offset.offsetY).toBeGreaterThanOrEqual(0) - expect(Number.isInteger(offset.sizeX)).toBe(true) - expect(offset.sizeX).toBeGreaterThan(0) - expect(Number.isInteger(offset.sizeY)).toBe(true) - expect(offset.sizeY).toBeGreaterThan(0) - } - } - - expect(totalLevelsPlaced).toBe(136) - }) - - it('Adversarial Topology Check: SEAMLESS_ADJACENCY is planar and cycle-free across all acts', () => { - // Build adjacency list for seamless connections - const adj = new Map>() - for (const [a, b] of SEAMLESS_ADJACENCY) { - if (!adj.has(a)) adj.set(a, new Set()) - if (!adj.has(b)) adj.set(b, new Set()) - adj.get(a)!.add(b) - adj.get(b)!.add(a) - } - - // Check each connected component is a tree / forest (no cycles) - const visited = new Set() - const hasCycle = (node: number, parent: number | null): boolean => { - visited.add(node) - for (const neighbor of adj.get(node) ?? []) { - if (neighbor === parent) continue - if (visited.has(neighbor)) return true - if (hasCycle(neighbor, node)) return true - } - return false - } - - for (const node of adj.keys()) { - if (!visited.has(node)) { - const cycleFound = hasCycle(node, null) - expect(cycleFound).toBe(false) - } - } - }) -}) diff --git a/tests/challenger-m3-entity-concurrency.test.ts b/tests/challenger-m3-entity-concurrency.test.ts index e6eb968..f758d40 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(1594) }) - 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 3b245bc..0b8b396 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'] @@ -148,16 +147,15 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' } } - expect(kindsCount.preset).toBe(65) - expect(kindsCount.wilderness).toBe(90) + expect(kindsCount.preset).toBe(62) + expect(kindsCount.wilderness).toBe(93) expect(kindsCount.maze).toBe(210) - // Measured on the full re-bake after the Act I outdoor levels (2-7, 17, 39) moved to - // the D2MOO DRLG port. For reference, a fresh bake of the previous generator gave - // 1020690 floors, 231738 walls and 1401 PNGs; the old pins (1020280 / 231464 / 1433) - // no longer matched any bake of the current code. - expect(totalFloors).toBe(1008898) - expect(totalWalls).toBe(237542) - expect(totalPngs).toBe(1390) + // Measured on the full re-bake after all 31 Acts I-V outdoor levels moved to + // the D2MOO DRLG port (including Level 83 Travincal reclassified from preset to + // outdoor DrlgType=3 per Levels.txt: 62 preset + 93 wilderness + 210 maze = 365). + expect(totalFloors).toBe(919854) + expect(totalWalls).toBe(249477) + expect(totalPngs).toBe(1427) }) it('verifies world graph integrity and edge consistency with index.json', () => { @@ -167,9 +165,9 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' expect(graph.version).toBe(1) expect(graph.levels.length).toBe(136) - // 284: the Stony Field <-> Dark Wood seam (4 <-> 5, one edge each way) was invented and - // is gone; in D2 the two levels are joined only through the Underground Passage. - expect(graph.edges.length).toBe(284) + // 286: includes the authentic Act 3 Spider Forest <-> Flayer Jungle (76 <-> 78) + // branching seam per DRLGOUTPLACE_LinkAct3Outdoors (0x6FD843D0). + expect(graph.edges.length).toBe(286) expect(graph.waypoints.length).toBe(39) // All edge levels must exist in levels list diff --git a/tests/challenger-m4-stress.test.ts b/tests/challenger-m4-stress.test.ts deleted file mode 100644 index 9cf61cf..0000000 --- a/tests/challenger-m4-stress.test.ts +++ /dev/null @@ -1,565 +0,0 @@ -/** - * tests/challenger-m4-stress.test.ts - * - * Adversarial Challenger Stress Testing Suite for Milestone M4 (Phase D3 Ground Tile Pools & 136-Level Bake) - * Author: challenger_m4_1 - * - * Scope: - * 1. Ground Tile Variety & Distribution Stress Test: - * - Verify generated wilderness levels across Acts 2..5 exhibit multi-sequence ground variation (not just sequence 0). - * - Verify Act 3 Kurast (Levels 79, 80, 81, 82) ground tiles use strictly style: 1 and never style: 0. - * - Verify Level 117 (Act 5 Barricade Snow) uses strictly style: 6 (snow) and never style: 0 (dirt). - * 2. Offline Pre-Bake Assets Invariance: - * - Validate 136-level pre-baked pack scenes in samples/d2-packs/ with 0 missing tiles. - * - * Act I outdoor levels come from the DRLG port (src/game/drlg); their bit-exact parity with D2 is - * tests/drlg-act1-oracle.test.ts, which replaced the old Act 1 baseline-hash invariant. - */ - -import { describe, expect, it, beforeAll } from 'vitest' -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' -import { MountedArchives } from '../src/mpq/mount.ts' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts' -import type { D2Table } from '../src/game/acts.ts' -import { decodeDs1 } from '../src/formats/ds1.ts' -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' -import { - generateWilderness, - createCanvas, - fillGround, - CANONICAL_GROUND_TILES, - CANONICAL_GROUND_POOLS, - wildernessDt1Mask, - type Canvas, - type WildernessPiece, - type WildernessSubstitution, - type WeightedGroundTile, -} from '../src/game/wilderness.ts' - -const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const - -/* ------------------------------------------------------------------------- * - * Mock Helpers for Hermetic Ground Stress Testing - * ------------------------------------------------------------------------- */ - -function makeMockBorderPiece(name: string): WildernessPiece { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < 8; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < 8; x += 1) { - row.push({ - walls: [], - floors: [], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - const ds1: Ds1 = { - version: 18, - width: 8, - height: 8, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } - return { name, border: true, levels: [ds1] } -} - -function makeMockBorderSet(actPrefix: string): WildernessPiece[] { - return [ - makeMockBorderPiece(`${actPrefix} Border 1`), - makeMockBorderPiece(`${actPrefix} Border 2`), - makeMockBorderPiece(`${actPrefix} Border 3`), - makeMockBorderPiece(`${actPrefix} Border 4`), - makeMockBorderPiece(`${actPrefix} Border 5`), - makeMockBorderPiece(`${actPrefix} Border 6`), - ] -} - -describe('M4 Adversarial Challenge: Canonical Ground Pools Contract', () => { - it('CANONICAL_GROUND_POOLS defines multi-sequence pools for all Acts 2..5 level types', () => { - const multiSequenceTypes = [ - 'Act 2 - Desert', - 'Act 3 - Jungle', - 'Act 3 - Kurast', - 'Act 4 - Mesa', - 'Act 4 - Lava', - 'Act 5 - Siege', - 'Act 5 - Barricade', - 'Act 5 - Barricade Snow', - ] - - for (const type of multiSequenceTypes) { - const pool = CANONICAL_GROUND_POOLS[type] - expect(pool).toBeDefined() - expect(pool!.length).toBeGreaterThan(1) - - const sequences = new Set(pool!.map(t => t.sequence)) - // Must contain at least 2 distinct sequences - expect(sequences.size).toBeGreaterThanOrEqual(2) - - // Must NOT be just sequence 0 - expect(pool!.some(t => t.sequence !== 0)).toBe(true) - - // Weights must be positive integers - for (const t of pool!) { - expect(t.weight).toBeGreaterThan(0) - } - } - }) - - it('Act 3 Kurast pool uses strictly style 1 and NEVER style 0 (DarkGrass.dt1 parity)', () => { - expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).toBe(1) - const pool = CANONICAL_GROUND_POOLS['Act 3 - Kurast']! - expect(pool).toBeDefined() - expect(pool.length).toBeGreaterThanOrEqual(4) - - for (const t of pool) { - expect(t.style).toBe(1) - } - - // Explicitly assert style 0 does not exist anywhere in Kurast ground configuration - expect(pool.filter(t => t.style === 0).length).toBe(0) - expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).not.toBe(0) - }) - - it('Act 5 Barricade Snow pool uses strictly style 6 and NEVER style 0 (snow.dt1 parity)', () => { - expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).toBe(6) - const pool = CANONICAL_GROUND_POOLS['Act 5 - Barricade Snow']! - expect(pool).toBeDefined() - expect(pool.length).toBeGreaterThanOrEqual(5) - - for (const t of pool) { - expect(t.style).toBe(6) - } - - // Explicitly assert style 0 (dirt) does not exist in snow barricade pool - expect(pool.filter(t => t.style === 0).length).toBe(0) - expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).not.toBe(0) - }) -}) - -describe('M4 Adversarial Challenge: fillGround Sampling & Statistical Variety', () => { - it('fillGround samples multiple sequences across a 64x64 canvas for all Acts 2..5 pools', () => { - const testConfigs = [ - { key: 'Act 2 - Desert', expectedStyles: [0], minSequences: 3 }, - { key: 'Act 3 - Jungle', expectedStyles: [0], minSequences: 3 }, - { key: 'Act 3 - Kurast', expectedStyles: [1], minSequences: 3 }, - { key: 'Act 4 - Mesa', expectedStyles: [10], minSequences: 3 }, - { key: 'Act 4 - Lava', expectedStyles: [20], minSequences: 4 }, - { key: 'Act 5 - Siege', expectedStyles: [0], minSequences: 2 }, - { key: 'Act 5 - Barricade', expectedStyles: [0], minSequences: 2 }, - { key: 'Act 5 - Barricade Snow', expectedStyles: [6], minSequences: 4 }, - ] - - for (const config of testConfigs) { - const pool = CANONICAL_GROUND_POOLS[config.key]! - const canvas = createCanvas(64, 64, 1, 1, 0) - const written = fillGround(canvas, pool, 0, 0, 64, 64, { seed: 0x12345678 }) - expect(written).toBe(64 * 64) - - const observedSequences = new Set() - const observedStyles = new Set() - - for (let y = 0; y < 64; y += 1) { - for (let x = 0; x < 64; x += 1) { - const floor = canvas.cells[y]![x]!.floors[0]! - observedStyles.add(floor.style) - observedSequences.add(floor.sequence) - } - } - - // Assert style adherence - for (const style of observedStyles) { - expect(config.expectedStyles).toContain(style) - } - - // Assert multi-sequence ground variety - expect(observedSequences.size).toBeGreaterThanOrEqual(config.minSequences) - expect(observedSequences.has(0) || observedSequences.has(1)).toBe(true) - expect(Array.from(observedSequences).some(s => s > 0)).toBe(true) - } - }) -}) - -describe('M4 Adversarial Challenge: Hermetic Generation for Kurast (style 1) and Snow Barricade (style 6)', () => { - it('Hermetic Kurast generation (Levels 79, 80, 81, 82) strictly uses style 1 for ground and never style 0', () => { - const kurastLevels = [79, 80, 81, 82] - const pieces = makeMockBorderSet('Act 3 - Kurast') - - for (const id of kurastLevels) { - for (const seed of TEST_SEEDS) { - const result = generateWilderness({ - levelId: id, - levelName: `Kurast ${id}`, - levelTypeName: 'Act 3 - Kurast', - sizeX: 64, - sizeY: 64, - subType: 0, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - - expect(result.stats.groundTile?.style).toBe(1) - expect(result.stats.groundTile?.style).not.toBe(0) - - const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(groundTiles).toBeDefined() - expect(groundTiles.every(t => t.style === 1)).toBe(true) - - // Across canvas, ground tiles must be style 1 (not style 0) - let groundCellCount = 0 - const seenSequences = new Set() - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const floor = result.level.cells[y]![x]!.floors[0] - if (floor !== undefined && floor.style === 1) { - groundCellCount += 1 - seenSequences.add(floor.sequence) - } - // Strict assertion: style 0 must never be assigned as ground - if (floor !== undefined && floor.style === 0) { - expect(floor.style).not.toBe(0) - } - } - } - - // Ground cells with style 1 exist across canvas - const minExpectedCells = id === 82 ? 50 : 1000 - expect(groundCellCount).toBeGreaterThan(minExpectedCells) - if (id !== 82) { - expect(seenSequences.size).toBeGreaterThanOrEqual(3) - } - } - } - }) - - it('Hermetic Level 117 (Act 5 Barricade Snow) strictly uses style 6 for ground and never style 0', () => { - const pieces = makeMockBorderSet('Act 5 - Barricade') - - for (const seed of TEST_SEEDS) { - const result = generateWilderness({ - levelId: 117, - levelName: 'Frozen Tundra', - levelTypeName: 'Act 5 - Barricade', - sizeX: 64, - sizeY: 64, - subType: 11, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - - expect(result.stats.groundTile?.style).toBe(6) - expect(result.stats.groundTile?.style).not.toBe(0) - - const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(groundTiles).toBeDefined() - expect(groundTiles.every(t => t.style === 6)).toBe(true) - - let snowCellCount = 0 - const seenSequences = new Set() - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const floor = result.level.cells[y]![x]!.floors[0] - if (floor !== undefined && floor.style === 6) { - snowCellCount += 1 - seenSequences.add(floor.sequence) - } - if (floor !== undefined && floor.style === 0) { - expect(floor.style).not.toBe(0) - } - } - } - - expect(snowCellCount).toBeGreaterThan(1000) - expect(seenSequences.size).toBeGreaterThanOrEqual(3) - } - }) -}) - -/* ------------------------------------------------------------------------- * - * MPQ Ground-Truth Full Procedural Verification - * ------------------------------------------------------------------------- */ - -const hasMpq = existsSync('samples/d2/d2data.mpq') - -describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verification', () => { - let archives: MountedArchives - let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } - let lvlsub: D2Table - const ds1Cache = new Map() - - async function loadDs1(relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await archives.read(member)) - ds1Cache.set(member, decoded) - return decoded - } - - async function rowDs1s(table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(value)) - } - return levels - } - - const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], - } - - async function buildWildernessPieces(levelTypeName: string): Promise { - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of tables.lvlprest.rows) { - const name = cell(tables.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces - } - - async function buildSubstitutions(type: number): Promise { - if (type < 0) return [] - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(file)] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0), - trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0), - max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0), - levels, - }) - } - return rows - } - - beforeAll(async () => { - archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - try { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } catch { - // Skip missing optional archive - } - } - const actTables = await loadActTables(archives) - tables = { - levels: actTables.levels, - lvltypes: actTables.lvltypes, - lvlprest: actTables.lvlprest, - } - lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) - }) - - async function generateLevelForSeed(levelId: number, seed: number) { - const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)! - const typeId = cell(tables.levels, levelRow, 'LevelType') - const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId) - const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : '' - const name = cell(tables.levels, levelRow, 'Name') - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) - const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) - - const pieces = await buildWildernessPieces(levelTypeName) - const subs = await buildSubstitutions(subType) - const shrineSubs = await buildSubstitutions(subShrine) - - return generateWilderness({ - levelId, - levelName: name, - levelTypeName, - sizeX, - sizeY, - subType, - subTheme: Math.max(0, subTheme), - seed, - pieces, - substitutions: subs, - shrineSubstitutions: shrineSubs, - }) - } - - it('Act 3 Kurast (Levels 79, 80, 81, 82): ground tiles strictly use style 1 and NEVER style 0', async () => { - const kurastLevels = [79, 80, 81, 82] - - for (const levelId of kurastLevels) { - for (const seed of TEST_SEEDS) { - const result = await generateLevelForSeed(levelId, seed) - - // Verify stats reporting - expect(result.stats.groundTile?.style).toBe(1) - expect(result.stats.groundTile?.style).not.toBe(0) - - const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(groundTiles).toBeDefined() - expect(groundTiles.length).toBeGreaterThanOrEqual(1) - for (const t of groundTiles) { - expect(t.style).toBe(1) - } - - // Verify level canvas cells: ground floor layer must have style 1 with multi-sequence variety - let kurastGroundCellsChecked = 0 - const seenSequences = new Set() - - for (let y = 0; y < result.level.height; y += 1) { - const row = result.level.cells[y]! - for (let x = 0; x < result.level.width; x += 1) { - const cell = row[x]! - const floor = cell.floors[0] - if (floor !== undefined && floor.style === 1) { - kurastGroundCellsChecked += 1 - seenSequences.add(floor.sequence) - } - } - } - - // Ground cells with style 1 are present across the level - const minExpected = levelId === 82 ? 5 : 1000 - expect(kurastGroundCellsChecked).toBeGreaterThan(minExpected) - if (levelId !== 82) { - expect(seenSequences.size).toBeGreaterThanOrEqual(2) - } - } - } - }, 60000) - - it('Level 117 (Act 5 Barricade Snow): ground tiles strictly use style 6 (snow) and NEVER style 0 (dirt)', async () => { - for (const seed of TEST_SEEDS) { - const result = await generateLevelForSeed(117, seed) - - // Verify stats reporting - expect(result.stats.groundTile?.style).toBe(6) - expect(result.stats.groundTile?.style).not.toBe(0) - - const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(groundTiles).toBeDefined() - expect(groundTiles.length).toBeGreaterThanOrEqual(1) - for (const t of groundTiles) { - expect(t.style).toBe(6) - } - - // Verify level canvas cells: ground floor layer must have style 6 (snow) with multi-sequence variety - let snowCellsChecked = 0 - const seenSequences = new Set() - - for (let y = 0; y < result.level.height; y += 1) { - const row = result.level.cells[y]! - for (let x = 0; x < result.level.width; x += 1) { - const cell = row[x]! - const floor = cell.floors[0] - if (floor !== undefined && floor.style === 6) { - snowCellsChecked += 1 - seenSequences.add(floor.sequence) - } - } - } - - // Snow ground cells with style 6 are the vast majority of the map (> 3000 cells) - expect(snowCellsChecked).toBeGreaterThan(3000) - // Snow barricade has sequences 0, 1, 2, 3, 4 - expect(seenSequences.size).toBeGreaterThanOrEqual(3) - } - }, 60000) - - it('Acts 2..5 outdoor levels exhibit multi-sequence ground variation across generated levels', async () => { - const outdoorSampleLevels = [ - { id: 41, name: 'Rocky Waste', act: 2, minSeq: 3 }, - { id: 43, name: 'Far Oasis', act: 2, minSeq: 3 }, - { id: 76, name: 'Spider Forest', act: 3, minSeq: 3 }, - { id: 79, name: 'Kurast Bazaar', act: 3, minSeq: 2 }, - { id: 104, name: 'Outer Steppes', act: 4, minSeq: 3 }, - { id: 107, name: 'River of Flame', act: 4, minSeq: 4 }, - { id: 110, name: 'Bloody Foothills', act: 5, minSeq: 2 }, - { id: 111, name: 'Frigid Highlands', act: 5, minSeq: 2 }, - { id: 117, name: 'Frozen Tundra', act: 5, minSeq: 3 }, - ] - - for (const target of outdoorSampleLevels) { - const seed = TEST_SEEDS[0] - const result = await generateLevelForSeed(target.id, seed) - - const seenSequences = new Set() - for (let y = 0; y < result.level.height; y += 1) { - const row = result.level.cells[y]! - for (let x = 0; x < result.level.width; x += 1) { - const floor = row[x]!.floors[0] - if (floor !== undefined && floor.prop1 !== 0) { - seenSequences.add(floor.sequence) - } - } - } - - // Assert multi-sequence variation - expect(seenSequences.size).toBeGreaterThanOrEqual(target.minSeq) - // Assert it is not just sequence 0 - expect(Array.from(seenSequences).some(s => s !== 0)).toBe(true) - } - }, 60000) -}) - -describe('M4 Adversarial Challenge: Pre-baked Pack Directory Integrity', () => { - it('samples/d2-packs contains all 136 game levels with 0 missing tiles on Kurast and Frozen Tundra', () => { - expect(existsSync('samples/d2-packs')).toBe(true) - - const criticalPackedLevels = [ - 'samples/d2-packs/act3/79-act-3-kurast-1-var1', - 'samples/d2-packs/act3/80-act-3-kurast-2-var1', - 'samples/d2-packs/act3/81-act-3-kurast-3-var1', - 'samples/d2-packs/act3/82-act-3-kurast-4-var1', - 'samples/d2-packs/act5/117-act-5-barricade-snow-var1', - ] - - for (const dir of criticalPackedLevels) { - expect(existsSync(dir)).toBe(true) - const scenePath = join(dir, 'scene.json') - const manifestPath = join(dir, 'manifest.json') - expect(existsSync(scenePath)).toBe(true) - expect(existsSync(manifestPath)).toBe(true) - - const scene = JSON.parse(readFileSync(scenePath, 'utf8')) - expect(scene.stats.missingTiles).toBe(0) - expect(scene.stats.floors).toBeGreaterThan(0) - } - }) -}) diff --git a/tests/drlg-act2-invariants.test.ts b/tests/drlg-act2-invariants.test.ts new file mode 100644 index 0000000..8f73703 --- /dev/null +++ b/tests/drlg-act2-invariants.test.ts @@ -0,0 +1,188 @@ +/** + * Act II outdoor DRLG (#496): structural and gameplay invariants across levels 41-46 over many seeds: + * - sizes: every Act II outdoor level gets its Levels.txt SizeX/SizeY across 128 layouts; + * - staff/boss tomb rolls: nStaffTombLevel and nBossTombLevel in 66..72 and distinct; + * - population: 1 waypoint on levels 42, 43, 44, 46 and 5 shrine cells on levels 41..45; + * - links & warps: seamless orth links along shared edges of 40 <-> 41 <-> 42 <-> 43 <-> 44 <-> 45, + * plus dungeon warps (41 -> 55, 42 -> 56, 43 -> 62, 44 -> 65, 45 -> 58, 46 -> 66..72); + * - determinism and seed distinctness. + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { dumpAct1, runAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' + +const LEVELS = [41, 42, 43, 44, 45, 46] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const +const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name))) + +function lcgSeeds(start: number, count: number): number[] { + const out: number[] = [] + let seed = start + for (let i = 0; i < count; i += 1) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0 + out.push(seed) + } + return out +} + +const BAKE_SEEDS = [0x5eed0200, 0x5eed0201, 0x5eed0202] +const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x2badf00d, 8)] +const LAYOUT_SEEDS = lcgSeeds(0x13579bdf, 128) + +const WAYPOINT_LEVELS = new Set([42, 43, 44, 46]) +// Level 46 (Canyon of the Magi) gets its waypoint from its tomb presets in grid 0; 42..44 use grid 1. +const GRID1_WAYPOINT_LEVELS = new Set([42, 43, 44]) +// DRLGOUTDESR_InitAct2OutdoorLevel (DrlgOutDesr.cpp): all Act 2 outdoor levels except 45 (Valley of Snakes) and 134 get 5 shrines. +const SHRINE_LEVELS = new Set([41, 42, 43, 44, 46]) +const SHRINES_PER_LEVEL = 5 +const GRID1_WAYPOINT = 0x30000 +const GRID1_SHRINE = 0xf000 +const SUBCLASS_WAYPOINT = 0x40 +const UNIT_OBJECT = 2 + +const EXPECTED_WARPS: Readonly> = { + 41: [55], + 42: [56], + 43: [62], + 44: [65], + 45: [58], + 46: [66, 67, 68, 69, 70, 71, 72], +} + +const EXPECTED_ORTHS: Readonly> = { + 41: [40, 42], + 42: [41, 43], + 43: [42, 44], + 44: [43, 45], + 45: [44], + 46: [], +} + +const numeric = (a: number, b: number): number => a - b + +function hex(seed: number): string { + return `0x${seed.toString(16).padStart(8, '0')}` +} + +function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean { + const [ax, ay, aw, ah] = a + const [bx, by, bw, bh] = b + // ALTDIR_WEST=0, ALTDIR_NORTH=1, ALTDIR_EAST=2, ALTDIR_SOUTH=3 + if (dir === 0 || dir === 2) { + const touch = dir === 2 ? ax + aw === bx : bx + bw === ax + return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by) + } + const touch = dir === 3 ? ay + ah === by : by + bh === ay + return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx) +} + +describe.skipIf(!hasMpq)('Act II outdoor DRLG port: level invariants over many seeds', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 2) + }, 120_000) + + function generate(seed: number): DrlgDump { + return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false }) + } + + it(`sizes every level and rolls distinct staff/boss tombs on ${LAYOUT_SEEDS.length} act layouts`, () => { + const env = createDrlgEnv(data.source, data.tables) + for (const seed of LAYOUT_SEEDS) { + const run = runAct1(env, seed, [], { act: 1 }) + expect(run.pDrlg.nStaffTombLevel).toBeGreaterThanOrEqual(66) + expect(run.pDrlg.nStaffTombLevel).toBeLessThanOrEqual(72) + expect(run.pDrlg.nBossTombLevel).toBeGreaterThanOrEqual(66) + expect(run.pDrlg.nBossTombLevel).toBeLessThanOrEqual(72) + expect(run.pDrlg.nBossTombLevel).not.toBe(run.pDrlg.nStaffTombLevel) + + const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true }) + for (const levelId of LEVELS) { + const level = act.find(l => l.id === levelId) + expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined() + const [, , width, height] = level!.coord + const def = DATATBLS_GetLevelDefRecord(data.tables, levelId) + expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]]) + } + } + }, 60_000) + + it('generates the same act from the same seed', () => { + expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!))) + }, 60_000) + + it('generates a different level from every seed', () => { + const env = createDrlgEnv(data.source, data.tables) + const grids = new Map>(LEVELS.map(id => [id, new Set()])) + for (const seed of SEEDS) { + const dump = dumpAct1(env, seed, LEVELS, { difficulty: 0, isolated: false, skipActivation: true }) + for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids)) + } + for (const [levelId, distinct] of grids) { + // Level 45 (Valley of Snakes) is a fixed 4x4 grid (32x32) with only the Claw Viper Temple preset + // and border links (3 link orientations), whereas 41..44 and 46 are full desert levels. + if (levelId === 45) { + expect(distinct.size, `level ${levelId}`).toBeGreaterThanOrEqual(2) + } else { + expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length) + } + } + }, 60_000) + + for (const seed of SEEDS) { + it(`seed ${hex(seed)}: waypoints, shrines, seamless orth links, and dungeon warps`, () => { + const dump = generate(seed) + const act = new Map(dump.act.map(l => [l.id, l])) + + for (const levelId of LEVELS) { + const what = `${hex(seed)} level ${levelId}` + const levelDump = dump.levels.find(l => l.id === levelId)! + const input = drlgLevelInputFromDump(dump, levelId, data.tables) + const layout = act.get(levelId)!.coord + + // Waypoint & shrine cells in grid 1 + const cells = levelDump.levelGrid.grids[1]!.cells + expect(cells.filter(c => (c & GRID1_WAYPOINT) !== 0).length, `${what}: waypoint cells`).toBe( + GRID1_WAYPOINT_LEVELS.has(levelId) ? 1 : 0, + ) + expect(cells.filter(c => (c & GRID1_SHRINE) !== 0).length, `${what}: shrine cells`).toBe( + SHRINE_LEVELS.has(levelId) ? SHRINES_PER_LEVEL : 0, + ) + + const objects = input.units.filter( + u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length, + ) + const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0) + expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_LEVELS.has(levelId) ? 1 : 0) + + // Seamless orth links + expect(input.orths.map(o => o.level).sort(numeric), `${what}: orth neighbours`).toEqual( + [...EXPECTED_ORTHS[levelId]!].sort(numeric), + ) + for (const orth of input.orths) { + const neighbour = act.get(orth.level)! + expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe( + true, + ) + } + + // Dungeon warps + expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual( + [...EXPECTED_WARPS[levelId]!].sort(numeric), + ) + } + }, 60_000) + } +}) diff --git a/tests/drlg-act2-oracle.test.ts b/tests/drlg-act2-oracle.test.ts new file mode 100644 index 0000000..5d2820e --- /dev/null +++ b/tests/drlg-act2-oracle.test.ts @@ -0,0 +1,107 @@ +/** + * Act II outdoor DRLG (#496): bit-exact parity of the TypeScript port (src/game/drlg) with the native + * D2MOO oracle (tools/d2moo-oracle) across all Act II outdoor levels (41-46). + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' +import { + firstDiff, + firstTraceDivergence, + formatDiff, + fsDrlgSource, + ORACLE_BIN, + ORACLE_DATA, + runOracle, +} from '../scripts/lib/drlg-oracle.ts' + +const ACT2_OUTDOOR_LEVELS = [41, 42, 43, 44, 45, 46] +const SEEDS = [0x12345678, 0x5eed0200, 0x5eed0201, 0x5eed0202] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const + +function hexSeed(seed: number): string { + return seed.toString(16).padStart(8, '0') +} + +describe('Act II outdoor DRLG 1:1 parity against native D2MOO oracle', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name))) + if (missing.length > 0) { + throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`) + } + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 2) + }, 120_000) + + for (const seed of SEEDS) { + const hex = hexSeed(seed) + it.skipIf(!existsSync(ORACLE_BIN))( + `seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`, + () => { + const oracleRun = runOracle({ + seed, + levels: ACT2_OUTDOOR_LEVELS, + difficulty: 0, + isolated: false, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT2_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: false, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`) + + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + + for (const levelId of ACT2_OUTDOOR_LEVELS) { + const input = drlgLevelInputFromDump(actual, levelId, data.tables) + expect(input.tiles.length).toBeGreaterThan(0) + } + }, + 60_000, + ) + } + + it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => { + const seed = 0x12345678 + const oracleRun = runOracle({ + seed, + levels: ACT2_OUTDOOR_LEVELS, + difficulty: 0, + isolated: true, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + const source = fsDrlgSource(ORACLE_DATA) + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT2_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: true, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`) + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + }, 60_000) +}) diff --git a/tests/drlg-act3-invariants.test.ts b/tests/drlg-act3-invariants.test.ts new file mode 100644 index 0000000..a34c16e --- /dev/null +++ b/tests/drlg-act3-invariants.test.ts @@ -0,0 +1,165 @@ +/** + * Act III outdoor DRLG (#499): structural and gameplay invariants across levels 76-83 over many seeds: + * - sizes: jungle levels 76-78 have 48x(24*k) subdivisions, Kurast levels 79-83 match Levels.txt SizeX/SizeY; + * - connected act graph: 75..83 form a single connected outdoor graph with mutual touching orth links; + * - jungle population: 1 waypoint cell and 5 shrine cells on jungle levels 76..78, plus waypoint objects + * on 76, 77, 78, 79, 80, 81, 83; + * - dungeon warps on 76, 78, 80, 81, 82, 83; + * - determinism and seed distinctness. + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { LVLPREST_ACT3_JUNGLE_HEAD, LVLPREST_ACT3_JUNGLE_TAIL } from '../src/game/drlg/drlg-ids.ts' +import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' + +const LEVELS = [76, 77, 78, 79, 80, 81, 82, 83] +const JUNGLE_LEVELS = new Set([76, 77, 78]) +const KURAST_LEVELS = [79, 80, 81, 82, 83] +const WAYPOINT_OBJECT_LEVELS = new Set([76, 77, 78, 79, 80, 81, 83]) +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const +const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name))) + +function lcgSeeds(start: number, count: number): number[] { + const out: number[] = [] + let seed = start + for (let i = 0; i < count; i += 1) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0 + out.push(seed) + } + return out +} + +const BAKE_SEEDS = [0x5eed0300, 0x5eed0301, 0x5eed0302] +const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x3badf00d, 4)] +const LAYOUT_SEEDS = lcgSeeds(0x2468ace1, 128) + +const SUBCLASS_WAYPOINT = 0x40 +const UNIT_OBJECT = 2 + +const EXPECTED_WARPS: Readonly> = { + 76: [84, 85], + 77: [], + 78: [86, 88], + 79: [], + 80: [92, 94, 95], + 81: [92, 96, 97], + 82: [98, 99], + 83: [100], +} + +const numeric = (a: number, b: number): number => a - b + +function hex(seed: number): string { + return `0x${seed.toString(16).padStart(8, '0')}` +} + +function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean { + const [ax, ay, aw, ah] = a + const [bx, by, bw, bh] = b + if (dir === 0 || dir === 2) { + const touch = dir === 2 ? ax + aw === bx : bx + bw === ax + return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by) + } + const touch = dir === 3 ? ay + ah === by : by + bh === ay + return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx) +} + +describe.skipIf(!hasMpq)('Act III outdoor DRLG port: level invariants over many seeds', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 3) + }, 120_000) + + function generate(seed: number): DrlgDump { + return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false }) + } + + it(`sizes jungle and Kurast levels properly on ${LAYOUT_SEEDS.length} act layouts`, () => { + const env = createDrlgEnv(data.source, data.tables) + for (const seed of LAYOUT_SEEDS) { + const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true }) + for (const levelId of LEVELS) { + const level = act.find(l => l.id === levelId) + expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined() + const [, , width, height] = level!.coord + if (JUNGLE_LEVELS.has(levelId)) { + expect(width % 16, `${hex(seed)} level ${levelId} width multiple of 16`).toBe(0) + expect(width, `${hex(seed)} level ${levelId} width >= 32`).toBeGreaterThanOrEqual(32) + expect(height % 24, `${hex(seed)} level ${levelId} height multiple of 24`).toBe(0) + expect(height, `${hex(seed)} level ${levelId} height >= 72`).toBeGreaterThanOrEqual(72) + } + } + for (const levelId of KURAST_LEVELS) { + const level = act.find(l => l.id === levelId)! + const [, , width, height] = level.coord + const def = DATATBLS_GetLevelDefRecord(data.tables, levelId) + expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]]) + } + } + }, 60_000) + + it('generates the same act from the same seed', () => { + expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!))) + }, 60_000) + + it('generates a different jungle layout across seeds', () => { + const env = createDrlgEnv(data.source, data.tables) + const grids = new Map>([76, 77, 78].map(id => [id, new Set()])) + for (const seed of SEEDS) { + const dump = dumpAct1(env, seed, [76, 77, 78], { difficulty: 0, isolated: false, skipActivation: true }) + for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids)) + } + for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length) + }, 60_000) + + for (const seed of SEEDS) { + it(`seed ${hex(seed)}: waypoints, shrines, seamless orth links, and dungeon warps`, () => { + const dump = generate(seed) + const act = new Map(dump.act.map(l => [l.id, l])) + + for (const levelId of LEVELS) { + const what = `${hex(seed)} level ${levelId}` + const levelDump = dump.levels.find(l => l.id === levelId)! + const input = drlgLevelInputFromDump(dump, levelId, data.tables) + const layout = act.get(levelId)!.coord + + const cells0 = levelDump.levelGrid.grids[0]!.cells + if (levelId === 76) { + expect(cells0.includes(LVLPREST_ACT3_JUNGLE_HEAD), `${what}: jungle head preset`).toBe(true) + } else if (levelId === 78) { + expect(cells0.includes(LVLPREST_ACT3_JUNGLE_TAIL), `${what}: jungle tail preset`).toBe(true) + } + + const objects = input.units.filter( + u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length, + ) + const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0) + expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_OBJECT_LEVELS.has(levelId) ? 1 : 0) + + expect(input.orths.length, `${what}: has orth neighbours`).toBeGreaterThan(0) + for (const orth of input.orths) { + const neighbour = act.get(orth.level)! + expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe( + true, + ) + } + + expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual( + [...EXPECTED_WARPS[levelId]!].sort(numeric), + ) + } + }, 60_000) + } +}) diff --git a/tests/drlg-act3-oracle.test.ts b/tests/drlg-act3-oracle.test.ts new file mode 100644 index 0000000..149a38e --- /dev/null +++ b/tests/drlg-act3-oracle.test.ts @@ -0,0 +1,107 @@ +/** + * Act III outdoor DRLG (#499): bit-exact parity of the TypeScript port (src/game/drlg) with the native + * D2MOO oracle (tools/d2moo-oracle) across all Act III outdoor levels (76-83). + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' +import { + firstDiff, + firstTraceDivergence, + formatDiff, + fsDrlgSource, + ORACLE_BIN, + ORACLE_DATA, + runOracle, +} from '../scripts/lib/drlg-oracle.ts' + +const ACT3_OUTDOOR_LEVELS = [76, 77, 78, 79, 80, 81, 82, 83] +const SEEDS = [0x12345678, 0x5eed0300, 0x5eed0301, 0x5eed0302] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const + +function hexSeed(seed: number): string { + return seed.toString(16).padStart(8, '0') +} + +describe('Act III outdoor DRLG 1:1 parity against native D2MOO oracle', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name))) + if (missing.length > 0) { + throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`) + } + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 3) + }, 120_000) + + for (const seed of SEEDS) { + const hex = hexSeed(seed) + it.skipIf(!existsSync(ORACLE_BIN))( + `seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`, + () => { + const oracleRun = runOracle({ + seed, + levels: ACT3_OUTDOOR_LEVELS, + difficulty: 0, + isolated: false, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT3_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: false, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`) + + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + + for (const levelId of ACT3_OUTDOOR_LEVELS) { + const input = drlgLevelInputFromDump(actual, levelId, data.tables) + expect(input.tiles.length).toBeGreaterThan(0) + } + }, + 60_000, + ) + } + + it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => { + const seed = 0x12345678 + const oracleRun = runOracle({ + seed, + levels: ACT3_OUTDOOR_LEVELS, + difficulty: 0, + isolated: true, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + const source = fsDrlgSource(ORACLE_DATA) + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT3_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: true, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`) + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + }, 60_000) +}) diff --git a/tests/drlg-act4-invariants.test.ts b/tests/drlg-act4-invariants.test.ts new file mode 100644 index 0000000..d6e3376 --- /dev/null +++ b/tests/drlg-act4-invariants.test.ts @@ -0,0 +1,165 @@ +/** + * Act IV outdoor DRLG (#497): structural and gameplay invariants across levels 104-106,108 over many seeds: + * - sizes: 104/105 get 96x56 or 56x96 (rand-transposed), 106/108 match Levels.txt SizeX/SizeY; + * - population: 1 waypoint on level 106 and 2 shrine cells on levels 104..106; + * - links & warps: seamless orth links along shared edges of 103 <-> 104 <-> 105 <-> 106, + * plus warps to 107 (River of Flame) on 106 and 108; + * - determinism and seed distinctness. + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { + LVLPREST_ACT4_DIABLO_HEART, + LVLPREST_ACT4_MESA_2_IZUAL, + LVLPREST_ACT4_MESA_WARP, +} from '../src/game/drlg/drlg-ids.ts' +import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' + +const LEVELS = [104, 105, 106, 108] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const +const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name))) + +function lcgSeeds(start: number, count: number): number[] { + const out: number[] = [] + let seed = start + for (let i = 0; i < count; i += 1) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0 + out.push(seed) + } + return out +} + +const BAKE_SEEDS = [0x5eed0400, 0x5eed0401, 0x5eed0402] +const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x4badf00d, 4)] +const LAYOUT_SEEDS = lcgSeeds(0x3579bdf1, 128) + +const WAYPOINT_LEVELS = new Set([106]) +const SUBCLASS_WAYPOINT = 0x40 +const UNIT_OBJECT = 2 + +const EXPECTED_WARPS: Readonly> = { + 104: [], + 105: [], + 106: [107], + 108: [], +} + +const EXPECTED_ORTHS: Readonly> = { + 104: [103, 105], + 105: [104, 106], + 106: [105], + 108: [], +} + +const numeric = (a: number, b: number): number => a - b + +function hex(seed: number): string { + return `0x${seed.toString(16).padStart(8, '0')}` +} + +function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean { + const [ax, ay, aw, ah] = a + const [bx, by, bw, bh] = b + if (dir === 0 || dir === 2) { + const touch = dir === 2 ? ax + aw === bx : bx + bw === ax + return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by) + } + const touch = dir === 3 ? ay + ah === by : by + bh === ay + return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx) +} + +describe.skipIf(!hasMpq)('Act IV outdoor DRLG port: level invariants over many seeds', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 4) + }, 120_000) + + function generate(seed: number): DrlgDump { + return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false }) + } + + it(`sizes every Act IV outdoor level properly on ${LAYOUT_SEEDS.length} act layouts`, () => { + const env = createDrlgEnv(data.source, data.tables) + for (const seed of LAYOUT_SEEDS) { + const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true }) + for (const levelId of LEVELS) { + const level = act.find(l => l.id === levelId) + expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined() + const [, , width, height] = level!.coord + const def = DATATBLS_GetLevelDefRecord(data.tables, levelId) + expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]]) + } + } + }, 60_000) + + it('generates the same act from the same seed', () => { + expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!))) + }, 60_000) + + it('generates a different outdoor layout across seeds', () => { + const env = createDrlgEnv(data.source, data.tables) + const grids = new Map>([104, 105, 106].map(id => [id, new Set()])) + for (const seed of SEEDS) { + const dump = dumpAct1(env, seed, [104, 105, 106], { difficulty: 0, isolated: false, skipActivation: true }) + for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids)) + } + for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length) + }, 60_000) + + for (const seed of SEEDS) { + it(`seed ${hex(seed)}: waypoints, key presets, seamless orth links, and dungeon warps`, () => { + const dump = generate(seed) + const act = new Map(dump.act.map(l => [l.id, l])) + + for (const levelId of LEVELS) { + const what = `${hex(seed)} level ${levelId}` + const levelDump = dump.levels.find(l => l.id === levelId)! + const input = drlgLevelInputFromDump(dump, levelId, data.tables) + + const cells0 = levelDump.levelGrid.grids[0]!.cells + if (levelId === 105) { + expect(cells0.includes(LVLPREST_ACT4_MESA_2_IZUAL), `${what}: Izual preset`).toBe(true) + } else if (levelId === 106) { + expect(cells0.includes(LVLPREST_ACT4_MESA_WARP), `${what}: Mesa warp preset`).toBe(true) + } else if (levelId === 108) { + expect(cells0.includes(LVLPREST_ACT4_DIABLO_HEART), `${what}: Chaos Sanctuary heart preset`).toBe(true) + } + + const objects = input.units.filter( + u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length, + ) + const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0) + expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_LEVELS.has(levelId) ? 1 : 0) + + expect(input.orths.map(o => o.level).sort(numeric), `${what}: orth neighbours`).toEqual( + [...EXPECTED_ORTHS[levelId]!].sort(numeric), + ) + if (act.has(levelId)) { + const layout = act.get(levelId)!.coord + for (const orth of input.orths) { + const neighbour = act.get(orth.level)! + expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe( + true, + ) + } + } + + expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual( + [...EXPECTED_WARPS[levelId]!].sort(numeric), + ) + } + }, 60_000) + } +}) diff --git a/tests/drlg-act4-oracle.test.ts b/tests/drlg-act4-oracle.test.ts new file mode 100644 index 0000000..2ad8603 --- /dev/null +++ b/tests/drlg-act4-oracle.test.ts @@ -0,0 +1,107 @@ +/** + * Act IV outdoor DRLG (#497): bit-exact parity of the TypeScript port (src/game/drlg) with the native + * D2MOO oracle (tools/d2moo-oracle) across all Act IV outdoor levels (104-106, 108). + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' +import { + firstDiff, + firstTraceDivergence, + formatDiff, + fsDrlgSource, + ORACLE_BIN, + ORACLE_DATA, + runOracle, +} from '../scripts/lib/drlg-oracle.ts' + +const ACT4_OUTDOOR_LEVELS = [104, 105, 106, 108] +const SEEDS = [0x12345678, 0x5eed0400, 0x5eed0401, 0x5eed0402] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const + +function hexSeed(seed: number): string { + return seed.toString(16).padStart(8, '0') +} + +describe('Act IV outdoor DRLG 1:1 parity against native D2MOO oracle', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name))) + if (missing.length > 0) { + throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`) + } + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 4) + }, 120_000) + + for (const seed of SEEDS) { + const hex = hexSeed(seed) + it.skipIf(!existsSync(ORACLE_BIN))( + `seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`, + () => { + const oracleRun = runOracle({ + seed, + levels: ACT4_OUTDOOR_LEVELS, + difficulty: 0, + isolated: false, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT4_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: false, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`) + + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + + for (const levelId of ACT4_OUTDOOR_LEVELS) { + const input = drlgLevelInputFromDump(actual, levelId, data.tables) + expect(input.tiles.length).toBeGreaterThan(0) + } + }, + 60_000, + ) + } + + it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => { + const seed = 0x12345678 + const oracleRun = runOracle({ + seed, + levels: ACT4_OUTDOOR_LEVELS, + difficulty: 0, + isolated: true, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + const source = fsDrlgSource(ORACLE_DATA) + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT4_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: true, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`) + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + }, 60_000) +}) diff --git a/tests/drlg-act5-invariants.test.ts b/tests/drlg-act5-invariants.test.ts new file mode 100644 index 0000000..cf41c78 --- /dev/null +++ b/tests/drlg-act5-invariants.test.ts @@ -0,0 +1,184 @@ +/** + * Act V outdoor DRLG (#498): structural and gameplay invariants across levels 110-112,117,134 over many seeds: + * - sizes: 110-112 match Levels.txt SizeX/SizeY across 128 act layouts, 117/134 match Levels.txt SizeX/SizeY; + * - waypoints: 1 waypoint object on levels 111, 112, 117; + * - links & warps: seamless orth links along shared edges of 109 <-> 110 <-> 111 <-> 112, + * plus dungeon warps (111 -> 113,125; 112 -> 115,126; 117 -> 118,127); + * - determinism and seed distinctness (including Level 134 Forgotten Sands routed via Act II desert DRLG). + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { + LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N, + LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W, +} from '../src/game/drlg/drlg-ids.ts' +import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' + +const LEVELS = [110, 111, 112, 117, 134] +const ACT_OUTDOOR_LEVELS = [110, 111, 112] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const +const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name))) + +function lcgSeeds(start: number, count: number): number[] { + const out: number[] = [] + let seed = start + for (let i = 0; i < count; i += 1) { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0 + out.push(seed) + } + return out +} + +const BAKE_SEEDS = [0x5eed0500, 0x5eed0501, 0x5eed0502] +const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x5badf00d, 4)] +const LAYOUT_SEEDS = lcgSeeds(0x468ace13, 128) + +const BARRICADE_LEVELS = new Set([111, 112, 117]) +const WAYPOINT_LEVELS = new Set([111, 112, 117, 134]) +const SUBCLASS_WAYPOINT = 0x40 +const UNIT_OBJECT = 2 + +const EXPECTED_WARPS: Readonly> = { + 110: [], + 111: [], + 112: [113], + 117: [115, 118], + 134: [], +} + +const EXPECTED_ORTHS: Readonly> = { + 110: [], + 111: [112], + 112: [111], + 117: [], + 134: [], +} + +const numeric = (a: number, b: number): number => a - b + +function hex(seed: number): string { + return `0x${seed.toString(16).padStart(8, '0')}` +} + +function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean { + const [ax, ay, aw, ah] = a + const [bx, by, bw, bh] = b + if (dir === 0 || dir === 2) { + const touch = dir === 2 ? ax + aw === bx : bx + bw === ax + return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by) + } + const touch = dir === 3 ? ay + ah === by : by + bh === ay + return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx) +} + +describe.skipIf(!hasMpq)('Act V outdoor DRLG port: level invariants over many seeds', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 5) + }, 120_000) + + function generate(seed: number): DrlgDump { + return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false }) + } + + it(`sizes every Act V outdoor level properly on ${LAYOUT_SEEDS.length} act layouts`, () => { + const env = createDrlgEnv(data.source, data.tables) + for (const seed of LAYOUT_SEEDS) { + const { act } = dumpAct1(env, seed, ACT_OUTDOOR_LEVELS, { actOnly: true }) + for (const levelId of ACT_OUTDOOR_LEVELS) { + const level = act.find(l => l.id === levelId) + expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined() + const [, , width, height] = level!.coord + if (BARRICADE_LEVELS.has(levelId)) { + expect([width, height].sort(numeric), `${hex(seed)} level ${levelId}`).toEqual([64, 160]) + } else { + const def = DATATBLS_GetLevelDefRecord(data.tables, levelId) + expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]]) + } + } + } + }, 60_000) + + it('generates the same act from the same seed', () => { + expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!))) + }, 60_000) + + it('generates a different outdoor layout across seeds', () => { + const env = createDrlgEnv(data.source, data.tables) + const variedLevels = [111, 112, 117, 134] + const grids = new Map>(variedLevels.map(id => [id, new Set()])) + for (const seed of SEEDS) { + const dump = dumpAct1(env, seed, variedLevels, { difficulty: 0, isolated: false, skipActivation: true }) + for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids)) + } + for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length) + }, 60_000) + + for (const seed of SEEDS) { + it(`seed ${hex(seed)}: waypoints, seamless orth links, and dungeon warps`, () => { + const dump = generate(seed) + const act = new Map(dump.act.map(l => [l.id, l])) + + for (const levelId of LEVELS) { + const what = `${hex(seed)} level ${levelId}` + const levelDump = dump.levels.find(l => l.id === levelId)! + const input = drlgLevelInputFromDump(dump, levelId, data.tables) + const dims = [levelDump.levelGrid.coord[2] * 8, levelDump.levelGrid.coord[3] * 8] + if (BARRICADE_LEVELS.has(levelId)) { + expect([...dims].sort(numeric), `${what}: level dimensions`).toEqual([64, 160]) + } else { + const def = DATATBLS_GetLevelDefRecord(data.tables, levelId) + expect(dims, `${what}: level dimensions`).toEqual([def.dwSizeX[0], def.dwSizeY[0]]) + } + + const objects = input.units.filter( + u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length, + ) + const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0) + expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_LEVELS.has(levelId) ? 1 : 0) + + expect(input.orths.map(o => o.level).sort(numeric), `${what}: orth neighbours`).toEqual( + [...EXPECTED_ORTHS[levelId]!].sort(numeric), + ) + if (act.has(levelId)) { + const layout = act.get(levelId)!.coord + for (const orth of input.orths) { + const neighbour = act.get(orth.level)! + expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe( + true, + ) + } + if (levelId === 110) { + expect(touchesOnDir(layout, act.get(111)!.coord, 0), `${what}: touches 111 on west`).toBe(true) + expect(touchesOnDir(layout, act.get(109)!.coord, 2), `${what}: touches 109 on east`).toBe(true) + } + } + + if (BARRICADE_LEVELS.has(levelId)) { + const cells0 = levelDump.levelGrid.grids[0]!.cells + expect( + cells0.includes(LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N) || + cells0.includes(LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W), + `${what}: Hell portal preset`, + ).toBe(true) + } + + expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual( + [...EXPECTED_WARPS[levelId]!].sort(numeric), + ) + } + }, 60_000) + } +}) diff --git a/tests/drlg-act5-oracle.test.ts b/tests/drlg-act5-oracle.test.ts new file mode 100644 index 0000000..063d15d --- /dev/null +++ b/tests/drlg-act5-oracle.test.ts @@ -0,0 +1,107 @@ +/** + * Act V outdoor DRLG (#498): bit-exact parity of the TypeScript port (src/game/drlg) with the native + * D2MOO oracle (tools/d2moo-oracle) across all Act V outdoor levels (110-112, 117, 134). + */ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts' +import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts' +import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { MountedArchives } from '../src/mpq/mount.ts' +import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts' +import { + firstDiff, + firstTraceDivergence, + formatDiff, + fsDrlgSource, + ORACLE_BIN, + ORACLE_DATA, + runOracle, +} from '../scripts/lib/drlg-oracle.ts' + +const ACT5_OUTDOOR_LEVELS = [110, 111, 112, 117, 134] +const SEEDS = [0x12345678, 0x5eed0500, 0x5eed0501, 0x5eed0502] +const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2') +const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const + +function hexSeed(seed: number): string { + return seed.toString(16).padStart(8, '0') +} + +describe('Act V outdoor DRLG 1:1 parity against native D2MOO oracle', () => { + let data: DrlgMpqData + + beforeAll(async () => { + const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name))) + if (missing.length > 0) { + throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`) + } + const archives = new MountedArchives() + for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name)))) + data = await loadDrlgMpqData(member => archives.read(member), 5) + }, 120_000) + + for (const seed of SEEDS) { + const hex = hexSeed(seed) + it.skipIf(!existsSync(ORACLE_BIN))( + `seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`, + () => { + const oracleRun = runOracle({ + seed, + levels: ACT5_OUTDOOR_LEVELS, + difficulty: 0, + isolated: false, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT5_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: false, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`) + + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + + for (const levelId of ACT5_OUTDOOR_LEVELS) { + const input = drlgLevelInputFromDump(actual, levelId, data.tables) + expect(input.tiles.length).toBeGreaterThan(0) + } + }, + 60_000, + ) + } + + it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => { + const seed = 0x12345678 + const oracleRun = runOracle({ + seed, + levels: ACT5_OUTDOOR_LEVELS, + difficulty: 0, + isolated: true, + dataDir: ORACLE_DATA, + trace: true, + }) + delete oracleRun.doc.oracle + const source = fsDrlgSource(ORACLE_DATA) + const trace = new DrlgTraceWriter() + const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT5_OUTDOOR_LEVELS, { + difficulty: 0, + isolated: true, + trace, + }) + const diff = firstDiff(oracleRun.doc, actual, '$') + if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`) + const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines) + expect(traceDiff).toBeNull() + }, 60_000) +}) diff --git a/tests/drlg-alias-guard.test.ts b/tests/drlg-alias-guard.test.ts index 6309ad6..2ad7c13 100644 --- a/tests/drlg-alias-guard.test.ts +++ b/tests/drlg-alias-guard.test.ts @@ -1,31 +1,21 @@ import { describe, expect, test } from "vitest" -import { readFileSync } from "node:fs" +import { readFileSync, existsSync } from "node:fs" import { resolve } from "node:path" -import * as wilderness from "../src/game/wilderness.ts" import * as maze from "../src/game/maze.ts" import * as levelLinks from "../src/game/level-links.ts" +import * as drlgMap from "../src/game/drlg/drlg-map.ts" describe("DRLG Export and Alias Guard (Issue #81)", () => { const getDrlgExports = (mod: Record): string[] => { return Object.keys(mod).filter((name) => name.startsWith("DRLG")) } - test("src/game/wilderness.ts does not export fake Blizzard aliases", () => { - const exports = getDrlgExports(wilderness as Record) - expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask") - expect(exports).not.toContain("DRLGROOMTILE_ApplyTransitions") - - // The invented 8-neighbour ground-transition pass (computeNeighborMask / - // applyGroundTransitions) was removed with the Act I DRLG port: Act I - // outdoor maps now come from the 1:1 D2MOO port in src/game/drlg. - expect((wilderness as Record).computeNeighborMask).toBeUndefined() - expect((wilderness as Record).applyGroundTransitions).toBeUndefined() - }) - - test("src/game/wilderness.ts keeps single export of DRLGOUTDOORS_SpawnAct12Waypoint", () => { - const exports = getDrlgExports(wilderness as Record) - expect(exports).toContain("DRLGOUTDOORS_SpawnAct12Waypoint") - expect(typeof wilderness.DRLGOUTDOORS_SpawnAct12Waypoint).toBe("function") + test("legacy src/game/wilderness*.ts and world-placement.ts have been removed in favor of src/game/drlg", () => { + expect(existsSync(resolve(__dirname, "../src/game/wilderness.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/wilderness-acts.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/wilderness-jungles.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/wilderness-siege.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/world-placement.ts"))).toBe(false) }) test("src/game/level-links.ts does not export duplicate DRLGOUTDOORS_SpawnAct12Waypoint", () => { @@ -38,29 +28,20 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => { expect(typeof levelLinks.findWaypointSpot).toBe("function") }) - test("src/game/maze.ts does not export fake Blizzard DRLG aliases", () => { - const exports = getDrlgExports(maze as Record) + test("src/game/maze.ts and src/game/drlg/drlg-map.ts do not export fake Blizzard DRLG aliases", () => { + const exports = [ + ...getDrlgExports(maze as Record), + ...getDrlgExports(drlgMap as Record), + ] expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask") expect(exports).not.toContain("DRLGROOMTILE_ApplyTransitions") - expect(exports).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint") - }) - - test("DRLGOUTDOORS_SpawnAct12Waypoint has at most ONE export across wilderness, maze, and level-links", () => { - const allDrlgExports = [ - ...getDrlgExports(wilderness as Record), - ...getDrlgExports(maze as Record), - ...getDrlgExports(levelLinks as Record), - ] - - const waypointExports = allDrlgExports.filter((name) => name === "DRLGOUTDOORS_SpawnAct12Waypoint") - expect(waypointExports).toHaveLength(1) }) test("Static source check: no fake DRLG export statements in source files", () => { const files = [ - resolve(__dirname, "../src/game/wilderness.ts"), resolve(__dirname, "../src/game/maze.ts"), resolve(__dirname, "../src/game/level-links.ts"), + resolve(__dirname, "../src/game/drlg/drlg-map.ts"), ] for (const filePath of files) { @@ -72,10 +53,5 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => { // Ensure level-links.ts does not export DRLGOUTDOORS_SpawnAct12Waypoint const levelLinksSource = readFileSync(resolve(__dirname, "../src/game/level-links.ts"), "utf-8") expect(levelLinksSource).not.toMatch(/export\s+(?:const|function|let|var)\s+DRLGOUTDOORS_SpawnAct12Waypoint\b/) - - // The invented (non-Blizzard) ground-transition helpers must not come back. - const wildernessSource = readFileSync(resolve(__dirname, "../src/game/wilderness.ts"), "utf-8") - expect(wildernessSource).not.toMatch(/function computeNeighborMask\b/) - expect(wildernessSource).not.toMatch(/function applyGroundTransitions\b/) }) }) diff --git a/tests/input-controls.test.ts b/tests/input-controls.test.ts index cf55d62..881cfe1 100644 --- a/tests/input-controls.test.ts +++ b/tests/input-controls.test.ts @@ -1807,8 +1807,11 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label } } - expect(warpCount).toBe(495) - expect(nonZeroBoxWarps).toBe(464) + // 501 total warps (470 with LvlWarp.txt bounding boxes + 31 zero-box warps): + // includes both authentic Sewer Level 1 (92) entrances in each of the 3 copies + // of Kurast Bazaar (80) and Upper Kurast (81). + expect(warpCount).toBe(501) + expect(nonZeroBoxWarps).toBe(470) expect(zeroBoxWarps).toBe(31) // 166 since the Act I outdoor levels come from the DRLG port: D2 has no // Stony Field <-> Dark Wood seam (DrlgOutPlace.cpp gAct1WildernessDrlgLink / diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts index 81ecb95..d370205 100644 --- a/tests/maze-objects.test.ts +++ b/tests/maze-objects.test.ts @@ -20,10 +20,6 @@ import { type MazePiece, type MazeRequest, } from '../src/game/maze.ts' -import { - ACT_POPULATION_CONFIG, - validateActPopulationConfig, -} from '../src/game/wilderness.ts' import type { D2Table } from '../src/game/acts.ts' import { Rng } from '../src/game/rng.ts' @@ -598,26 +594,6 @@ describe('Issue #61 Step 4: Objects.txt mapping validation and LvlPrest piece fa }) }) - describe('validateActPopulationConfig', () => { - test('validates ACT_POPULATION_CONFIG for all 5 acts against embedded data', () => { - for (let act = 1; act <= 5; act += 1) { - const config = ACT_POPULATION_CONFIG[act]! - const result = validateActPopulationConfig(config, act) - expect(result.valid).toBe(true) - expect(result.errors).toEqual([]) - expect(result.totalChecked).toBeGreaterThan(0) - } - }) - - test('detects invalid IDs in population config with incomplete table', () => { - const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1']] } - const config = ACT_POPULATION_CONFIG[1]! - const result = validateActPopulationConfig(config, 1, mockObjectsTable) - expect(result.valid).toBe(false) - expect(result.errors.length).toBeGreaterThan(0) - }) - }) - describe('discoverMazePieceFamilies', () => { test('returns static families when lvlPrestTable is omitted', () => { expect(discoverMazePieceFamilies('Act 2 - Harem')).toEqual(['Act 2 - Corrupt Harem', 'Act 2 - Harem']) 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/outdoor-vertices-path.test.ts b/tests/outdoor-vertices-path.test.ts deleted file mode 100644 index 15d380f..0000000 --- a/tests/outdoor-vertices-path.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { Rng } from '../src/game/rng.ts' -import { - ALTDIR_WEST, - ALTDIR_NORTH, - ALTDIR_EAST, - ALTDIR_SOUTH, - DRLGVER_CreateVertices, - DRLGOUTDOORS_CalculatePathCoordinates, - DRLGOUTDOORS_CalculateHubCoordinates, - DRLGOUTDOORS_TestGridCellSpawnValid, - bresenhamLine, - UNIMPLEMENTED_PASSES, - TILES_PER_BLOCK, -} from '../src/game/wilderness.ts' -import type { Act1OutdoorPlan, DrlgVertex } from '../src/game/wilderness.ts' - -describe('Outdoor Boundary Vertices & Grid Road Path (Issue #82)', () => { - function createMockPlan(overrides: Partial = {}): Act1OutdoorPlan { - const gridW = 4 - const gridH = 4 - return { - openGates: new Map(), - claimedBorderBlocks: new Set(), - claimedBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)), - roadObstacleBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)), - specialPresets: [], - gateOpenings: [], - anchors: [ - { - side: 'west', - anchorCell: { x: 0, y: 16 }, - interiorCell: { x: 4, y: 16 }, - label: 'Rogue Encampment Gate', - toLevelId: 1, - kind: 'gate', - }, - { - side: 'east', - anchorCell: { x: 31, y: 16 }, - interiorCell: { x: 27, y: 16 }, - label: 'Cold Plains Gate', - toLevelId: 3, - kind: 'gate', - }, - ], - ...overrides, - } - } - - describe('Unimplemented Passes Audit', () => { - it('verifies DRLGVER_CreateVertices is removed from UNIMPLEMENTED_PASSES', () => { - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices') - }) - }) - - describe('DRLGVER_CreateVertices: 0 RNG Rolls Guarantee', () => { - it('consumes exactly 0 RNG rolls during vertex and path construction', () => { - const rng = new Rng(0x12345678) - const initialSeed = rng.seed - const plan = createMockPlan() - - const result = DRLGVER_CreateVertices(plan, 4, 4, rng) - - expect(rng.seed).toBe(initialSeed) - expect(result.vertices.length).toBeGreaterThan(0) - expect(result.polylines.length).toBe(plan.anchors.length) - }) - }) - - describe('DRLGVER_CreateVertices: Closed Cyclic Linked List', () => { - it('constructs 4 corner vertices in clockwise order with relative coordinates', () => { - const plan = createMockPlan({ anchors: [] }) - const gridW = 4 - const gridH = 4 - const tileW = gridW * TILES_PER_BLOCK - const tileH = gridH * TILES_PER_BLOCK - const origin = { x: 100, y: 200 } - - const result = DRLGVER_CreateVertices(plan, gridW, gridH, undefined, origin) - const cyclic = result.cyclicVertices! - - expect(cyclic).toBeDefined() - expect(cyclic.length).toBe(4) - - const [v0, v1, v2, v3] = cyclic - expect(v0).toMatchObject({ x: 0, y: 0, direction: ALTDIR_NORTH, dwFlags: 0, type: 'corner' }) - expect(v1).toMatchObject({ x: tileW, y: 0, direction: ALTDIR_EAST, dwFlags: 0, type: 'corner' }) - expect(v2).toMatchObject({ x: tileW, y: tileH, direction: ALTDIR_SOUTH, dwFlags: 0, type: 'corner' }) - expect(v3).toMatchObject({ x: 0, y: tileH, direction: ALTDIR_WEST, dwFlags: 0, type: 'corner' }) - - // Check next pointers (clockwise) - expect(v0.next).toBe(v1) - expect(v1.next).toBe(v2) - expect(v2.next).toBe(v3) - expect(v3.next).toBe(v0) - - // Check prev pointers (counter-clockwise) - expect(v0.prev).toBe(v3) - expect(v3.prev).toBe(v2) - expect(v2.prev).toBe(v1) - expect(v1.prev).toBe(v0) - }) - - it('inserts border connection vertices into appropriate edges with correct dwFlags and order', () => { - const plan = createMockPlan({ - anchors: [ - { - side: 'north', - anchorCell: { x: 16, y: 0 }, - interiorCell: { x: 16, y: 4 }, - label: 'North Gate', - toLevelId: 4, - kind: 'gate', - }, - { - side: 'east', - anchorCell: { x: 32, y: 20 }, - interiorCell: { x: 28, y: 20 }, - label: 'East Preset', - toLevelId: 5, - kind: 'preset', - }, - { - side: 'south', - anchorCell: { x: 24, y: 32 }, - interiorCell: { x: 24, y: 28 }, - label: 'South Gate', - toLevelId: 6, - kind: 'gate', - }, - { - side: 'west', - anchorCell: { x: 0, y: 12 }, - interiorCell: { x: 4, y: 12 }, - label: 'West Gate', - toLevelId: 7, - kind: 'gate', - }, - ], - }) - - const result = DRLGVER_CreateVertices(plan, 4, 4) - const cyclic = result.cyclicVertices! - - // 4 corners + 4 inserted border vertices = 8 vertices - expect(cyclic.length).toBe(8) - - // Verify full cycle traversal via next - let curr = result.head! - const visitedForward: DrlgVertex[] = [] - for (let i = 0; i < cyclic.length; i += 1) { - visitedForward.push(curr) - curr = curr.next! - } - expect(curr).toBe(result.head!) - expect(visitedForward.length).toBe(8) - - // Verify full cycle traversal via prev - let prevNode = result.head! - const visitedBackward: DrlgVertex[] = [] - for (let i = 0; i < cyclic.length; i += 1) { - visitedBackward.push(prevNode) - prevNode = prevNode.prev! - } - expect(prevNode).toBe(result.head!) - - // Find inserted vertices and verify flags: - // Gate: dwFlags = 1 (connection) - // Preset: dwFlags = 3 (1 = connection | 2 = preset) - const northV = cyclic.find(v => v.direction === ALTDIR_NORTH && v.type === 'gate') - expect(northV).toBeDefined() - expect(northV!.dwFlags).toBe(1) - expect(northV!.x).toBe(16) - expect(northV!.y).toBe(0) - - const eastV = cyclic.find(v => v.direction === ALTDIR_EAST && v.type === 'preset') - expect(eastV).toBeDefined() - expect(eastV!.dwFlags).toBe(3) // dwFlags |= 1 | 2 - expect(eastV!.x).toBe(32) - expect(eastV!.y).toBe(20) - - const southV = cyclic.find(v => v.direction === ALTDIR_SOUTH && v.type === 'gate') - expect(southV).toBeDefined() - expect(southV!.dwFlags).toBe(1) - expect(southV!.x).toBe(24) - expect(southV!.y).toBe(32) - - const westV = cyclic.find(v => v.direction === ALTDIR_WEST && v.type === 'gate') - expect(westV).toBeDefined() - expect(westV!.dwFlags).toBe(1) - expect(westV!.x).toBe(0) - expect(westV!.y).toBe(12) - }) - - it('inserts span endpoint vertices [v15, v16] when provided for wide connections', () => { - const plan = createMockPlan({ - anchors: [ - { - side: 'north', - anchorCell: { x: 16, y: 0 }, - interiorCell: { x: 16, y: 4 }, - label: 'North River Span', - toLevelId: 4, - kind: 'preset', - span: [12, 20], - } as any, - ], - }) - - const result = DRLGVER_CreateVertices(plan, 4, 4) - const cyclic = result.cyclicVertices! - - // 4 corners + 2 span vertices = 6 - expect(cyclic.length).toBe(6) - - const northSpans = cyclic.filter(v => v.direction === ALTDIR_NORTH && v.type === 'preset') - expect(northSpans.length).toBe(2) - expect(northSpans[0]!.x).toBe(12) - expect(northSpans[1]!.x).toBe(20) - expect(northSpans[0]!.label).toContain('(Start)') - expect(northSpans[1]!.label).toContain('(End)') - }) - }) - - describe('DRLGOUTDOORS_CalculatePathCoordinates', () => { - it('correctly snaps coordinates according to 8-cell grid rules for all 4 directions', () => { - const origin = { x: 0, y: 0 } - - // ALTDIR_WEST (0): relX = 8 * Math.trunc(relX / 8) + 11 - const westSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ - x: 0, - y: 16, - direction: ALTDIR_WEST, - }, origin) - expect(westSnapped.x).toBe(8 * Math.trunc(0 / 8) + 11) // 11 - expect(westSnapped.y).toBe(16) - - // ALTDIR_NORTH (1): relY = 8 * Math.trunc(relY / 8) + 11 - const northSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ - x: 16, - y: 0, - direction: ALTDIR_NORTH, - }, origin) - expect(northSnapped.x).toBe(16) - expect(northSnapped.y).toBe(8 * Math.trunc(0 / 8) + 11) // 11 - - // ALTDIR_EAST (2): relX = 8 * Math.trunc(relX / 8) - 5 - const eastSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ - x: 32, - y: 16, - direction: ALTDIR_EAST, - }, origin) - expect(eastSnapped.x).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27 - expect(eastSnapped.y).toBe(16) - - // ALTDIR_SOUTH (3): relY = 8 * Math.trunc(relY / 8) - 5 - const southSnapped = DRLGOUTDOORS_CalculatePathCoordinates({ - x: 16, - y: 32, - direction: ALTDIR_SOUTH, - }, origin) - expect(southSnapped.x).toBe(16) - expect(southSnapped.y).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27 - }) - - it('supports D2MOO C++ style (pLevel, pVertex1, pVertex2) signature and populates pVertex2', () => { - const mockLevel = { nPosX: 10, nPosY: 20 } - const pVertex1 = { nPosX: 42, nPosY: 20, nDirection: ALTDIR_EAST } - const pVertex2: { nPosX?: number; nPosY?: number; x?: number; y?: number } = {} - - const res = DRLGOUTDOORS_CalculatePathCoordinates(mockLevel, pVertex1, pVertex2) - - // relX = 42 - 10 = 32 -> 8 * trunc(32 / 8) - 5 = 27 -> 27 + 10 = 37 - // relY = 20 - 20 = 0 -> 0 + 20 = 20 - expect(res.x).toBe(37) - expect(res.y).toBe(20) - expect(pVertex2.x).toBe(37) - expect(pVertex2.y).toBe(20) - expect(pVertex2.nPosX).toBe(37) - expect(pVertex2.nPosY).toBe(20) - }) - }) - - describe('DRLGOUTDOORS_TestGridCellSpawnValid', () => { - it('validates cell bitmask 0x1B81 and road obstacle blocks', () => { - const plan = { - gridFlags: [ - [0, 0x0001], - [0x0080, 0x1000], - ], - roadObstacleBlocks: [ - [false, false], - [false, true], - ], - } - - // (0,0) has 0 -> valid - expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 0, plan)).toBe(true) - // (1,0) has 0x0001 (bit 0 in 0x1B81) -> invalid - expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 0, plan)).toBe(false) - // (0,1) has 0x0080 (bit 7 in 0x1B81) -> invalid - expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 1, plan)).toBe(false) - // (1,1) has roadObstacleBlocks = true -> invalid - expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 1, plan)).toBe(false) - }) - }) - - describe('DRLGOUTDOORS_CalculateHubCoordinates', () => { - it('returns center of level when nVertices === 1', () => { - const plan = createMockPlan({ - anchors: [ - { - side: 'west', - anchorCell: { x: 0, y: 16 }, - interiorCell: { x: 4, y: 16 }, - label: 'Single Gate', - kind: 'gate', - }, - ], - }) - - const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6) - expect(hub.bx).toBe(3) // floor(6 / 2) - expect(hub.by).toBe(3) // floor(6 / 2) - expect(hub.x).toBe(3 * TILES_PER_BLOCK + 4) - expect(hub.y).toBe(3 * TILES_PER_BLOCK + 4) - }) - - it('anchors to bridge coordinates when bridge flag is set', () => { - const plan = createMockPlan({ - dwFlags: 0x10, - specialPresets: [ - { - name: 'Blood Moor Bridge', - bx: 2, - by: 3, - blocksX: 1, - blocksY: 1, - ds1: {} as any, - }, - ], - } as any) - - const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6) - expect(hub.bx).toBe(2) - expect(hub.by).toBe(3) - expect(hub.x).toBe(8 * 2 + 3) // 19 - expect(hub.y).toBe(8 * 3 + 3) // 27 - }) - - it('computes average coordinates and spirals outward radius 0..7 avoiding obstacles', () => { - // 2 anchors on west and east borders - // anchor 1: cell (0, 8) -> bx = 0, by = 1 - // anchor 2: cell (32, 24) -> bx = 4, by = 3 - // sumX = 0 + 32 = 32 -> avgX = floor(32 / 16) = 2 - // sumY = 8 + 24 = 32 -> avgY = floor(32 / 16) = 2 - // If (2, 2) is blocked by road obstacle, spiral radius 1 tests [-1,0] -> (1, 2) - const obstacleBlocks = Array.from({ length: 5 }, () => Array(5).fill(false)) - obstacleBlocks[2]![2] = true // Block (2, 2) - - const plan = createMockPlan({ - anchors: [ - { - side: 'west', - anchorCell: { x: 0, y: 8 }, - interiorCell: { x: 4, y: 8 }, - label: 'West', - kind: 'gate', - }, - { - side: 'east', - anchorCell: { x: 32, y: 24 }, - interiorCell: { x: 28, y: 24 }, - label: 'East', - kind: 'gate', - }, - ], - roadObstacleBlocks: obstacleBlocks, - }) - - const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 5, 5) - // Radius 1 first direction is { x: -1, y: 0 } -> testX = 2 - 1 = 1, testY = 2 - expect(hub.bx).toBe(1) - expect(hub.by).toBe(2) - }) - }) - - describe('Bresenham Grid Line Rasterization', () => { - it('produces deterministic contiguous grid line without diagonal gaps', () => { - const points = bresenhamLine(2, 3, 7, 6) - - expect(points.length).toBeGreaterThan(0) - expect(points[0]).toEqual({ x: 2, y: 3 }) - expect(points[points.length - 1]).toEqual({ x: 7, y: 6 }) - - for (let i = 1; i < points.length; i += 1) { - const pPrev = points[i - 1]! - const pCurr = points[i]! - const dx = Math.abs(pCurr.x - pPrev.x) - const dy = Math.abs(pCurr.y - pPrev.y) - expect(dx).toBeLessThanOrEqual(1) - expect(dy).toBeLessThanOrEqual(1) - expect(dx + dy).toBeGreaterThan(0) - } - }) - - it('guarantees deterministic identical output across runs with 0 RNG', () => { - const line1 = bresenhamLine(10, 20, 50, 80) - const line2 = bresenhamLine(10, 20, 50, 80) - expect(line1).toEqual(line2) - }) - }) -}) diff --git a/tests/outdoor-waypoint-113c.test.ts b/tests/outdoor-waypoint-113c.test.ts deleted file mode 100644 index f8b5ae7..0000000 --- a/tests/outdoor-waypoint-113c.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - type Canvas, - DRLG_GRID_FLAG_WAYPOINT, - DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS, - DRLG_GRID_SPAWN_INVALID_MASK, - DRLGOUTDOORS_SpawnAct12Waypoint, - type PlacedPresetRect, - rollLimited, - type SeamConstraint, - TestGridCellSpawnValid, - type WildernessStats, -} from "../src/game/wilderness.ts" -import { Rng } from "../src/game/rng.ts" -import type { Ds1Cell } from "../src/formats/ds1.ts" - -class TrackingRng extends Rng { - rollCount = 0 - override next(): number { - this.rollCount += 1 - return super.next() - } -} - -function makeOutdoorCanvas(width: number, height: number): Canvas { - 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: 0, - unknown1: 0, - unknown2: 0, - hidden: false, - }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - width, - height, - cells, - wallLayers: 1, - floorLayers: 1, - substitutionLayers: 1, - objects: [], - } -} - -function makeEmptyStats(): WildernessStats { - return { - substitutions: [], - borderPieces: {}, - unresolved: [], - notes: [], - borderStamped: 0, - groundCells: 0, - groundTile: null, - sizeSource: "default", - roadCells: 0, - roadSegments: 0, - anchors: 0, - specialPresets: [], - entrances: [], - waypointsSpawned: 0, - shrinesSpawned: 0, - interactivesSpawned: 0, - } -} - -describe("DRLGOUTDOORS_SpawnAct12Waypoint 1.13c Parity (Issue #80)", () => { - describe("TestGridCellSpawnValid and DRLG_GRID_SPAWN_INVALID_MASK", () => { - test("DRLG_GRID_SPAWN_INVALID_MASK matches 1.13c disassembly 0x1B81", () => { - expect(DRLG_GRID_SPAWN_INVALID_MASK).toBe(0x1b81) - }) - - test("cell flags with no 0x1B81 bits pass validation", () => { - const validFlags = [ - 0, - 0x0002, - 0x0004, - 0x0008, - 0x0010, - 0x0020, - 0x0040, - 0x0400, - 0x2000, - 0x4000, - 0x8000, - DRLG_GRID_FLAG_WAYPOINT, // 0x10000 - DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS, // 0x20000 - 0x0002 | 0x0010 | 0x20000, - ] - - for (const flag of validFlags) { - const grid2D = [[flag]] - expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(true) - - const grid1D = [flag] - expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(true) - - const gridU32 = new Uint32Array([flag]) - expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(true) - - const gridObj = { width: 1, height: 1, flags: [flag] } - expect(TestGridCellSpawnValid(gridObj, 0, 0)).toBe(true) - } - }) - - test("cell flags with any 0x1B81 bits fail validation", () => { - const invalidFlags = [ - 0x0001, // preset occupied - 0x0080, // border cell - 0x0100, // collision bit - 0x0200, // collision bit - 0x0800, // collision bit - 0x1000, // walls / impassable - 0x1b81, // full mask - 0x1000 | 0x0002, // mixed - 0x0001 | DRLG_GRID_FLAG_WAYPOINT, - ] - - for (const flag of invalidFlags) { - const grid2D = [[flag]] - expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(false) - - const grid1D = [flag] - expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(false) - - const gridU32 = new Uint32Array([flag]) - expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(false) - } - }) - - test("out of bounds coordinates safely return false", () => { - const grid = [[0, 0], [0, 0]] - expect(TestGridCellSpawnValid(grid, -1, 0)).toBe(false) - expect(TestGridCellSpawnValid(grid, 0, -1)).toBe(false) - expect(TestGridCellSpawnValid(grid, 2, 0)).toBe(false) - expect(TestGridCellSpawnValid(grid, 0, 2)).toBe(false) - }) - }) - - describe("rollLimited helper", () => { - test("rolls within [0, max - 1] and consumes exactly 1 roll per call", () => { - const rng = new TrackingRng(0x12345678) - const max = 36 - for (let i = 0; i < 100; i += 1) { - const prevRolls = rng.rollCount - const val = rollLimited(rng, max) - expect(val).toBeGreaterThanOrEqual(0) - expect(val).toBeLessThan(max) - expect(rng.rollCount).toBe(prevRolls + 1) - } - }) - - test("handles edge case max <= 0 without consuming or erroring", () => { - const rng = new TrackingRng(0x12345678) - expect(rollLimited(rng, 0)).toBe(0) - expect(rollLimited(rng, -5)).toBe(0) - expect(rng.rollCount).toBe(0) - }) - }) - - describe("Branch A: Cold Plains Special (levelId === 3)", () => { - test("consumes exactly 0 RNG rolls", () => { - const canvas = makeOutdoorCanvas(64, 64) - const stats = makeEmptyStats() - const rng = new TrackingRng(0xabcdef) - const seamConstraints: SeamConstraint[] = [ - { fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 }, - ] - - DRLGOUTDOORS_SpawnAct12Waypoint( - canvas, - 3, - 1, - undefined, - [], - rng, - stats, - "Act 1 - Wilderness", - undefined, - seamConstraints, - ) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - // Exactly 0 RNG rolls consumed! - expect(rng.rollCount).toBe(0) - }) - - test("clamps connection to Blood Moor on all 4 boundary sides and sets 0x20000 flag", () => { - const testCases: { side: "west" | "east" | "north" | "south"; expectedBlockX: number; expectedBlockY: number }[] = [ - { side: "west", expectedBlockX: 1, expectedBlockY: 4 }, // j=0 clamped to 1, i=4 - { side: "east", expectedBlockX: 6, expectedBlockY: 4 }, // j=7 clamped to 6, i=4 - { side: "north", expectedBlockX: 4, expectedBlockY: 1 }, // i=0 clamped to 1, j=4 - { side: "south", expectedBlockX: 4, expectedBlockY: 6 }, // i=7 clamped to 6, j=4 - ] - - for (const tc of testCases) { - const canvas = makeOutdoorCanvas(64, 64) // 8x8 blocks (0..7) - const stats = makeEmptyStats() - const rng = new TrackingRng(0x9999) - const seamConstraints: SeamConstraint[] = [ - { fromLevelId: 3, toLevelId: 2, side: tc.side, blockCoord: 4 }, - ] - - DRLGOUTDOORS_SpawnAct12Waypoint( - canvas, - 3, - 1, - undefined, - [], - rng, - stats, - "Act 1 - Wilderness", - undefined, - seamConstraints, - ) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - const wpTile = stats.waypointTile! - const blockX = Math.floor(wpTile.x / 8) - const blockY = Math.floor(wpTile.y / 8) - expect(blockX).toBe(tc.expectedBlockX) - expect(blockY).toBe(tc.expectedBlockY) - - const note = stats.notes.find(n => n.includes("Cold Plains special grid cell")) - expect(note).toBeDefined() - expect(note).toContain(`(${tc.expectedBlockX}, ${tc.expectedBlockY})`) - - expect(rng.rollCount).toBe(0) - } - }) - - test("clamps extreme boundary corners (e.g. j=0 clamped to 1, i=0 clamped to 1)", () => { - const canvas = makeOutdoorCanvas(64, 64) - const stats = makeEmptyStats() - const rng = new TrackingRng(0x4242) - // West gate at extreme top (blockCoord 0) - const seamConstraints: SeamConstraint[] = [ - { fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 }, - ] - - DRLGOUTDOORS_SpawnAct12Waypoint( - canvas, - 3, - 1, - undefined, - [], - rng, - stats, - "Act 1 - Wilderness", - undefined, - seamConstraints, - ) - - expect(stats.waypointTile).toBeDefined() - const blockX = Math.floor(stats.waypointTile!.x / 8) - const blockY = Math.floor(stats.waypointTile!.y / 8) - expect(blockX).toBe(1) - expect(blockY).toBe(1) - expect(rng.rollCount).toBe(0) - }) - }) - - describe("Branch B: General Path (e.g. Stony Field, levelId === 4)", () => { - test("consumes exactly 2 * nArea RNG rolls for coordinate shuffle", () => { - // 64x64 tiles -> 8x8 blocks of 8x8 tiles - // nGridWidth = 8, nGridHeight = 8 - // nWidth = 8 - 2 = 6, nHeight = 8 - 2 = 6 - // nArea = 6 * 6 = 36 - // Exactly 2 * nArea = 72 rolls for shuffle, and 0 rolls during placement search - const canvas = makeOutdoorCanvas(64, 64) - const stats = makeEmptyStats() - const rng = new TrackingRng(0x778899) - - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - const expectedRolls = 2 * 36 - expect(rng.rollCount).toBe(expectedRolls) - }) - - test("consumes exactly 2 * nArea rolls for larger map (80x80 tiles -> 10x10 blocks -> 8x8 area = 64 cells -> 128 rolls)", () => { - const canvas = makeOutdoorCanvas(80, 80) - const stats = makeEmptyStats() - const rng = new TrackingRng(0x112233) - - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - // nWidth = 10 - 2 = 8, nHeight = 10 - 2 = 8, nArea = 64 - // 2 * 64 = 128 rolls - expect(rng.rollCount).toBe(128) - }) - - test("skips cells occupied by preset rectangles (obstacle-free placement via TestGridCellSpawnValid)", () => { - const canvas = makeOutdoorCanvas(64, 64) - const stats = makeEmptyStats() - const rng = new TrackingRng(0x5555) - - // Mark preset covering block (1, 1) -> tiles [8..15, 8..15] - const placedPresets: PlacedPresetRect[] = [ - { name: "PresetObstacle", x: 8, y: 8, w: 8, h: 8 }, - ] - - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, placedPresets, rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - const wp = stats.waypointTile! - const blockX = Math.floor(wp.x / 8) - const blockY = Math.floor(wp.y / 8) - // Block (1, 1) had an obstacle so waypoint must not be in (1, 1) - expect(blockX === 1 && blockY === 1).toBe(false) - // Must be within inner bounds [1..6, 1..6] - expect(blockX).toBeGreaterThanOrEqual(1) - expect(blockX).toBeLessThanOrEqual(6) - expect(blockY).toBeGreaterThanOrEqual(1) - expect(blockY).toBeLessThanOrEqual(6) - }) - - test("is deterministic: identical seeds produce identical waypoint coordinates", () => { - const canvas1 = makeOutdoorCanvas(64, 64) - const stats1 = makeEmptyStats() - const rng1 = new TrackingRng(0xfeedbeef) - DRLGOUTDOORS_SpawnAct12Waypoint(canvas1, 4, 1, undefined, [], rng1, stats1) - - const canvas2 = makeOutdoorCanvas(64, 64) - const stats2 = makeEmptyStats() - const rng2 = new TrackingRng(0xfeedbeef) - DRLGOUTDOORS_SpawnAct12Waypoint(canvas2, 4, 1, undefined, [], rng2, stats2) - - expect(stats1.waypointTile).toEqual(stats2.waypointTile) - expect(rng1.rollCount).toBe(rng2.rollCount) - }) - }) -}) 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/preset.test.ts b/tests/preset.test.ts index 294d50a..8a27080 100644 --- a/tests/preset.test.ts +++ b/tests/preset.test.ts @@ -18,7 +18,6 @@ import { resolvePickedFile, } from "../src/game/preset.ts" import type { PresetPiece } from "../src/game/preset.ts" -import { generateWilderness } from "../src/game/wilderness.ts" import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" function makeMockDs1(width: number, height: number, style = 1): Ds1 { @@ -208,42 +207,6 @@ describe("Preset Map Pipeline (DrlgType 2)", () => { expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(1) expect(result.level.cells[30]?.[30]?.floors[0]?.style).toBe(2) }) - - test("wilderness generator no longer hardcodes Level 108 Chaos Sanctuary special case", () => { - // Wilderness generator does not hijack Level 108 anymore; without special preset routing, - // it will not produce the 7-cluster Chaos Sanctuary layout. - const names = [ - "Act 4 - Diablo Arm N", - "Act 4 - Diablo Arm W", - "Act 4 - Diablo Heart", - "Act 4 - Diablo Arm E", - "Act 4 - Diablo Arm S", - "Act 4 - Diablo Entry", - "Act 4 - Bridge 2", - ] - const pieces = names.map(name => ({ - name, - border: false, - levels: [makeMockDs1(24, 24)], - })) - - const result = generateWilderness({ - levelId: 108, - levelName: "Chaos Sanctuary", - levelTypeName: "Act 4 - Lava", - sizeX: 120, - sizeY: 120, - subType: -1, - subTheme: -1, - seed: 108, - pieces, - substitutions: [], - }) - - // Previously this returned substitutedClusters: 7 via stampAct4ChaosSanctuary. - // Now wilderness treats it as regular wilderness, not the 7-preset Chaos Sanctuary. - expect(result.stats.substitutedClusters).not.toBe(7) - }) }) describe("Canonical Preset Levels (Levels.txt DrlgType == 2)", () => { diff --git a/tests/stress-world-placement.test.ts b/tests/stress-world-placement.test.ts deleted file mode 100644 index efe60fd..0000000 --- a/tests/stress-world-placement.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { SEAMLESS_ADJACENCY } from '../src/game/world-graph.ts' -import { resolveWorldOffsets, type WorldOffset } from '../src/game/world-placement.ts' - -/** - * 100 distinct seeds for adversarial stress testing: - * - 6 canonical / regression seeds (0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999) - * - 7 boundary / extreme seeds (0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919) - * - 87 pseudo-random seeds generated via standard LCG to ensure broad coverage - */ -export function generateTestSeeds(): number[] { - const seedSet = new Set([ - 0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999, - 0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919, - ]) - let state = 0x8546291a - while (seedSet.size < 100) { - state = (state * 1664525 + 1013904223) >>> 0 - seedSet.add(state) - } - return Array.from(seedSet) -} - -export const TEST_SEEDS = generateTestSeeds() - -/** Check if two rectangles have overlapping interior areas. */ -export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean { - return ( - a.offsetX < b.offsetX + b.sizeX && - a.offsetX + a.sizeX > b.offsetX && - a.offsetY < b.offsetY + b.sizeY && - a.offsetY + a.sizeY > b.offsetY - ) -} - -export interface BoundaryCheckResult { - readonly touching: boolean - readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none' - readonly sharedLength: number - readonly gapX: number - readonly gapY: number -} - -/** - * Check if two rectangular levels share an exact boundary edge without tears or gaps. - * Must share a non-zero length segment along their touching edge. - */ -export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult { - // Check horizontal adjacency (A is West of B or B is West of A) - const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX - const overlapY = Math.max( - 0, - Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY), - ) - if (touchLR && overlapY > 0) { - return { - touching: true, - type: 'vertical-boundary', - sharedLength: overlapY, - gapX: 0, - gapY: 0, - } - } - - // Check vertical adjacency (A is North of B or B is North of A) - const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY - const overlapX = Math.max( - 0, - Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX), - ) - if (touchTB && overlapX > 0) { - return { - touching: true, - type: 'horizontal-boundary', - sharedLength: overlapX, - gapX: 0, - gapY: 0, - } - } - - // Compute gaps / tears if not touching along an edge - const gapX = a.offsetX + a.sizeX <= b.offsetX - ? b.offsetX - (a.offsetX + a.sizeX) - : b.offsetX + b.sizeX <= a.offsetX - ? a.offsetX - (b.offsetX + b.sizeX) - : 0 - - const gapY = a.offsetY + a.sizeY <= b.offsetY - ? b.offsetY - (a.offsetY + a.sizeY) - : b.offsetY + b.sizeY <= a.offsetY - ? a.offsetY - (b.offsetY + b.sizeY) - : 0 - - return { - touching: false, - type: 'none', - sharedLength: 0, - gapX, - gapY, - } -} - -describe('Adversarial Stress Test: resolveWorldOffsets across 100 Seeds for Acts 1..5', () => { - it('generates exactly 100 distinct test seeds', () => { - expect(TEST_SEEDS).toHaveLength(100) - expect(new Set(TEST_SEEDS).size).toBe(100) - }) - - it('Act 5: Level 111 West edge consistently aligns to X = 702 when connected to Level 110 at (760, 1000)', () => { - let checkedCount = 0 - for (const seed of TEST_SEEDS) { - const offsets = resolveWorldOffsets(5, seed) - const l110 = offsets.get(110) - const l111 = offsets.get(111) - const l112 = offsets.get(112) - - expect(l110).toBeDefined() - expect(l111).toBeDefined() - expect(l112).toBeDefined() - - expect(l110!.offsetX).toBe(760) - expect(l110!.offsetY).toBe(1000) - - expect(l111!.sizeX).toBe(58) - expect(l111!.offsetX).toBe(702) - expect(l111!.offsetX + l111!.sizeX).toBe(760) // Zero gap to Level 110 - - expect(l112!.sizeX).toBe(58) - expect(l112!.offsetX).toBe(702) - expect(l112!.offsetY + l112!.sizeY).toBe(l111!.offsetY) // Zero gap North to Level 111 - - checkedCount++ - } - expect(checkedCount).toBe(100) - }) - - it('Determinism: identical seeds yield strictly identical WorldOffset maps', () => { - let checkedCount = 0 - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const run1 = resolveWorldOffsets(act, seed) - const run2 = resolveWorldOffsets(act, seed) - expect(Array.from(run1.entries())).toEqual(Array.from(run2.entries())) - checkedCount++ - } - } - expect(checkedCount).toBe(500) - }) - - it('Non-Overlapping Interiors: seamless outdoor levels have zero interior overlap', () => { - let pairsChecked = 0 - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - const placedSeamlessIds = Array.from(new Set(actSeamless.flat())) - - for (let i = 0; i < placedSeamlessIds.length; i++) { - for (let j = i + 1; j < placedSeamlessIds.length; j++) { - const a = offsets.get(placedSeamlessIds[i]!)! - const b = offsets.get(placedSeamlessIds[j]!)! - expect(doRectanglesOverlap(a, b)).toBe(false) - pairsChecked++ - } - } - } - } - expect(pairsChecked).toBe(9900) - }) - - it('Acts 1, 2, 4, 5: contiguous pairs in SEAMLESS_ADJACENCY share exact boundary coordinates without tears or gaps', () => { - let pairsChecked = 0 - const failures: string[] = [] - - for (const seed of TEST_SEEDS) { - for (const act of [1, 2, 4, 5]) { - const offsets = resolveWorldOffsets(act, seed) - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - for (const [aId, bId] of actSeamless) { - const a = offsets.get(aId)! - const b = offsets.get(bId)! - const res = checkBoundarySharing(a, b) - if (!res.touching) { - failures.push(`Act ${act}: [${aId}, ${bId}] seed=${seed}`) - } - pairsChecked++ - } - } - } - - expect(failures).toEqual([]) - // 18 pairs (Act 1: 7, Act 2: 5, Act 4: 3, Act 5: 3) x 100 seeds. - expect(pairsChecked).toBe(1800) - }) - - it('Act 3 Seamless Adjacency: contiguous pairs in SEAMLESS_ADJACENCY share exact boundary coordinates without tears or gaps', () => { - let pairsChecked = 0 - const failures: { seed: number; pair: string; reason: string }[] = [] - - for (const seed of TEST_SEEDS) { - const offsets = resolveWorldOffsets(3, seed) - const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - for (const [aId, bId] of actSeamless) { - const a = offsets.get(aId)! - const b = offsets.get(bId)! - const res = checkBoundarySharing(a, b) - if (!res.touching) { - failures.push({ - seed, - pair: `[${aId}, ${bId}]`, - reason: `Touch at corner only, sharedLength=0, gapX=${res.gapX}, gapY=${res.gapY}`, - }) - } - pairsChecked++ - } - } - - expect(pairsChecked).toBe(800) - expect(failures).toEqual([]) - }) - - it('All-Level Interior Non-Overlap: all placed levels across all acts have zero interior overlap', () => { - let pairsChecked = 0 - const overlaps: { seed: number; act: number; a: number; b: number }[] = [] - - for (const seed of TEST_SEEDS) { - for (let act = 1; act <= 5; act++) { - const offsets = resolveWorldOffsets(act, seed) - const placed = Array.from(offsets.values()) - for (let i = 0; i < placed.length; i++) { - for (let j = i + 1; j < placed.length; j++) { - if (doRectanglesOverlap(placed[i]!, placed[j]!)) { - overlaps.push({ seed, act, a: placed[i]!.levelId, b: placed[j]!.levelId }) - } - pairsChecked++ - } - } - } - } - - expect(pairsChecked).toBe(210700) - expect(overlaps).toEqual([]) - }) -}) diff --git a/tests/superuniques-fidelity.test.ts b/tests/superuniques-fidelity.test.ts index 0e9727e..714d64a 100644 --- a/tests/superuniques-fidelity.test.ts +++ b/tests/superuniques-fidelity.test.ts @@ -9,7 +9,6 @@ import { MONSTER_ART_MAP, resolveMonsterArtSpec, } from '../src/game/monster-mapping.ts' -import { CANONICAL_LANDMARK_ENTITIES } from '../src/game/wilderness.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' @@ -34,38 +33,6 @@ describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => { expect(landmarkMap.get('Nihlathak')?.minionMonsterId).toBe('minion6') }) - test('CANONICAL_LANDMARK_ENTITIES contains aligned boss and minion IDs', () => { - const entityMap = new Map(CANONICAL_LANDMARK_ENTITIES.map(e => [e.id, e])) - - expect(entityMap.get('Beetleburst')?.monsterId).toBe('scarab2') - expect(entityMap.get('Beetleburst')?.minionMonsterId).toBe('scarab2') - - expect(entityMap.get('Dark Elder')?.monsterId).toBe('zombie5') - expect(entityMap.get('Dark Elder')?.minionMonsterId).toBe('zombie5') - - expect(entityMap.get('Bloodwitch the Wild')?.monsterId).toBe('pantherwoman1') - expect(entityMap.get('Bloodwitch the Wild')?.minionMonsterId).toBe('pantherwoman1') - - expect(entityMap.get('Sszark the Burning')?.monsterId).toBe('arach4') - expect(entityMap.get('Sszark the Burning')?.minionMonsterId).toBe('arach4') - - expect(entityMap.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4') - expect(entityMap.get('Witch Doctor Endugu')?.minionMonsterId).toBe('fetish4') - - expect(entityMap.get('Battlemaid Sarina')?.monsterId).toBe('corruptrogue5') - expect(entityMap.get('Battlemaid Sarina')?.minionMonsterId).toBe('corruptrogue5') - - expect(entityMap.get('Dac Farren')?.monsterId).toBe('imp3') - expect(entityMap.get('Dac Farren')?.minionMonsterId).toBe('imp3') - - expect(entityMap.get('Shenk the Overseer')?.minionMonsterId).toBe('minion1') - - expect(entityMap.get('Frozenstein')?.monsterId).toBe('snowyeti4') - expect(entityMap.get('Frozenstein')?.minionMonsterId).toBe('snowyeti4') - - expect(entityMap.get('Nihlathak')?.minionMonsterId).toBe('minion6') - }) - test('CANONICAL_SUPERUNIQUES_TABLE parses with corrected Class entries', () => { const parsed = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE) const map = new Map(parsed.map(p => [p.id, p])) @@ -125,26 +92,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() }) diff --git a/tests/tile-cache-seed.test.ts b/tests/tile-cache-seed.test.ts deleted file mode 100644 index 01c8a30..0000000 --- a/tests/tile-cache-seed.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, test } from "vitest" -import { DRLGROOMTILE_GetTileCache } from "../src/game/wilderness.ts" -import { Rng } from "../src/game/rng.ts" -import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts" - -function makeMockDt1(tiles: (Partial & { rarity?: number; Rarity?: number })[]): Dt1 { - return { - warnings: [], - versionMajor: 7, - versionMinor: 6, - tiles: tiles.map(t => ({ - direction: 0, - height: -80, - width: 160, - type: t.type ?? 0, - style: t.style ?? 0, - sequence: t.sequence ?? 0, - materialFlags: 0, - roofHeight: 0, - animated: t.animated ?? 0, - rarityFrameIndex: t.rarityFrameIndex ?? 1, - subTileFlags: [], - minBlockY: 0, - bitmapHeight: 80, - blocks: [], - ...t, - })), - } -} - -describe("DRLGROOMTILE_GetTileCache deterministic seed (Issue #78)", () => { - const dt1 = makeMockDt1([ - { type: 0, style: 0, sequence: 0, rarityFrameIndex: 20 }, - { type: 0, style: 0, sequence: 1, rarityFrameIndex: 30 }, - { type: 0, style: 0, sequence: 2, rarityFrameIndex: 50 }, - ]) - - describe("same seed produces identical result", () => { - test("same integer seed produces identical tile selection across multiple invocations", () => { - const query1 = { type: 0, seed: 12345 } - const query2 = { type: 0, seed: 12345 } - - const res1 = DRLGROOMTILE_GetTileCache([dt1], 1, query1) - const res2 = DRLGROOMTILE_GetTileCache([dt1], 1, query2) - - expect(res1).not.toBeNull() - expect(res2).not.toBeNull() - expect(res1).toEqual(res2) - }) - - test("same coordinates and seed produce identical tile selection", () => { - const resA = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 99999, x: 15, y: 27 }) - const resB = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 99999, x: 15, y: 27 }) - - expect(resA).not.toBeNull() - expect(resA).toEqual(resB) - }) - - test("same direct fractional roll produces identical tile selection", () => { - const resA = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 0.45 }) - const resB = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 0.45 }) - - expect(resA).not.toBeNull() - expect(resA).toEqual(resB) - }) - - test("identical Rng seeds produce identical sequences of tile selections", () => { - const rng1 = new Rng(88888) - const rng2 = new Rng(88888) - - const results1 = Array.from({ length: 20 }, () => - DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: rng1 })?.sequence, - ) - const results2 = Array.from({ length: 20 }, () => - DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: rng2 })?.sequence, - ) - - expect(results1).toEqual(results2) - }) - }) - - describe("different seeds produce varying selections over multiple runs", () => { - test("varying integer seeds select different tiles across runs", () => { - const seenSequences = new Set() - - for (let s = 0; s < 50; s += 1) { - const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: s * 1000 + 7 }) - if (res !== null) { - seenSequences.add(res.sequence) - } - } - - // All 3 candidate sequences (0, 1, 2) should be selected across 50 distinct seeds - expect(seenSequences.size).toBe(3) - expect(seenSequences.has(0)).toBe(true) - expect(seenSequences.has(1)).toBe(true) - expect(seenSequences.has(2)).toBe(true) - }) - - test("varying coordinates with same seed select different tiles", () => { - const seenSequences = new Set() - - for (let x = 0; x < 50; x += 1) { - const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x, y: 0 }) - if (res !== null) { - seenSequences.add(res.sequence) - } - } - - expect(seenSequences.size).toBeGreaterThan(1) - }) - - test("consecutive draws from a single Rng produce varying selections", () => { - const rng = new Rng(42) - const seenSequences = new Set() - - for (let i = 0; i < 50; i += 1) { - const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: rng }) - if (res !== null) { - seenSequences.add(res.sequence) - } - } - - expect(seenSequences.size).toBe(3) - }) - }) -}) diff --git a/tests/tilesub-113c.test.ts b/tests/tilesub-113c.test.ts deleted file mode 100644 index 13fe101..0000000 --- a/tests/tilesub-113c.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - applySubstitution, - applySubstitutions, - buildSubstitutionCandidatePermutation, - createCanvas, - stampSubstitutionPatch, -} from '../src/game/wilderness.ts' -import type { WildernessSubstitution } from '../src/game/wilderness.ts' -import { Rng } from '../src/game/rng.ts' -import type { Ds1 } from '../src/formats/ds1.ts' - -function makePatchDs1(width: number, height: number, floorSeq: number, wallSeq = 10, roofSeq = 20, shadowSeq = 30): Ds1 { - const cells = Array.from({ length: height }, () => - Array.from({ length: width }, () => ({ - floors: [{ prop1: 1, style: 5, sequence: floorSeq, hidden: false, unknown1: 0, unknown2: 0 }], - walls: [ - { prop1: 1, style: 6, sequence: wallSeq, type: 1, hidden: false, unknown1: 0, unknown2: 0 }, - { prop1: 1, style: 7, sequence: roofSeq, type: 15, hidden: false, unknown1: 0, unknown2: 0 }, - ], - shadows: [{ prop1: 1, style: 8, sequence: shadowSeq, hidden: false, unknown1: 0, unknown2: 0 }], - substitutions: [{ value: 1 }], - })), - ) - return { - version: 18, - width, - height, - act: 1, - substitutionType: 1, - wallLayers: 2, - floorLayers: 1, - cells, - objects: [{ type: 2, id: 15, x: 2, y: 2, flags: 0 }], - npcPathOffset: null, - } -} - -describe('Blizzard D2 1.13c LvlSub.txt Trials & Exhaustive Permutation Substitution (Issue #76)', () => { - it('generates a complete, duplicate-free Fisher-Yates candidate permutation that varies by seed', () => { - const permA = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(1001)) - const permB = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(2002)) - - expect(permA).toHaveLength(36) - expect(permB).toHaveLength(36) - - // Verify all 36 grid coordinates are present with zero duplicates - const keysA = new Set(permA.map(p => `${p.ox},${p.oy}`)) - expect(keysA.size).toBe(36) - - // Verify permutation order differs between seeds (not fixed top-left scan) - const firstFiveA = permA.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|') - const firstFiveB = permB.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|') - expect(firstFiveA).not.toBe(firstFiveB) - }) - - it('finds the sole remaining free candidate slot when Trials == -1 (exhaustive scan guarantee)', () => { - const gridWidth = 8 - const gridHeight = 8 - const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) - - // Claim every interior block EXCEPT (bx = 5, by = 4) - const claimedBlocks = Array.from({ length: gridHeight }, () => - Array.from({ length: gridWidth }, () => true), - ) - claimedBlocks[4]![5] = false - - const patch = makePatchDs1(8, 8, 42) - const subRow: WildernessSubstitution = { - name: 'Act 1 - Exhaustive Shrine', - type: 1, - bordType: -1, - gridSize: 1, - dt1Mask: 0, - prob: [100, 100, 100, 100, 100], - trials: [-1, -1, -1, -1, -1], - max: [1, 1, 1, 1, 1], - levels: [patch], - } - - const stamped = applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(999), claimedBlocks) - expect(stamped).toBe(1) - - // Verify the patch was stamped at exact block (bx = 5, by = 4) => tile (40, 32) - const targetCell = canvas.cells[32]![40]! - expect(targetCell.floors[0]?.sequence).toBe(42) - }) - - it('distributes Trials == -1 placements across varied interior locations rather than top-left (1,1)', () => { - const gridWidth = 10 - const gridHeight = 10 - const patch = makePatchDs1(8, 8, 55) - const subRow: WildernessSubstitution = { - name: 'Act 1 - Swamp Pool', - type: 1, - bordType: -1, - gridSize: 1, - dt1Mask: 0, - prob: [100, 100, 100, 100, 100], - trials: [-1, -1, -1, -1, -1], - max: [1, 1, 1, 1, 1], - levels: [patch], - } - - const placedCoords = new Set() - for (let seed = 1; seed <= 10; seed += 1) { - const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) - const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) - applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(seed * 7919), claimed) - for (let by = 1; by < gridHeight - 1; by += 1) { - for (let bx = 1; bx < gridWidth - 1; bx += 1) { - if (claimed[by]![bx]) placedCoords.add(`${bx},${by}`) - } - } - } - - // Across 10 seeds, Fisher-Yates permutation places the cluster in many different spots (not just 1,1) - expect(placedCoords.size).toBeGreaterThan(5) - }) - - it('atomically replaces Floor, Wall, Roof (type 15), and Shadow layers while clearing stale procedural clutter', () => { - const canvas = createCanvas(16, 16, 2, 1, 1) - - // Pre-populate cell (4, 4) with procedural clutter on floor, wall, roof, and shadow layers - const staleCell = canvas.cells[4]![4]! - ;(staleCell.floors as unknown[])[0] = { prop1: 1, style: 1, sequence: 1, hidden: false, unknown1: 0, unknown2: 0 } - ;(staleCell.walls as unknown[])[0] = { prop1: 1, style: 99, sequence: 99, type: 1, hidden: false, unknown1: 0, unknown2: 0 } - ;(staleCell.walls as unknown[])[1] = { prop1: 1, style: 99, sequence: 98, type: 15, hidden: false, unknown1: 0, unknown2: 0 } - ;(staleCell.shadows as unknown[])[0] = { prop1: 1, style: 99, sequence: 97, hidden: false, unknown1: 0, unknown2: 0 } - - const patch = makePatchDs1(2, 2, 11, 22, 33, 44) - const written = stampSubstitutionPatch(canvas, patch, 4, 4) - expect(written).toBe(4) - - const updatedCell = canvas.cells[4]![4]! - expect(updatedCell.floors[0]?.sequence).toBe(11) - expect(updatedCell.walls[0]?.sequence).toBe(22) - expect(updatedCell.walls[0]?.type).toBe(1) - expect(updatedCell.walls[1]?.sequence).toBe(33) - expect(updatedCell.walls[1]?.type).toBe(15) - expect(updatedCell.shadows[0]?.sequence).toBe(44) - expect(updatedCell.substitutions[0]?.value).toBe(1) - expect(canvas.objects).toHaveLength(1) - }) - - it('applies theme-indexed Prob0..4, Trials0..4, and Max0..4 with multi-variant selection', () => { - const gridWidth = 10 - const gridHeight = 10 - const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1) - const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false)) - - const variantA = makePatchDs1(8, 8, 101) - const variantB = makePatchDs1(8, 8, 102) - - const rows: WildernessSubstitution[] = [ - { - name: 'Theme 2 Multi-Variant Bivouac', - type: 1, - bordType: -1, - gridSize: 1, - dt1Mask: 0, - prob: [0, 0, 100, 0, 0], - trials: [0, 0, 20, 0, 0], - max: [0, 0, 4, 0, 0], - levels: [variantA, variantB], - }, - ] - - const stats = { - levelId: 2, - seed: 12345, - subTheme: 2, - gridWidth, - gridHeight, - substitutions: [] as { name: string; role: string; enabled: boolean; clusters: number }[], - borderPieces: {}, - unresolved: [], - notes: [], - } - - const total = applySubstitutions(canvas, rows, 2, gridWidth, gridHeight, new Rng(12345), stats as never, claimed) - expect(total).toBe(4) - expect(stats.substitutions[0]?.enabled).toBe(true) - expect(stats.substitutions[0]?.clusters).toBe(4) - }) -}) diff --git a/tests/variant-distribution.test.ts b/tests/variant-distribution.test.ts index 4b9921d..7af948b 100644 --- a/tests/variant-distribution.test.ts +++ b/tests/variant-distribution.test.ts @@ -1,94 +1,5 @@ import { describe, expect, test } from 'vitest' import { pickVariant, type PoolTile } from '../src/game/d2map.ts' -import { generateWilderness, type WildernessPiece } from '../src/game/wilderness.ts' -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' -import { ACT5_SIEGE_STRIP_NAMES } from '../src/game/wilderness-siege.ts' - -function makeMockDs1( - width: number, - height: number, - hasGapInWalls: boolean = false, - floorStyle: number = 1, - wallStyle: number = 1, -): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - const isGap = hasGapInWalls && x === Math.floor(width / 2) - row.push({ - walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { - const borderDs1 = makeMockDs1(8, 8, false) - const openBorderDs1 = makeMockDs1(8, 8, true) - return [ - { name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, - { name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, - { name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] }, - ] -} - -function makeMockStripDs1(width: number, height: number, tagSequence: 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: x === 0 && y === 0 ? tagSequence : 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 5, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function createMockSiegePieces(): WildernessPiece[] { - return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({ - name, - border: false, - levels: [makeMockStripDs1(16, 48, 100 + idx)], - })) -} describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => { describe('pickVariant 2D spatial coordinate hash and diagonal decorrelation', () => { @@ -194,172 +105,4 @@ describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => { expect(ratio1).toBeLessThan(0.80) }) }) - - describe('Kurast causeway valid stone floor styles (Level 82 & 79~81)', () => { - test('Kurast Causeway (82) uses valid stone floor style 20 and balustrade wall style 20', () => { - const groundDs1 = makeMockDs1(48, 16) - const pieces: WildernessPiece[] = [ - { name: 'Act 3 - Ground', border: false, levels: [groundDs1] }, - ] - - const result = generateWilderness({ - levelId: 82, - levelName: 'Kurast Causeway', - levelTypeName: 'Act 3 - Kurast', - sizeX: 48, - sizeY: 16, - subType: 0, - subTheme: 0, - seed: 82001, - pieces, - substitutions: [], - }) - - let foundCausewayFloor = false - let foundBalustradeWall = false - let foundInvalidStyle5 = false - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - for (const floor of cell.floors) { - if (floor.style === 5) foundInvalidStyle5 = true - if (floor.style === 20 && floor.prop1 === 194) foundCausewayFloor = true - } - for (const wall of cell.walls) { - if (wall.style === 20 && wall.prop1 === 129) foundBalustradeWall = true - } - } - } - - // Must NOT use fictional style 5 (not present in Kurast DT1s) - expect(foundInvalidStyle5).toBe(false) - // Must use valid Kurast stone floor style 20 - expect(foundCausewayFloor).toBe(true) - // Must use valid Kurast balustrade wall style 20 - expect(foundBalustradeWall).toBe(true) - }) - - test('Kurast Urban Districts (79 Lower Kurast) avenues and bridges use valid style 20', () => { - const pieces = makeMockBorderPieces('Act 3') - const result = generateWilderness({ - levelId: 79, - levelName: 'Lower Kurast', - levelTypeName: 'Act 3 - Kurast', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 79001, - pieces, - substitutions: [], - }) - - let foundAvenueFloor = false - let foundBridgeFloor = false - let foundInvalidStyle5 = false - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - for (const floor of cell.floors) { - if (floor.style === 5) foundInvalidStyle5 = true - if (floor.style === 20 && floor.prop1 === 194) foundAvenueFloor = true - } - } - } - - expect(foundInvalidStyle5).toBe(false) - expect(foundAvenueFloor).toBe(true) - }) - }) - - describe('Mesa canyon and chasm valid tile styles (Levels 104, 105, 106)', () => { - test.each([ - { levelId: 104, levelName: 'Outer Steppes' }, - { levelId: 105, levelName: 'Plains of Despair' }, - { levelId: 106, levelName: 'City of the Damned' }, - ])('$levelName ($levelId) uses Mesa floor style 10 instead of Lava style 20', ({ levelId, levelName }) => { - const pieces = makeMockBorderPieces('Act 4') - const result = generateWilderness({ - levelId, - levelName, - levelTypeName: 'Act 4 - Mesa', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: levelId * 1000 + 1, - pieces, - substitutions: [], - }) - - let foundMesaChasmFloor = false - let foundMesaBridgeFloor = false - let foundLavaStyle20 = false - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - for (const floor of cell.floors) { - if (floor.style === 20) foundLavaStyle20 = true - if (floor.style === 10 && floor.prop1 === 2) { - if (floor.sequence === 0) foundMesaChasmFloor = true - } - } - } - } - - // Mesa DT1s have no style 20 (only present in Lava.dt1) - expect(foundLavaStyle20).toBe(false) - // Valid Mesa chasm floor is style 10 - expect(foundMesaChasmFloor).toBe(true) - }) - }) - - describe('Siege barricades valid fence sequence numbers (Level 110)', () => { - test('Bloody Foothills (110) barricades use fence style 2 with valid sequence numbers 10 and 11', () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: 'Bloody Foothills', - levelTypeName: 'Act 5 - Siege', - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - let foundBarricadePalisade = false - let foundSplinteredEndCap = false - let foundInvalidSequence = false - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - for (const wall of cell.walls) { - if (wall.style === 2 && wall.prop1 === 129) { - // Intact palisade (type 1) - if (wall.type === 1) { - if (wall.sequence === 10) foundBarricadePalisade = true - else foundInvalidSequence = true - } - // Splintered end-cap post (type 7) - if (wall.type === 7) { - if (wall.sequence === 10 || wall.sequence === 11) foundSplinteredEndCap = true - else foundInvalidSequence = true - } - } - } - } - } - - expect(foundInvalidSequence).toBe(false) - expect(foundBarricadePalisade).toBe(true) - expect(foundSplinteredEndCap).toBe(true) - }) - }) }) diff --git a/tests/wilderness-acts.test.ts b/tests/wilderness-acts.test.ts deleted file mode 100644 index 57ef36c..0000000 --- a/tests/wilderness-acts.test.ts +++ /dev/null @@ -1,825 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { - generateWilderness, - getPerimeterOpenings, - UNIMPLEMENTED_PASSES, -} from '../src/game/wilderness.ts' -import type { WildernessPiece } from '../src/game/wilderness.ts' -import { - act2OasisRadius, - act5FrozenLakeRadius, - jitterFrac, -} from '../src/game/wilderness-acts.ts' -import type { - Act2TopographyStats, - Act3TopographyStats, - Act4TopographyStats, - Act5TopographyStats, -} from '../src/game/wilderness-acts.ts' -import { Rng } from '../src/game/rng.ts' -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' - -function makeMockDs1( - width: number, - height: number, - hasGapInWalls: boolean = false, - floorStyle: number = 1, - wallStyle: number = 1, -): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - const isGap = hasGapInWalls && x === Math.floor(width / 2) - row.push({ - walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { - const borderDs1 = makeMockDs1(8, 8, false) - const openBorderDs1 = makeMockDs1(8, 8, true) - return [ - { name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, - { name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] }, - { name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] }, - { name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] }, - ] -} - -describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => { - test('UNIMPLEMENTED_PASSES has 5 outdoor passes removed', () => { - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDESR_InitAct2OutdoorLevel') - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTPLACE_InitAct3OutdoorLevel') - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel') - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel') - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles') - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices') - expect(UNIMPLEMENTED_PASSES).toEqual([ - 'DRLGOUTPLACE_CreateLevelConnections', - 'DRLGOUTDOORS_SpawnAct3Mephisto', - ]) - }) - - describe('Act 2: DRLGOUTDESR_InitAct2OutdoorLevel', () => { - test('Rocky Waste (41) generates sand caravan trails and canyon ridge walls', () => { - const pieces = makeMockBorderPieces('Act 2') - const result = generateWilderness({ - levelId: 41, - levelName: 'Rocky Waste', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 41001, - pieces, - substitutions: [], - }) - - expect(result.stats.act2Topography).toBeDefined() - const topo = result.stats.act2Topography as Act2TopographyStats - expect(topo.sandTrailCells).toBeGreaterThan(50) - expect(topo.canyonWalls).toBeGreaterThan(20) - - let foundSandTrail = false - let foundCanyonWall = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundSandTrail = true - if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundCanyonWall = true - } - } - expect(foundSandTrail).toBe(true) - expect(foundCanyonWall).toBe(true) - }) - - test('Far Oasis (43) generates lush water oasis pools', () => { - const pieces = makeMockBorderPieces('Act 2') - const result = generateWilderness({ - levelId: 43, - levelName: 'Far Oasis', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 43001, - pieces, - substitutions: [], - }) - - expect(result.stats.act2Topography).toBeDefined() - const topo = result.stats.act2Topography as Act2TopographyStats - expect(topo.oasisPoolCells).toBeGreaterThan(15) - - let foundOasisWater = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundOasisWater = true - } - } - expect(foundOasisWater).toBe(true) - }) - - test('Valley of Snakes (45) creates narrow canyon gorge', () => { - const pieces = makeMockBorderPieces('Act 2') - const result = generateWilderness({ - levelId: 45, - levelName: 'Valley of Snakes', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 45001, - pieces, - substitutions: [], - }) - - expect(result.stats.act2Topography).toBeDefined() - const topo = result.stats.act2Topography as Act2TopographyStats - expect(topo.canyonWalls).toBeGreaterThan(30) - }) - - test('Canyon of the Magi (46) places 7 Tal Rasha tombs, central dais, and has 0 perimeter openings', () => { - const pieces = makeMockBorderPieces('Act 2') - expect(getPerimeterOpenings(46, 10, 10).size).toBe(0) - - const result = generateWilderness({ - levelId: 46, - levelName: 'Canyon of the Magi', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 46001, - pieces, - substitutions: [], - }) - - expect(result.stats.act2Topography).toBeDefined() - const topo = result.stats.act2Topography as Act2TopographyStats - expect(topo.tombEntrances).toBe(7) - - // Verify central Arcane Sanctuary warp platform (portal/dais) - let foundCentralDais = false - for (let y = 35; y <= 45; y += 1) { - for (let x = 35; x <= 45; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 3)) foundCentralDais = true - } - } - expect(foundCentralDais).toBe(true) - }) - }) - - describe('Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles', () => { - test('Jungles (76 Spider Forest) generate river channel, wooden river bridges, and jungle paths', () => { - const tailDs1 = makeMockDs1(64, 32) - const headDs1 = makeMockDs1(64, 32) - const clearingDs1 = makeMockDs1(32, 32) - const riverDs1 = makeMockDs1(32, 32) - - const pieces: WildernessPiece[] = [ - { name: 'Act 3 - Jungle Tail', border: false, levels: [tailDs1] }, - { name: 'Act 3 - Jungle Head', border: false, levels: [headDs1] }, - { name: 'Act 3 - Clearing Webby E', border: false, levels: [clearingDs1] }, - { name: 'Act 3 - Jungle NS W', border: false, levels: [riverDs1] }, - ] - - const result = generateWilderness({ - levelId: 76, - levelName: 'Spider Forest', - levelTypeName: 'Act 3 - Jungle', - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - seed: 76001, - pieces, - substitutions: [], - }) - - expect(result.stats.act3Topography).toBeDefined() - const topo = result.stats.act3Topography as Act3TopographyStats - expect(topo.riverBridges).toBeGreaterThanOrEqual(2) - expect(topo.junglePathCells).toBeGreaterThan(20) - - // Verify wooden river bridge planks (style 4, prop1 2) spanning across East channel - let foundBridgePlank = false - for (let y = 70; y <= 90; y += 1) { - for (let x = 32; x < 60; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) foundBridgePlank = true - } - } - expect(foundBridgePlank).toBe(true) - }) - - test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => { - const groundDs1 = makeMockDs1(48, 16) - const pieces: WildernessPiece[] = [ - { name: 'Act 3 - Ground', border: false, levels: [groundDs1] }, - ] - - const result = generateWilderness({ - levelId: 82, - levelName: 'Kurast Causeway', - levelTypeName: 'Act 3 - Kurast', - sizeX: 48, - sizeY: 16, - subType: 0, - subTheme: 0, - seed: 82001, - pieces, - substitutions: [], - }) - - expect(result.stats.act3Topography).toBeDefined() - const topo = result.stats.act3Topography as Act3TopographyStats - expect(topo.causewayCells).toBeGreaterThan(50) - expect(topo.canalCells).toBeGreaterThan(100) - - // Verify central walkway (style 5), stone balustrade walls (style 1), and canal water (style 2) - let foundWalkway = false - let foundBalustrade = false - let foundCanalWater = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => (f.style === 5 || f.style === 20) && f.prop1 === 194)) foundWalkway = true - if (cell.walls.some(w => (w.style === 1 || w.style === 20) && w.prop1 === 129)) foundBalustrade = true - if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true - } - } - expect(foundWalkway).toBe(true) - expect(foundBalustrade).toBe(true) - expect(foundCanalWater).toBe(true) - }) - - test('Kurast Causeway (82) preserves preset when Act 3 - Bridge is provided', () => { - const bridgeDs1 = makeMockDs1(48, 16, false, 29, 31) - const pieces: WildernessPiece[] = [ - { name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] }, - ] - - const result = generateWilderness({ - levelId: 82, - levelName: 'Kurast Causeway', - levelTypeName: 'Act 3 - Kurast', - sizeX: 48, - sizeY: 16, - subType: 0, - subTheme: 0, - seed: 82001, - pieces, - substitutions: [], - }) - - let foundPresetFloor = false - let foundPresetWall = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 29)) foundPresetFloor = true - if (cell.walls.some(w => w.style === 31)) foundPresetWall = true - } - } - expect(foundPresetFloor).toBe(true) - expect(foundPresetWall).toBe(true) - }) - - test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => { - const pieces = makeMockBorderPieces('Act 3') - const result = generateWilderness({ - levelId: 79, - levelName: 'Kurast Bazaar', - levelTypeName: 'Act 3 - Kurast', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 79001, - pieces, - substitutions: [], - }) - - expect(result.stats.act3Topography).toBeDefined() - const topo = result.stats.act3Topography as Act3TopographyStats - expect(topo.causewayCells).toBeGreaterThan(50) - expect(topo.canalCells).toBeGreaterThan(30) - expect(topo.riverBridges).toBeGreaterThanOrEqual(1) - }) - }) - - describe('Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel', () => { - test('Outer Steppes (104) generates lava chasms, basalt lips, and rocky bridge causeways', () => { - const pieces = makeMockBorderPieces('Act 4') - const result = generateWilderness({ - levelId: 104, - levelName: 'Outer Steppes', - levelTypeName: 'Act 4 - Mesa', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 104001, - pieces, - substitutions: [], - }) - - expect(result.stats.act4Topography).toBeDefined() - const topo = result.stats.act4Topography as Act4TopographyStats - expect(topo.lavaChasmCells).toBeGreaterThan(100) - expect(topo.rockyBridges).toBeGreaterThan(5) - - // Verify boiling molten lava (style 20, sequence 3, prop1 2) and rocky bridges (style 10, sequence 0, prop1 2) - let foundLava = false - let foundRockyBridge = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => (f.style === 20 || f.style === 10) && f.prop1 === 2)) foundLava = true - if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true - } - } - expect(foundLava).toBe(true) - expect(foundRockyBridge).toBe(true) - }) - - test('Plains of Despair (105) creates Izual prison plateau', () => { - const pieces = makeMockBorderPieces('Act 4') - const result = generateWilderness({ - levelId: 105, - levelName: 'Plains of Despair', - levelTypeName: 'Act 4 - Mesa', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 105001, - pieces, - substitutions: [], - }) - - expect(result.stats.act4Topography).toBeDefined() - const topo = result.stats.act4Topography as Act4TopographyStats - expect(topo.plateauCells).toBeGreaterThan(20) - }) - }) - - describe('Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel', () => { - test('Frigid Highlands (111) generates transverse siege barricades, choke-point gates, and trenches', () => { - const pieces = makeMockBorderPieces('Act 5') - const result = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 111001, - pieces, - substitutions: [], - }) - - expect(result.stats.act5Topography).toBeDefined() - const topo = result.stats.act5Topography as Act5TopographyStats - expect(topo.barricadeWalls).toBeGreaterThan(40) - expect(topo.trenchCells).toBeGreaterThan(30) - - // Verify spiked palisade walls (style 2, prop1 129) and trench floors (style 0/6) - let foundBarricadeWall = false - let foundTrench = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) foundBarricadeWall = true - if (cell.floors.some(f => (f.style === 0 || f.style === 6) && f.prop1 === 2)) foundTrench = true - } - } - expect(foundBarricadeWall).toBe(true) - expect(foundTrench).toBe(true) - }) - - test('Arreat Plateau (112) generates snow crater frozen lakes with ice floors', () => { - const pieces = makeMockBorderPieces('Act 5') - const result = generateWilderness({ - levelId: 112, - levelName: 'Arreat Plateau', - levelTypeName: 'Act 5 - Barricade', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 112001, - pieces, - substitutions: [], - }) - - expect(result.stats.act5Topography).toBeDefined() - const topo = result.stats.act5Topography as Act5TopographyStats - expect(topo.frozenLakeCells).toBeGreaterThan(20) - - let foundIceFloor = false - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)) foundIceFloor = true - } - } - expect(foundIceFloor).toBe(true) - }) - }) - - describe('Phase D2: Organic Geometries & Fractional Jitter (Milestone M2)', () => { - function computePolarIsoperimetricQuotient( - radiusFn: (theta: number) => number, - steps: number = 1000, - ): { area: number; perimeter: number; quotient: number } { - const dTheta = (2 * Math.PI) / steps - let area = 0 - let perimeter = 0 - for (let i = 0; i < steps; i += 1) { - const t1 = i * dTheta - const t2 = (i + 1) * dTheta - const r1 = radiusFn(t1) - const r2 = radiusFn(t2) - area += 0.5 * r1 * r2 * Math.sin(dTheta) - const x1 = r1 * Math.cos(t1) - const y1 = r1 * Math.sin(t1) - const x2 = r2 * Math.cos(t2) - const y2 = r2 * Math.sin(t2) - perimeter += Math.hypot(x2 - x1, y2 - y1) - } - const quotient = (perimeter * perimeter) / area - return { area, perimeter, quotient } - } - - test('Act 2 Oasis satisfies organic isoperimetric quotient P^2/A > 4pi * 1.15', () => { - const threshold = 4 * Math.PI * 1.15 - - // 1. Analytical contour integration across various phase angles - const testPhases = [ - [0, 0], - [0.5, 1.2], - [Math.PI / 3, Math.PI / 4], - [2.1, 0.9], - ] - for (const [phi1, phi2] of testPhases) { - const { quotient } = computePolarIsoperimetricQuotient( - theta => act2OasisRadius(10, theta, phi1, phi2), - 1000, - ) - expect(quotient).toBeGreaterThan(threshold) - } - - // 2. Discrete raster grid verification on generated Far Oasis level - const pieces = makeMockBorderPieces('Act 2') - const result = generateWilderness({ - levelId: 43, - levelName: 'Far Oasis', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 43002, - pieces, - substitutions: [], - }) - - const cells = result.level.cells - const W = result.level.width - const H = result.level.height - const isOasis = (x: number, y: number): boolean => { - if (x < 0 || x >= W || y < 0 || y >= H) return false - return cells[y]![x]!.floors.some(f => f.style === 2 && f.prop1 === 2) - } - - let area = 0 - let perimeter = 0 - for (let y = 0; y < H; y += 1) { - for (let x = 0; x < W; x += 1) { - if (isOasis(x, y)) { - area += 1 - let exposedEdges = 0 - if (!isOasis(x + 1, y)) exposedEdges += 1 - if (!isOasis(x - 1, y)) exposedEdges += 1 - if (!isOasis(x, y + 1)) exposedEdges += 1 - if (!isOasis(x, y - 1)) exposedEdges += 1 - perimeter += exposedEdges - } - } - } - expect(area).toBeGreaterThan(15) - const discreteQuotient = (perimeter * perimeter) / area - expect(discreteQuotient).toBeGreaterThan(threshold) - }) - - test('Act 5 Frozen Lake satisfies organic isoperimetric quotient P^2/A > 4pi * 1.15', () => { - const threshold = 4 * Math.PI * 1.15 - - // 1. Analytical contour integration across various phase angles - const testPhases = [ - [0, 0], - [0.7, 1.5], - [Math.PI / 2, Math.PI / 6], - [1.8, 2.3], - ] - for (const [phi1, phi2] of testPhases) { - const { quotient } = computePolarIsoperimetricQuotient( - theta => act5FrozenLakeRadius(10, theta, phi1, phi2), - 1000, - ) - expect(quotient).toBeGreaterThan(threshold) - } - - // 2. Discrete raster grid verification on generated Arreat Plateau level - const pieces = makeMockBorderPieces('Act 5') - const result = generateWilderness({ - levelId: 112, - levelName: 'Arreat Plateau', - levelTypeName: 'Act 5 - Barricade', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 112002, - pieces, - substitutions: [], - }) - - const cells = result.level.cells - const W = result.level.width - const H = result.level.height - const isLake = (x: number, y: number): boolean => { - if (x < 0 || x >= W || y < 0 || y >= H) return false - return cells[y]![x]!.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2) - } - - let area = 0 - let perimeter = 0 - for (let y = 0; y < H; y += 1) { - for (let x = 0; x < W; x += 1) { - if (isLake(x, y)) { - area += 1 - let exposedEdges = 0 - if (!isLake(x + 1, y)) exposedEdges += 1 - if (!isLake(x - 1, y)) exposedEdges += 1 - if (!isLake(x, y + 1)) exposedEdges += 1 - if (!isLake(x, y - 1)) exposedEdges += 1 - perimeter += exposedEdges - } - } - } - expect(area).toBeGreaterThan(20) - const discreteQuotient = (perimeter * perimeter) / area - expect(discreteQuotient).toBeGreaterThan(threshold) - }) - - test('Act 4 Lava Fissure guarantees >= 90% walkable flood-fill connectivity', () => { - const pieces = makeMockBorderPieces('Act 4') - const testSeeds = [104001, 104002, 104003, 104004] - - for (const seed of testSeeds) { - const result = generateWilderness({ - levelId: 104, - levelName: 'Outer Steppes', - levelTypeName: 'Act 4 - Mesa', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - - const W = result.level.width - const H = result.level.height - const hasWall = (x: number, y: number): boolean => { - const cell = result.level.cells[y]![x]! - return cell.walls.some(w => w.prop1 !== 0 || w.style !== 0) - } - const isWalkable = (x: number, y: number): boolean => !hasWall(x, y) - - // Find all walkable cells in the playable interior [8..W-8] x [8..H-8] - let totalWalkable = 0 - let startX = -1 - let startY = -1 - for (let y = 8; y < H - 8; y += 1) { - for (let x = 8; x < W - 8; x += 1) { - if (isWalkable(x, y)) { - totalWalkable += 1 - if (startX === -1 && y < 16) { - startX = x - startY = y - } - } - } - } - - expect(totalWalkable).toBeGreaterThan(1000) - expect(startX).toBeGreaterThanOrEqual(8) - - // BFS flood-fill from starting walkable cell - const visited = new Set() - const queue: [number, number][] = [[startX, startY]] - visited.add(startY * W + startX) - - while (queue.length > 0) { - const [cx, cy] = queue.shift()! - for (const [dx, dy] of [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1], - ]) { - const nx = cx + dx - const ny = cy + dy - if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) { - const key = ny * W + nx - if (!visited.has(key) && isWalkable(nx, ny)) { - visited.add(key) - queue.push([nx, ny]) - } - } - } - } - - const reachableRatio = visited.size / totalWalkable - expect(reachableRatio).toBeGreaterThanOrEqual(0.90) - } - }) - - test('Act 5 Barricade palisades cut horizontally across X when height > width with gate at hubX', () => { - const pieces = makeMockBorderPieces('Act 5') - - // 1. Vertical corridor (height > width): palisades run horizontally across X - const vertResult = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: 48, - sizeY: 160, - subType: 0, - subTheme: 0, - seed: 111001, - pieces, - substitutions: [], - }) - - const vW = vertResult.level.width - const vH = vertResult.level.height - const vGridW = Math.floor(48 / 8) - const vInX = Math.floor(vGridW / 2) - const vHubX = vInX * 8 + 4 - - // Locate horizontal barricade rows (at least 15 palisade wall tiles across the row) - const horizontalBarricadeRows: number[] = [] - for (let y = 8; y < vH - 8; y += 1) { - let count = 0 - for (let x = 8; x < vW - 8; x += 1) { - const cell = vertResult.level.cells[y]![x]! - if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) { - count += 1 - } - } - if (count >= 15) { - horizontalBarricadeRows.push(y) - } - } - - expect(horizontalBarricadeRows.length).toBeGreaterThanOrEqual(2) - - // Verify each horizontal barricade row has an open choke-point gate at vHubX - for (const by of horizontalBarricadeRows) { - const gateCell = vertResult.level.cells[by]![vHubX]! - const hasWall = gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) - expect(hasWall).toBe(false) - expect(gateCell.floors.some(f => (f.style === 0 || f.style === 6) && f.prop1 === 2)).toBe(true) - } - - // 2. Horizontal corridor (width >= height): palisades run vertically across Y - const horizResult = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: 160, - sizeY: 48, - subType: 0, - subTheme: 0, - seed: 111002, - pieces, - substitutions: [], - }) - - const hW = horizResult.level.width - const hH = horizResult.level.height - const hGridH = Math.floor(48 / 8) - const hOutY = Math.floor(hGridH / 2) - const hHubY = hOutY * 8 + 4 - - const verticalBarricadeCols: number[] = [] - for (let x = 8; x < hW - 8; x += 1) { - let count = 0 - for (let y = 8; y < hH - 8; y += 1) { - const cell = horizResult.level.cells[y]![x]! - if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) { - count += 1 - } - } - if (count >= 15) { - verticalBarricadeCols.push(x) - } - } - - expect(verticalBarricadeCols.length).toBeGreaterThanOrEqual(2) - - // Verify each vertical barricade col has an open choke-point gate at hHubY - for (const bx of verticalBarricadeCols) { - const gateCell = horizResult.level.cells[hHubY]![bx]! - const hasWall = gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) - expect(hasWall).toBe(false) - expect(gateCell.floors.some(f => (f.style === 0 || f.style === 6) && f.prop1 === 2)).toBe(true) - } - }) - - test('Topographical jitter is bounded within +-0.05 and varies across seeds', () => { - const rng = new Rng(0x98765432) - const samples: number[] = [] - for (let i = 0; i < 300; i += 1) { - const val = jitterFrac(rng, 0.50, 0.05) - expect(val).toBeGreaterThanOrEqual(0.45) - expect(val).toBeLessThanOrEqual(0.55) - samples.push(val) - } - - const mean = samples.reduce((a, b) => a + b, 0) / samples.length - expect(Math.abs(mean - 0.50)).toBeLessThan(0.02) - const min = Math.min(...samples) - const max = Math.max(...samples) - expect(max - min).toBeGreaterThan(0.08) - - // Verify that across different seeds, generated topographical feature counts vary organically - const pieces = makeMockBorderPieces('Act 2') - const seeds = [43001, 43002, 43003, 43004, 43005] - const results = seeds.map(s => - generateWilderness({ - levelId: 43, - levelName: 'Far Oasis', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: s, - pieces, - substitutions: [], - }), - ) - - const sandTrailStats = results.map( - r => (r.stats.act2Topography as Act2TopographyStats).sandTrailCells, - ) - const oasisPoolStats = results.map( - r => (r.stats.act2Topography as Act2TopographyStats).oasisPoolCells, - ) - - // Confirm organic variation across runs - const uniqueSandTrails = new Set(sandTrailStats) - const uniqueOasisPools = new Set(oasisPoolStats) - expect(uniqueSandTrails.size).toBeGreaterThan(1) - expect(uniqueOasisPools.size).toBeGreaterThan(1) - }) - }) -}) - diff --git a/tests/wilderness-barricade-stress.test.ts b/tests/wilderness-barricade-stress.test.ts deleted file mode 100644 index 57f2132..0000000 --- a/tests/wilderness-barricade-stress.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - resolveUnsetSize, - UnresolvedLevelSizeError, - generateWilderness, - type WildernessPiece, -} from "../src/game/wilderness.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockDs1(width: number, height: number, floorStyle: number = 1): 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: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 5, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -describe("Adversarial Stress Testing: resolveUnsetSize and Barricade Extent", () => { - test("resolveUnsetSize throws UnresolvedLevelSizeError when pieces array is empty for Level 111 and 112", () => { - for (const levelId of [111, 112]) { - expect(() => { - resolveUnsetSize({ - levelId, - levelName: `Act 5 - Barricade ${levelId - 110}`, - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - }).toThrow(UnresolvedLevelSizeError) - } - }) - - test("resolveUnsetSize throws UnresolvedLevelSizeError for non-barricade levels without pieces across Acts 1..4", () => { - const testCases = [ - { levelId: 2, name: "Blood Moor", type: "Act 1 - Wilderness" }, - { levelId: 41, name: "Rocky Waste", type: "Act 2 - Desert" }, - { levelId: 76, name: "Spider Forest", type: "Act 3 - Jungle" }, - { levelId: 104, name: "Outer Steppes", type: "Act 4 - Mesa" }, - ] - - for (const tc of testCases) { - expect(() => { - resolveUnsetSize({ - levelId: tc.levelId, - levelName: tc.name, - levelTypeName: tc.type, - sizeX: -1, - sizeY: -1, - subType: 0, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - }).toThrow(UnresolvedLevelSizeError) - } - }) - - test("resolveUnsetSize throws UnresolvedLevelSizeError for non-barricade levels with non-barricade pieces", () => { - const mockBorderPiece: WildernessPiece = { - name: "Act 1 - Wild Border 1", - border: true, - levels: [makeMockDs1(8, 8)], - } - - expect(() => { - resolveUnsetSize({ - levelId: 2, - levelName: "Blood Moor", - levelTypeName: "Act 1 - Wilderness", - sizeX: -1, - sizeY: -1, - subType: 6, - subTheme: 0, - seed: 12345, - pieces: [mockBorderPiece], - substitutions: [], - }) - }).toThrow(UnresolvedLevelSizeError) - }) - - test("resolveUnsetSize handles combinations of entrance only, exit only, multiple entrances, and multiple exits", () => { - const entranceDs1 = makeMockDs1(16, 32) - const exitDs1 = makeMockDs1(16, 32) - const wallDs1 = makeMockDs1(17, 17) - - // Entrance only - const entranceOnly: WildernessPiece[] = [ - { name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] }, - { name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] }, - ] - const resEntranceOnly = resolveUnsetSize({ - levelId: 111, - levelName: "Act 5 - Barricade 1", - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: entranceOnly, - substitutions: [], - }) - expect(resEntranceOnly.sizeY).toBeGreaterThan(resEntranceOnly.sizeX) - expect(resEntranceOnly.sizeX).toBeGreaterThanOrEqual(48) - expect(resEntranceOnly.sizeX).toBeLessThanOrEqual(64) - expect(resEntranceOnly.sizeY).toBeGreaterThanOrEqual(160) - expect(resEntranceOnly.sizeY).toBeLessThanOrEqual(192) - - // Exit only - const exitOnly: WildernessPiece[] = [ - { name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] }, - { name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] }, - ] - const resExitOnly = resolveUnsetSize({ - levelId: 111, - levelName: "Act 5 - Barricade 1", - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: exitOnly, - substitutions: [], - }) - expect(resExitOnly.sizeY).toBeGreaterThan(resExitOnly.sizeX) - expect(resExitOnly.sizeX).toBeGreaterThanOrEqual(48) - expect(resExitOnly.sizeX).toBeLessThanOrEqual(64) - expect(resExitOnly.sizeY).toBeGreaterThanOrEqual(160) - expect(resExitOnly.sizeY).toBeLessThanOrEqual(192) - - // Multiple entrances & exits - const multiplePieces: WildernessPiece[] = [ - { name: "Act 5 - Barricade Entrance A", border: false, levels: [entranceDs1] }, - { name: "Act 5 - Barricade Entrance B", border: false, levels: [makeMockDs1(24, 40)] }, - { name: "Act 5 - Barricade Exit A", border: false, levels: [exitDs1] }, - { name: "Act 5 - Barricade Exit B", border: false, levels: [makeMockDs1(32, 48)] }, - { name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] }, - ] - const resMultiple = resolveUnsetSize({ - levelId: 111, - levelName: "Act 5 - Barricade 1", - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: multiplePieces, - substitutions: [], - }) - expect(resMultiple.sizeY).toBeGreaterThan(resMultiple.sizeX) - expect(resMultiple.sizeX).toBeGreaterThanOrEqual(48) - expect(resMultiple.sizeX).toBeLessThanOrEqual(64) - expect(resMultiple.sizeY).toBeGreaterThanOrEqual(160) - expect(resMultiple.sizeY).toBeLessThanOrEqual(192) - }) - - test("resolveUnsetSize guarantees sizeY > sizeX across 1,000 combinatorial fuzzing trials", () => { - const mockWallPiece: WildernessPiece = { - name: "Act 5 - Barricade Wall 1", - border: false, - levels: [makeMockDs1(17, 17)], - } - - for (let trial = 0; trial < 1000; trial += 1) { - const numEntrances = trial % 5 - const numExits = (trial * 3) % 5 - - const pieces: WildernessPiece[] = [mockWallPiece] - - for (let i = 0; i < numEntrances; i += 1) { - const w = 1 + ((trial * 7 + i * 11) % 128) - const h = 1 + ((trial * 13 + i * 17) % 256) - pieces.push({ - name: `Act 5 - Barricade Entrance ${i}`, - border: false, - levels: [makeMockDs1(w, h)], - }) - } - - for (let i = 0; i < numExits; i += 1) { - const w = 1 + ((trial * 19 + i * 23) % 128) - const h = 1 + ((trial * 29 + i * 31) % 256) - pieces.push({ - name: `Act 5 - Barricade Exit ${i}`, - border: false, - levels: [makeMockDs1(w, h)], - }) - } - - const res = resolveUnsetSize({ - levelId: 111, - levelName: "Act 5 - Barricade 1", - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: trial, - pieces, - substitutions: [], - }) - - expect(res.sizeX).toBeGreaterThanOrEqual(48) - expect(res.sizeX).toBeLessThanOrEqual(64) - expect(res.sizeX % 8).toBe(0) - expect(res.sizeY).toBeGreaterThanOrEqual(160) - expect(res.sizeY).toBeLessThanOrEqual(192) - expect(res.sizeY % 8).toBe(0) - expect(res.sizeY).toBeGreaterThan(res.sizeX) - expect(res.sizeY / res.sizeX).toBeGreaterThanOrEqual(2.5) - expect(res.source).toBe("LvlPrest Barricade corridor dynamic extent") - } - }) - - test("generateWilderness generates Level 111 and 112 conforming to vertical corridor bounds", () => { - const entranceDs1 = makeMockDs1(17, 33) - const exitDs1 = makeMockDs1(17, 33) - const wallDs1 = makeMockDs1(17, 17) - const cliffDs1 = makeMockDs1(17, 17) - - const pieces: WildernessPiece[] = [ - { name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] }, - { name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] }, - { name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] }, - { name: "Act 5 - Barricade 2", border: false, levels: [wallDs1] }, - { name: "Act 5 - Barricade Cliff Border 1", border: true, levels: [cliffDs1] }, - ] - - for (const levelId of [111, 112]) { - const generated = generateWilderness({ - levelId, - levelName: `Act 5 - Barricade ${levelId - 110}`, - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 0x301cd095 + levelId, - pieces, - substitutions: [], - }) - - expect(Number(generated.stats.sizeX)).toBeGreaterThanOrEqual(48) - expect(Number(generated.stats.sizeX)).toBeLessThanOrEqual(64) - expect(Number(generated.stats.sizeY)).toBeGreaterThanOrEqual(160) - expect(Number(generated.stats.sizeY)).toBeLessThanOrEqual(192) - expect(Number(generated.stats.sizeY)).toBeGreaterThan(Number(generated.stats.sizeX)) - expect(generated.level.width).toBe(Number(generated.stats.sizeX)) - expect(generated.level.height).toBe(Number(generated.stats.sizeY)) - } - }) -}) diff --git a/tests/wilderness-borders.test.ts b/tests/wilderness-borders.test.ts deleted file mode 100644 index 1906945..0000000 --- a/tests/wilderness-borders.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - classifyBorderPiece, - getCellOrientation, - selectBorderPiece, - selectBorderVariant, - isClosedBorder, - getPerimeterOpenings, - DRLGOUTPLACE_PlaceAct1245OutdoorBorders, - layBorder, - generateWilderness, - type WildernessPiece, - type WildernessStats, - type BorderOrientation, -} from "../src/game/wilderness.ts" -import { createCanvas } from "../src/game/wilderness.ts" -import { Rng } from "../src/game/rng.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockBorderDs1(width: number, height: number, orientation: BorderOrientation, isOpen: boolean): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - let isWall: boolean - if (isOpen) { - // Open variant: gap in the middle of the border edge - if (orientation === "south" || orientation === "north") { - isWall = x !== Math.floor(width / 2) - } else if (orientation === "west" || orientation === "east") { - isWall = y !== Math.floor(height / 2) - } else { - isWall = true - } - } else { - // Closed variant: continuous wall along the border - isWall = true - } - row.push({ - walls: isWall ? [{ prop1: 129, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [], - floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function createEmptyStats(): WildernessStats { - return { - substitutions: [], - borderPieces: {}, - unresolved: [], - notes: [], - borderStamped: 0, - groundCells: 0, - groundTile: null, - sizeSource: "test", - roadCells: 0, - roadSegments: 0, - anchors: 0, - specialPresets: [], - entrances: [], - proceduralRoadCells: 0, - proceduralDividerCells: 0, - proceduralWaterCells: 0, - proceduralWallCells: 0, - } -} - -describe("Issue #67: DRLGOUTPLACE_PlaceAct1245OutdoorBorders & Multi-Act Border Classification", () => { - describe("Task 1A: classifyBorderPiece across Acts 1, 2, 3, 4, 5", () => { - test("Act 1: Wild Border 1..12 and Wild Cliff Border 2..10 (including 6A-C and corner 10)", () => { - // Edges: 1=S, 2=W, 3=N, 4=E - expect(classifyBorderPiece("Act 1 - Wild Border 1")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 2")).toEqual({ orientation: "west", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 3")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 4")).toEqual({ orientation: "east", isCliff: false }) - - // Corners: 5/9=SW, 6/10=NW, 7/11=NE, 8/12=SE - expect(classifyBorderPiece("Act 1 - Wild Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 7")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 8")).toEqual({ orientation: "corner-se", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 9")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 10")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 11")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Border 12")).toEqual({ orientation: "corner-se", isCliff: false }) - - // Wild Cliff borders: isCliff: true - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 2")).toEqual({ orientation: "west", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 3")).toEqual({ orientation: "north", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6A")).toEqual({ orientation: "corner-nw", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6B")).toEqual({ orientation: "corner-nw", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6C")).toEqual({ orientation: "corner-nw", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 7")).toEqual({ orientation: "corner-ne", isCliff: true }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Border 10")).toEqual({ orientation: "corner-nw", isCliff: true }) - - // Caves are excluded from generic borders - expect(classifyBorderPiece("Act 1 - Wild Cliff Cave Right")).toEqual({ orientation: null, isCliff: false }) - expect(classifyBorderPiece("Act 1 - Wild Cliff Cave Left")).toEqual({ orientation: null, isCliff: false }) - }) - - test("Act 2: Desert Border 1..12 and Desert Cliff Left/Right/Top/Tomb variants", () => { - // Desert Border 1..12 - expect(classifyBorderPiece("Act 2 - Desert Border 1")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 2")).toEqual({ orientation: "west", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 3")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 4")).toEqual({ orientation: "east", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 7")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 8")).toEqual({ orientation: "corner-se", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 9")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 10")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 11")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 2 - Desert Border 12")).toEqual({ orientation: "corner-se", isCliff: false }) - - // Desert Cliff Left / Right / Top variants - expect(classifyBorderPiece("Act 2 - Desert Cliff Left Wall")).toEqual({ orientation: "west", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Left Path")).toEqual({ orientation: "west", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Left Ends")).toEqual({ orientation: "west", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Left King Tomb")).toEqual({ orientation: "west", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Right Wall")).toEqual({ orientation: "east", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Right Path")).toEqual({ orientation: "east", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Right Ends")).toEqual({ orientation: "east", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Right King Tomb")).toEqual({ orientation: "east", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Top")).toEqual({ orientation: "north", isCliff: true }) - expect(classifyBorderPiece("Act 2 - Desert Cliff Top King Tomb")).toEqual({ orientation: "north", isCliff: true }) - }) - - test("Act 3: Slums/Burbs/Metro Border cardinal directions (N, S, E, W, NE, NW, SE, SW)", () => { - expect(classifyBorderPiece("Act 3 - Slums Border N")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border S")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border E")).toEqual({ orientation: "east", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border W")).toEqual({ orientation: "west", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border NE")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border NW")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border SE")).toEqual({ orientation: "corner-se", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Slums Border SW")).toEqual({ orientation: "corner-sw", isCliff: false }) - - expect(classifyBorderPiece("Act 3 - Burbs Border N")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Burbs Border SW")).toEqual({ orientation: "corner-sw", isCliff: false }) - - expect(classifyBorderPiece("Act 3 - Metro Border E")).toEqual({ orientation: "east", isCliff: false }) - expect(classifyBorderPiece("Act 3 - Metro Border SE")).toEqual({ orientation: "corner-se", isCliff: false }) - }) - - test("Act 4: Mesa Border 1..12", () => { - expect(classifyBorderPiece("Act 4 - Mesa Border 1")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 2")).toEqual({ orientation: "west", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 3")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 4")).toEqual({ orientation: "east", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 7")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 8")).toEqual({ orientation: "corner-se", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 9")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 10")).toEqual({ orientation: "corner-nw", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 11")).toEqual({ orientation: "corner-ne", isCliff: false }) - expect(classifyBorderPiece("Act 4 - Mesa Border 12")).toEqual({ orientation: "corner-se", isCliff: false }) - }) - - test("Act 5: Barricade Cliff Border 1..12, Barricade Ravine Border 1..6, and Snow variants", () => { - // Barricade Cliff: isCliff: true - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 1")).toEqual({ orientation: "south", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 2")).toEqual({ orientation: "west", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 3")).toEqual({ orientation: "north", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 4")).toEqual({ orientation: "east", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 6")).toEqual({ orientation: "corner-nw", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 7")).toEqual({ orientation: "corner-ne", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 8")).toEqual({ orientation: "corner-se", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 9")).toEqual({ orientation: "corner-sw", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 10")).toEqual({ orientation: "corner-nw", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 11")).toEqual({ orientation: "corner-ne", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 12")).toEqual({ orientation: "corner-se", isCliff: true }) - - // Barricade Ravine: isCliff: false - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 1")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 2")).toEqual({ orientation: "west", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 3")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 4")).toEqual({ orientation: "east", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 5")).toEqual({ orientation: "corner-sw", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) - - // Corner join / transition pieces - expect(classifyBorderPiece("Act 5 - Barricade Ravine-Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Cliff Ravine Border 7")).toEqual({ orientation: "corner-ne", isCliff: true }) - - // Snow variants - expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 1 Snow")).toEqual({ orientation: "south", isCliff: true }) - expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 1 Snow")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Snow Border 1")).toEqual({ orientation: "south", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Snow Border 2")).toEqual({ orientation: "west", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Snow Border 3")).toEqual({ orientation: "north", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Snow Border 4")).toEqual({ orientation: "east", isCliff: false }) - expect(classifyBorderPiece("Act 5 - Snow Border 6")).toEqual({ orientation: "corner-nw", isCliff: false }) - }) - }) - - describe("Task 1B: selectBorderPiece cliff preference", () => { - test("prefers cliff pieces when preferCliff is true and non-cliff when false", () => { - const cliffPiece: WildernessPiece = { - name: "Act 1 - Wild Cliff Border 3", - border: true, - levels: [makeMockBorderDs1(8, 8, "north", false)], - } - const fencePiece: WildernessPiece = { - name: "Act 1 - Wild Border 3", - border: true, - levels: [makeMockBorderDs1(8, 8, "north", false)], - } - const classified = [ - { piece: cliffPiece, orientation: "north" as const, isCliff: true }, - { piece: fencePiece, orientation: "north" as const, isCliff: false }, - ] - const rng = new Rng(100) - - const pickedCliff = selectBorderPiece("north", classified, rng, true) - expect(pickedCliff?.name).toBe("Act 1 - Wild Cliff Border 3") - - const pickedFence = selectBorderPiece("north", classified, rng, false) - expect(pickedFence?.name).toBe("Act 1 - Wild Border 3") - }) - - test("falls back gracefully when only one cliff status is available", () => { - const fencePiece: WildernessPiece = { - name: "Act 4 - Mesa Border 3", - border: true, - levels: [makeMockBorderDs1(8, 8, "north", false)], - } - const classified = [{ piece: fencePiece, orientation: "north" as const, isCliff: false }] - const rng = new Rng(200) - - // Even if preferCliff is true, falls back to fencePiece - const picked = selectBorderPiece("north", classified, rng, true) - expect(picked?.name).toBe("Act 4 - Mesa Border 3") - }) - }) - - describe("Task 1C & 2: DRLGOUTPLACE_PlaceAct1245OutdoorBorders & layBorder outer ring coverage", () => { - function buildMultiActPieces(prefix: string, hasCliffs = false): WildernessPiece[] { - const pieces: WildernessPiece[] = [] - // Numbers 1..8 - for (let i = 1; i <= 8; i += 1) { - const orientation = i === 1 ? "south" : i === 2 ? "west" : i === 3 ? "north" : i === 4 ? "east" : - i === 5 ? "corner-sw" : i === 6 ? "corner-nw" : i === 7 ? "corner-ne" : "corner-se" - const closedDs1 = makeMockBorderDs1(8, 8, orientation, false) - const openDs1 = makeMockBorderDs1(8, 8, orientation, true) - pieces.push({ - name: `${prefix} Border ${i}`, - border: true, - // 4 variants: 0..2 closed, 3 open - levels: [closedDs1, closedDs1, closedDs1, openDs1], - }) - } - if (hasCliffs) { - // Add cliff pieces for north and west - pieces.push({ - name: `${prefix} Cliff Border 2`, - border: true, - levels: [makeMockBorderDs1(8, 8, "west", false)], - }) - pieces.push({ - name: `${prefix} Cliff Border 3`, - border: true, - levels: [makeMockBorderDs1(8, 8, "north", false)], - }) - } - return pieces - } - - test.each([ - { act: 1, name: "Act 1 - Wild", hasCliffs: true }, - { act: 2, name: "Act 2 - Desert", hasCliffs: false }, - { act: 4, name: "Act 4 - Mesa", hasCliffs: false }, - { act: 5, name: "Act 5 - Barricade", hasCliffs: true }, - ])("Act $act ($name): 100% outer ring coverage with 0 procedural fallback walls", ({ name, hasCliffs }) => { - const gridWidth = 8 - const gridHeight = 8 - const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0) - const pieces = buildMultiActPieces(name, hasCliffs) - const stats = createEmptyStats() - const rng = new Rng(42) - - const stamped = DRLGOUTPLACE_PlaceAct1245OutdoorBorders( - canvas, - pieces, - gridWidth, - gridHeight, - rng, - stats, - 2, // Blood Moor level ID with South and East openings - ) - - // Expected ring block count: 2 * (w + h) - 4 = 2 * (8 + 8) - 4 = 28 blocks - const expectedRingBlocks = 2 * (gridWidth + gridHeight) - 4 - expect(stamped).toBe(expectedRingBlocks) - expect(stats.borderStamped).toBe(expectedRingBlocks) - expect(stats.proceduralWallCells).toBe(0) - - // Verify every perimeter cell on the canvas has floor and valid wall placement - for (let x = 0; x < canvas.width; x += 1) { - // North edge (y=0) and South edge (y=canvas.height-1) - expect(canvas.cells[0]?.[x]?.floors.length).toBeGreaterThan(0) - expect(canvas.cells[canvas.height - 1]?.[x]?.floors.length).toBeGreaterThan(0) - } - for (let y = 0; y < canvas.height; y += 1) { - // West edge (x=0) and East edge (x=canvas.width-1) - expect(canvas.cells[y]?.[0]?.floors.length).toBeGreaterThan(0) - expect(canvas.cells[y]?.[canvas.width - 1]?.floors.length).toBeGreaterThan(0) - } - }) - - test("Opening blocks select Open DS1 variants while closed blocks select Closed DS1 variants", () => { - const gridWidth = 8 - const gridHeight = 8 - const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0) - const pieces = buildMultiActPieces("Act 1 - Wild", true) - const stats = createEmptyStats() - const rng = new Rng(12345) - - // Level 2 has default entrance on South edge (mid-X, y=7) and East edge (x=7, mid-Y) - DRLGOUTPLACE_PlaceAct1245OutdoorBorders(canvas, pieces, gridWidth, gridHeight, rng, stats, 2) - - const midX = Math.floor(gridWidth / 2) // block (4, 7) - const midY = Math.floor(gridHeight / 2) // block (7, 4) - - // South opening block: cell (4*8 + 4, 7*8 + y) should have the open gateway gap - const southOpenCenterCell = canvas.cells[7 * 8 + 4]?.[midX * 8 + 4] - expect(southOpenCenterCell).toBeDefined() - // Gateway center in open variant has no wall - expect(southOpenCenterCenterWallCount(canvas, midX * 8, 7 * 8, 8, 8)).toBe(false) - - // Closed South block: cell (1*8, 7*8) should have solid continuous wall - expect(isBlockClosed(canvas, 1 * 8, 7 * 8, 8, 8)).toBe(true) - }) - - function southOpenCenterCenterWallCount(canvas: any, bx: number, by: number, w: number, h: number): boolean { - const centerX = bx + Math.floor(w / 2) - // Check if all cells in column centerX of this block are walls - for (let y = 0; y < h; y += 1) { - if (!canvas.cells[by + y]?.[centerX]?.walls.some((w: any) => !w.hidden && w.prop1 !== 0)) { - return false // There is an open path through this column! - } - } - return true - } - - function isBlockClosed(canvas: any, bx: number, by: number, w: number, h: number): boolean { - for (let y = 0; y < h; y += 1) { - let hasWallRow = true - for (let x = 0; x < w; x += 1) { - if (!canvas.cells[by + y]?.[bx + x]?.walls.some((w: any) => !w.hidden && w.prop1 !== 0)) { - hasWallRow = false - break - } - } - if (hasWallRow) return true - } - return false - } - - test("Docking support: claimedBorderBlocks are skipped during border stamping", () => { - const gridWidth = 8 - const gridHeight = 8 - const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0) - const pieces = buildMultiActPieces("Act 1 - Wild", false) - const stats = createEmptyStats() - const rng = new Rng(777) - - // Claim 3 blocks along the West edge for a Town Transition / Facade docking - const claimed = new Set(["0,2", "0,3", "0,4"]) - - const stamped = DRLGOUTPLACE_PlaceAct1245OutdoorBorders( - canvas, - pieces, - gridWidth, - gridHeight, - rng, - stats, - 2, - undefined, - claimed, - ) - - // 28 total perimeter blocks - 3 claimed blocks = 25 stamped blocks - expect(stamped).toBe(25) - expect(stats.borderStamped).toBe(25) - - // Verified claimed blocks were skipped: canvas floor/walls at block (0, 3) were NOT painted by border - expect(canvas.cells[3 * 8]?.[0]?.walls[0]?.style).toBe(0) - expect(canvas.cells[1 * 8]?.[0]?.walls[0]?.style).toBe(1) - }) - - test("layBorder is an exported alias for DRLGOUTPLACE_PlaceAct1245OutdoorBorders", () => { - expect(layBorder).toBe(DRLGOUTPLACE_PlaceAct1245OutdoorBorders) - }) - - test("getCellOrientation correctly maps 4 corners and 4 edges", () => { - expect(getCellOrientation(0, 0, 10, 10)).toBe("corner-nw") - expect(getCellOrientation(9, 0, 10, 10)).toBe("corner-ne") - expect(getCellOrientation(9, 9, 10, 10)).toBe("corner-se") - expect(getCellOrientation(0, 9, 10, 10)).toBe("corner-sw") - expect(getCellOrientation(5, 0, 10, 10)).toBe("north") - expect(getCellOrientation(9, 5, 10, 10)).toBe("east") - expect(getCellOrientation(5, 9, 10, 10)).toBe("south") - expect(getCellOrientation(0, 5, 10, 10)).toBe("west") - }) - }) -}) diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts deleted file mode 100644 index d3f9e7e..0000000 --- a/tests/wilderness-features.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - isClosedBorder, - selectBorderVariant, - getPerimeterOpenings, - SPECIAL_PRESETS_BY_LEVEL, - generateWilderness, - resolveUnsetSize, - UnresolvedLevelSizeError, -} from "../src/game/wilderness.ts" -import type { WildernessPiece } from "../src/game/wilderness.ts" -import { Rng } from "../src/game/rng.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockDs1(width: number, height: number, hasGapInWalls: boolean): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - const isGap = hasGapInWalls && x === Math.floor(width / 2) - row.push({ - walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -describe("Wilderness Features & Border Rules", () => { - test("isClosedBorder identifies continuous vs open wall presets", () => { - const closed = makeMockDs1(8, 8, false) - const open = makeMockDs1(8, 8, true) - - expect(isClosedBorder(closed, "south")).toBe(true) - expect(isClosedBorder(closed, "north")).toBe(true) - expect(isClosedBorder(open, "south")).toBe(false) - expect(isClosedBorder(open, "north")).toBe(false) - }) - - test("getPerimeterOpenings enforces 0 exits for Cow Level and 1 entrance/1 exit for Blood Moor", () => { - const cowOpenings = getPerimeterOpenings(39, 10, 10) - expect(cowOpenings.size).toBe(0) - - const bloodMoorOpenings = getPerimeterOpenings(2, 10, 10) - expect(bloodMoorOpenings.size).toBe(2) - expect(bloodMoorOpenings.has("5,9")).toBe(true) - expect(bloodMoorOpenings.has("9,5")).toBe(true) - }) - - test("getPerimeterOpenings defaults to open borders for Act 3 Jungles and outdoor levels", () => { - // Act 3: Spider Forest (76), Great Marsh (77), Flayer Jungle (78) - const spiderForestOpenings = getPerimeterOpenings(76, 10, 10) - expect(spiderForestOpenings.size).toBe(2) - expect(spiderForestOpenings.has("5,9")).toBe(true) - expect(spiderForestOpenings.has("9,5")).toBe(true) - - // Act 5: Arreat Plateau (113) - const arreatOpenings = getPerimeterOpenings(113, 10, 10) - expect(arreatOpenings.size).toBe(2) - }) - - test("getPerimeterOpenings maps planned gates to perimeter block coordinates", () => { - const plannedGates = [ - { side: 'north' as const, toLevelId: 75, label: 'To Town' }, - { side: 'east' as const, toLevelId: 77, label: 'To Marsh' }, - ] - const openings = getPerimeterOpenings(76, 12, 14, plannedGates) - expect(openings.size).toBe(2) - expect(openings.has("6,0")).toBe(true) // north: (12/2, 0) - expect(openings.has("11,7")).toBe(true) // east: (12-1, 14/2) - }) - - test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => { - const closedDs1 = makeMockDs1(8, 8, false) - const openDs1 = makeMockDs1(8, 8, true) - const piece: WildernessPiece = { - name: "Act 1 - Wild Border 1", - border: true, - levels: [closedDs1, openDs1], - } - - const rng = new Rng(12345) - const pickedOpen = selectBorderVariant(piece, "south", true, rng) - expect(isClosedBorder(pickedOpen, "south")).toBe(false) - - const pickedClosed = selectBorderVariant(piece, "south", false, rng) - expect(isClosedBorder(pickedClosed, "south")).toBe(true) - }) - - test("SPECIAL_PRESETS_BY_LEVEL has no Act I outdoor entries (the DRLG port places those presets)", () => { - for (const levelId of [2, 3, 4, 5, 6, 7, 17, 39]) { - expect(SPECIAL_PRESETS_BY_LEVEL[levelId]).toBeUndefined() - } - expect(SPECIAL_PRESETS_BY_LEVEL[41]).toContain("Act 2 - Desert Tomb 1") - }) - - test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => { - const stripNames = [ - "Act 5 - Siege To Barricade", - "Act 5 - Siege Strip 13", - "Act 5 - Siege Strip 12", - "Act 5 - Siege Strip 11", - "Act 5 - Siege Strip 10", - "Act 5 - Siege Strip 9", - "Act 5 - Siege Strip 8", - "Act 5 - Siege Strip 7", - "Act 5 - Siege Strip 6", - "Act 5 - Siege Strip 5", - "Act 5 - Siege Strip 4", - "Act 5 - Siege Strip 3", - "Act 5 - Siege Strip 2", - "Act 5 - Siege Strip 1", - "Act 5 - Siege To Town", - ] - - const pieces: WildernessPiece[] = stripNames.map((name, idx) => { - const baseDs1 = makeMockDs1(17, 49, false) - // Create a copy with tagged floor sequence at (0,0) and one object - const cells = baseDs1.cells.map((row, y) => - row.map((cell, x) => - x === 0 && y === 0 - ? { - ...cell, - floors: [{ ...cell.floors[0], sequence: idx + 10 }], - } - : cell, - ), - ) - const ds1: Ds1 = { - ...baseDs1, - cells, - objects: [ - { - type: 2, - id: 100 + idx, - x: 5, - y: 5, - flags: 0, - }, - ], - } - return { - name, - border: false, - levels: [ds1], - } - }) - - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 }) - expect(result.stats.substitutedClusters).toBe(15) - expect(result.level.width).toBe(240) - expect(result.level.height).toBe(48) - expect(result.level.objects.filter(o => o.type === 2).length).toBe(15) - expect(result.level.objects.length).toBe(17) - - // Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224 - expect(result.level.cells[0][0].floors[0].sequence).toBe(10) - expect(result.level.cells[0][224].floors[0].sequence).toBe(24) - }) - - - test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => { - const names = [ - "Act 3 - Travincal NW", - "Act 3 - Travincal N ", // trailing space as in LvlPrest.txt - "Act 3 - Travincal NE", - "Act 3 - Travincal SW", - "Act 3 - Travincal S ", // trailing space as in LvlPrest.txt - "Act 3 - Travincal SE", - ] - const pieces: WildernessPiece[] = names.map((name, idx) => { - const baseDs1 = makeMockDs1(name.includes(" N ") || name.includes(" S ") ? 33 : 17, 33, false) - const ds1: Ds1 = { - ...baseDs1, - objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }], - } - return { name, border: false, levels: [ds1] } - }) - - const result = generateWilderness({ - levelId: 83, - levelName: "Travincal", - levelTypeName: "Act 3 - Kurast", - sizeX: 64, - sizeY: 64, - subType: -1, - subTheme: -1, - seed: 83, - pieces, - substitutions: [], - }) - - expect(result.level.width).toBe(64) - expect(result.level.height).toBe(64) - expect(result.stats.substitutedClusters).toBe(6) - expect(result.level.objects.length).toBe(6) - }) - - test("resolveUnsetSize dynamically derives vertical corridor dimensions for Level 111 and 112", () => { - const entranceDs1 = makeMockDs1(17, 33, false) - const exitDs1 = makeMockDs1(17, 33, false) - const wallDs1 = makeMockDs1(17, 17, false) - const cliffDs1 = makeMockDs1(17, 17, false) - - const pieces: WildernessPiece[] = [ - { name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] }, - { name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] }, - { name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] }, - { name: "Act 5 - Barricade 2", border: false, levels: [wallDs1] }, - { name: "Act 5 - Barricade Cliff Border 1", border: true, levels: [cliffDs1] }, - ] - - for (const levelId of [111, 112]) { - const resolved = resolveUnsetSize({ - levelId, - levelName: `Act 5 - Barricade ${levelId - 110}`, - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces, - substitutions: [], - }) - - expect(resolved.sizeX).toBeGreaterThanOrEqual(48) - expect(resolved.sizeX).toBeLessThanOrEqual(64) - expect(resolved.sizeY).toBeGreaterThanOrEqual(160) - expect(resolved.sizeY).toBeLessThanOrEqual(192) - expect(resolved.sizeY).toBeGreaterThan(resolved.sizeX) - expect(resolved.sizeY / resolved.sizeX).toBeGreaterThanOrEqual(2.5) - expect(resolved.source).toBe("LvlPrest Barricade corridor dynamic extent") - } - }) - - test("resolveUnsetSize throws UnresolvedLevelSizeError when pieces are absent (anti-silent failure)", () => { - expect(() => { - resolveUnsetSize({ - levelId: 111, - levelName: "Act 5 - Barricade 1", - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - }).toThrow(UnresolvedLevelSizeError) - }) - - test("resolveUnsetSize throws UnresolvedLevelSizeError when non-barricade level lacks barricade pieces", () => { - const mockDs1 = makeMockDs1(8, 8, false) - expect(() => { - resolveUnsetSize({ - levelId: 2, - levelName: "Blood Moor", - levelTypeName: "Act 1 - Wilderness", - sizeX: -1, - sizeY: -1, - subType: 6, - subTheme: 0, - seed: 12345, - pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [mockDs1] }], - substitutions: [], - }) - }).toThrow(UnresolvedLevelSizeError) - }) - - test("resolveUnsetSize respects sizeOverride when provided", () => { - const resolved = resolveUnsetSize({ - levelId: 111, - levelName: "Act 5 - Barricade 1", - levelTypeName: "Act 5 - Barricade", - sizeX: -1, - sizeY: -1, - sizeOverride: { x: 80, y: 120 }, - subType: 10, - subTheme: 0, - seed: 12345, - pieces: [], - substitutions: [], - }) - expect(resolved.sizeX).toBe(80) - expect(resolved.sizeY).toBe(120) - expect(resolved.source).toBe("sizeOverride") - }) -}) diff --git a/tests/wilderness-ground-blend.test.ts b/tests/wilderness-ground-blend.test.ts deleted file mode 100644 index fbc70b4..0000000 --- a/tests/wilderness-ground-blend.test.ts +++ /dev/null @@ -1,705 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - fillGround, - sampleGroundTile, - deriveGroundTilesFromDt1Libraries, - deriveGroundTiles, - deriveGroundTile, - cellHash, - smoothNoise2D, - createCanvas, - generateWilderness, - CANONICAL_GROUND_TILES, - CANONICAL_GROUND_POOLS, - DRLGROOMTILE_GetTileCache, -} from "../src/game/wilderness.ts" -import type { - WeightedGroundTile, - WildernessPiece, - TileCacheQuery, - TileCacheResult, -} from "../src/game/wilderness.ts" -import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockDs1( - width: number, - height: number, - floorStyle = 0, - floorSequence = 0, - customFloor?: (x: number, y: number) => { readonly style: number; readonly sequence: number } | undefined, -): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - const custom = customFloor?.(x, y) - const style = custom ? custom.style : floorStyle - const sequence = custom ? custom.sequence : floorSequence - row.push({ - walls: [], - floors: [{ prop1: 2, sequence, style, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeMockDt1(tiles: (Partial & { rarity?: number; Rarity?: number })[]): Dt1 { - return { - warnings: [], - versionMajor: 7, - versionMinor: 6, - tiles: tiles.map(t => ({ - direction: 0, - height: -80, - width: 160, - type: t.type ?? 0, - style: t.style ?? 0, - sequence: t.sequence ?? 0, - materialFlags: 0, - roofHeight: 0, - animated: t.animated ?? 0, - rarityFrameIndex: t.rarityFrameIndex ?? 1, - subTileFlags: [], - minBlockY: 0, - bitmapHeight: 80, - blocks: [], - ...t, - })), - } -} - -function makeTestPieces(): WildernessPiece[] { - return [ - { name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - { name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, 0, 0)] }, - ] -} - -describe("Wilderness Ground Tile Blending (Issue #52)", () => { - describe("PRNG & Noise Primitives", () => { - test("cellHash produces deterministic values in [0, 1)", () => { - const v1 = cellHash(10, 20, 12345) - const v2 = cellHash(10, 20, 12345) - const v3 = cellHash(10, 20, 99999) - const v4 = cellHash(11, 20, 12345) - - expect(v1).toBe(v2) - expect(v1).toBeGreaterThanOrEqual(0) - expect(v1).toBeLessThan(1) - expect(v1).not.toBe(v3) - expect(v1).not.toBe(v4) - }) - - test("smoothNoise2D produces values in [0, 1] with spatial smoothness", () => { - const n0 = smoothNoise2D(10, 10, 8, 42) - const n1 = smoothNoise2D(10.1, 10.1, 8, 42) - const nFar = smoothNoise2D(50, 50, 8, 42) - - expect(n0).toBeGreaterThanOrEqual(0) - expect(n0).toBeLessThanOrEqual(1) - // Small step produces small difference - expect(Math.abs(n1 - n0)).toBeLessThan(0.1) - expect(nFar).toBeGreaterThanOrEqual(0) - expect(nFar).toBeLessThanOrEqual(1) - }) - }) - - describe("Single-Tile Fallback (Backward Compatibility)", () => { - test("fillGround with a single tile object fills entire area uniformly", () => { - const canvas = createCanvas(20, 20, 1, 1, 0) - const written = fillGround(canvas, { style: 3, sequence: 7 }, 0, 0, 20, 20) - - expect(written).toBe(400) - for (let y = 0; y < 20; y += 1) { - for (let x = 0; x < 20; x += 1) { - const floor = canvas.cells[y]![x]!.floors[0]! - expect(floor.style).toBe(3) - expect(floor.sequence).toBe(7) - expect(floor.hidden).toBe(false) - } - } - }) - - test("fillGround with a 1-element array fills entire area uniformly", () => { - const canvas = createCanvas(15, 15, 1, 1, 0) - const written = fillGround(canvas, [{ style: 1, sequence: 4, weight: 100 }], 0, 0, 15, 15) - - expect(written).toBe(225) - for (let y = 0; y < 15; y += 1) { - for (let x = 0; x < 15; x += 1) { - const floor = canvas.cells[y]![x]!.floors[0]! - expect(floor.style).toBe(1) - expect(floor.sequence).toBe(4) - } - } - }) - - test("sampleGroundTile with a single tile returns it directly", () => { - const single = { style: 2, sequence: 5 } - expect(sampleGroundTile(single, 10, 10, 123)).toEqual(single) - - const singleArr = [{ style: 4, sequence: 9, weight: 1 }] - expect(sampleGroundTile(singleArr, 10, 10, 123)).toEqual(singleArr[0]) - }) - }) - - describe("Multi-Tile Ground Blending & Weight Adherence", () => { - const candidates: readonly WeightedGroundTile[] = [ - { style: 0, sequence: 0, weight: 70 }, - { style: 0, sequence: 1, weight: 20 }, - { style: 0, sequence: 2, weight: 10 }, - ] - - test("fillGround in spatial mode generates all candidate tiles and respects weight ratios", () => { - const width = 60 - const height = 60 - const totalCells = width * height - const canvas = createCanvas(width, height, 1, 1, 0) - - fillGround(canvas, candidates, 0, 0, width, height, { - seed: 424242, - mode: "spatial", - spatialScale: 6, - spatialCoherence: 0.6, - }) - - const counts = new Map() - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const seq = canvas.cells[y]![x]!.floors[0]!.sequence - counts.set(seq, (counts.get(seq) ?? 0) + 1) - } - } - - // All candidate sequences must be present - expect(counts.has(0)).toBe(true) - expect(counts.has(1)).toBe(true) - expect(counts.has(2)).toBe(true) - - const c0 = counts.get(0)! / totalCells - const c1 = counts.get(1)! / totalCells - const c2 = counts.get(2)! / totalCells - - // Expected: ~70% sequence 0, ~20% sequence 1, ~10% sequence 2 - // Spatial noise percentile ranking guarantees global proportions very close to exact weights - expect(c0).toBeGreaterThanOrEqual(0.60) - expect(c0).toBeLessThanOrEqual(0.80) - expect(c1).toBeGreaterThanOrEqual(0.12) - expect(c1).toBeLessThanOrEqual(0.28) - expect(c2).toBeGreaterThanOrEqual(0.04) - expect(c2).toBeLessThanOrEqual(0.18) - }) - - test("fillGround in CDF mode generates all candidate tiles and respects weight ratios", () => { - const width = 60 - const height = 60 - const totalCells = width * height - const canvas = createCanvas(width, height, 1, 1, 0) - - fillGround(canvas, candidates, 0, 0, width, height, { - seed: 88888, - mode: "cdf", - }) - - const counts = new Map() - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const seq = canvas.cells[y]![x]!.floors[0]!.sequence - counts.set(seq, (counts.get(seq) ?? 0) + 1) - } - } - - expect(counts.has(0)).toBe(true) - expect(counts.has(1)).toBe(true) - expect(counts.has(2)).toBe(true) - - const c0 = counts.get(0)! / totalCells - const c1 = counts.get(1)! / totalCells - const c2 = counts.get(2)! / totalCells - - // Law of large numbers for 3600 samples: within +-8% of true probabilities - expect(c0).toBeGreaterThanOrEqual(0.62) - expect(c0).toBeLessThanOrEqual(0.78) - expect(c1).toBeGreaterThanOrEqual(0.14) - expect(c1).toBeLessThanOrEqual(0.26) - expect(c2).toBeGreaterThanOrEqual(0.05) - expect(c2).toBeLessThanOrEqual(0.16) - }) - - test("spatial mode creates contiguous natural patches (higher neighborhood correlation than random)", () => { - const width = 40 - const height = 40 - const canvasSpatial = createCanvas(width, height, 1, 1, 0) - const canvasCdf = createCanvas(width, height, 1, 1, 0) - - fillGround(canvasSpatial, candidates, 0, 0, width, height, { seed: 100, mode: "spatial", spatialScale: 6 }) - fillGround(canvasCdf, candidates, 0, 0, width, height, { seed: 100, mode: "cdf" }) - - // Count horizontal neighbor matches: how often cell(x, y) has the same sequence as cell(x+1, y) - let spatialMatches = 0 - let cdfMatches = 0 - let totalPairs = 0 - - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width - 1; x += 1) { - totalPairs += 1 - if (canvasSpatial.cells[y]![x]!.floors[0]!.sequence === canvasSpatial.cells[y]![x + 1]!.floors[0]!.sequence) { - spatialMatches += 1 - } - if (canvasCdf.cells[y]![x]!.floors[0]!.sequence === canvasCdf.cells[y]![x + 1]!.floors[0]!.sequence) { - cdfMatches += 1 - } - } - } - - // Spatial coherent noise should have significantly higher neighbor continuity than independent CDF noise - const spatialCorr = spatialMatches / totalPairs - const cdfCorr = cdfMatches / totalPairs - - expect(spatialCorr).toBeGreaterThan(cdfCorr) - expect(spatialCorr).toBeGreaterThan(0.60) - }) - }) - - describe("Deterministic PRNG Consistency", () => { - test("same seed produces byte-for-byte identical tile layouts", () => { - const candidates: readonly WeightedGroundTile[] = [ - { style: 0, sequence: 0, weight: 60 }, - { style: 0, sequence: 1, weight: 30 }, - { style: 0, sequence: 2, weight: 10 }, - ] - - const canvasA = createCanvas(25, 25, 1, 1, 0) - const canvasB = createCanvas(25, 25, 1, 1, 0) - - fillGround(canvasA, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef }) - fillGround(canvasB, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef }) - - for (let y = 0; y < 25; y += 1) { - for (let x = 0; x < 25; x += 1) { - const floorA = canvasA.cells[y]![x]!.floors[0]! - const floorB = canvasB.cells[y]![x]!.floors[0]! - expect(floorA.style).toBe(floorB.style) - expect(floorA.sequence).toBe(floorB.sequence) - } - } - }) - - test("different seeds produce different ground layouts", () => { - const candidates: readonly WeightedGroundTile[] = [ - { style: 0, sequence: 0, weight: 50 }, - { style: 0, sequence: 1, weight: 50 }, - ] - - const canvasA = createCanvas(20, 20, 1, 1, 0) - const canvasB = createCanvas(20, 20, 1, 1, 0) - - fillGround(canvasA, candidates, 0, 0, 20, 20, { seed: 111 }) - fillGround(canvasB, candidates, 0, 0, 20, 20, { seed: 999 }) - - let differences = 0 - for (let y = 0; y < 20; y += 1) { - for (let x = 0; x < 20; x += 1) { - if (canvasA.cells[y]![x]!.floors[0]!.sequence !== canvasB.cells[y]![x]!.floors[0]!.sequence) { - differences += 1 - } - } - } - - expect(differences).toBeGreaterThan(50) - }) - }) - - describe("DT1 Library Derivation (deriveGroundTilesFromDt1Libraries)", () => { - test("filters libraries strictly according to dwDt1Mask", () => { - const lib0 = makeMockDt1([ - { type: 0, style: 0, sequence: 0, rarityFrameIndex: 50 }, - ]) - const lib1 = makeMockDt1([ - { type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 }, - ]) - const lib2 = makeMockDt1([ - { type: 0, style: 0, sequence: 2, rarityFrameIndex: 10 }, - ]) - - const libraries = [lib0, lib1, lib2] - - // Mask 0b001 = 1 (only lib 0) - const onlyLib0 = deriveGroundTilesFromDt1Libraries(libraries, 1) - expect(onlyLib0).toHaveLength(1) - expect(onlyLib0[0]).toMatchObject({ style: 0, sequence: 0, weight: 50 }) - - // Mask 0b101 = 5 (lib 0 and lib 2, skipping lib 1) - const lib0and2 = deriveGroundTilesFromDt1Libraries(libraries, 5) - expect(lib0and2).toHaveLength(2) - expect(lib0and2.map(t => t.sequence)).toEqual([0, 2]) - - // Mask 0b000 = 0 (no libraries active) - const empty = deriveGroundTilesFromDt1Libraries(libraries, 0) - expect(empty).toHaveLength(0) - }) - - test("filters out non-floor tiles (type !== 0) and weights by rarityFrameIndex", () => { - const dt1 = makeMockDt1([ - // Floor tiles (type === 0) - { type: 0, style: 1, sequence: 0, rarityFrameIndex: 60 }, - { type: 0, style: 1, sequence: 1, rarityFrameIndex: 40 }, - // Same style/seq variant in library: weights should accumulate - { type: 0, style: 1, sequence: 1, rarityFrameIndex: 10 }, - // Wall tile (type === 1) - must be excluded - { type: 1, style: 1, sequence: 0, rarityFrameIndex: 99 }, - // Roof tile (type === 2) - must be excluded - { type: 2, style: 1, sequence: 0, rarityFrameIndex: 99 }, - ]) - - const result = deriveGroundTilesFromDt1Libraries([dt1], 1) - expect(result).toHaveLength(2) - - // Expected ordering: highest weight first - // seq 0 has 60, seq 1 has 40 + 10 = 50 - expect(result[0]).toMatchObject({ style: 1, sequence: 0, weight: 60 }) - expect(result[1]).toMatchObject({ style: 1, sequence: 1, weight: 50 }) - }) - - test("handles empty libraries or libraries with no floor tiles gracefully", () => { - expect(deriveGroundTilesFromDt1Libraries([], 0xff)).toEqual([]) - - const noFloors = makeMockDt1([{ type: 1, style: 0, sequence: 0 }]) - expect(deriveGroundTilesFromDt1Libraries([noFloors], 1)).toEqual([]) - }) - }) - - describe("Piece Floor Derivation (deriveGroundTiles & deriveGroundTile)", () => { - test("derives candidate ground tiles with frequencies from interior floor cells", () => { - // Create a piece with interior floor tiles: - // 8x8 piece has interior from x=1..6, y=1..6 (36 interior cells) - // Set cells x=2..4, y=2..4 to sequence 1 (9 cells) - const level = makeMockDs1(8, 8, 0, 0, (x, y) => { - if (x >= 2 && x <= 4 && y >= 2 && y <= 4) { - return { style: 0, sequence: 1 } - } - return undefined - }) - - const pieces: WildernessPiece[] = [{ name: "Test Piece", border: true, levels: [level] }] - const tiles = deriveGroundTiles(pieces) - - expect(tiles.length).toBe(2) - // Sequence 0 is the majority, sequence 1 has 9 cells - expect(tiles[0]!.sequence).toBe(0) - expect(tiles[1]!.sequence).toBe(1) - expect(tiles[1]!.weight).toBe(9) - - // deriveGroundTile should return the most frequent interior tile - const primary = deriveGroundTile(pieces) - expect(primary).toEqual({ style: 0, sequence: 0 }) - }) - }) - - describe("generateWilderness Integration", () => { - test("generates wilderness with explicit groundTiles blend", () => { - const pieces = makeTestPieces() - const candidateTiles: readonly WeightedGroundTile[] = [ - { style: 0, sequence: 0, weight: 70 }, - { style: 0, sequence: 1, weight: 30 }, - ] - - const result = generateWilderness({ - levelId: 41, - levelName: "Rocky Waste Blend Test", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 123456, - pieces, - substitutions: [], - groundTiles: candidateTiles, - }) - - expect(result.stats.groundTiles).toBeDefined() - const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(statsTiles).toHaveLength(2) - - // Verify level cells contain both sequences - let seq0Count = 0 - let seq1Count = 0 - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const floor = result.level.cells[y]![x]!.floors[0] - if (floor && !floor.hidden) { - if (floor.sequence === 0) seq0Count += 1 - if (floor.sequence === 1) seq1Count += 1 - } - } - } - - expect(seq0Count).toBeGreaterThan(0) - expect(seq1Count).toBeGreaterThan(0) - expect(seq0Count).toBeGreaterThan(seq1Count) - }) - - test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => { - const pieces = makeTestPieces() - const result = generateWilderness({ - levelId: 41, - levelName: "Rocky Waste Single Tile", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 654321, - pieces, - substitutions: [], - groundTile: { style: 0, sequence: 0 }, - }) - - expect(result.stats.groundTile).toEqual({ style: 0, sequence: 0 }) - }) - - test("generates wilderness with dt1Libraries deriving ground tiles automatically", () => { - const pieces = makeTestPieces() - const dt1 = makeMockDt1([ - { type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 }, - { type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 }, - ]) - - const result = generateWilderness({ - levelId: 41, - levelName: "Rocky Waste DT1 Test", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 777777, - pieces, - substitutions: [], - dt1Libraries: [dt1], - }) - - expect(result.stats.groundTiles).toBeDefined() - const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(statsTiles).toHaveLength(2) - expect(statsTiles[0]!.sequence).toBe(0) - expect(statsTiles[1]!.sequence).toBe(1) - }) - }) - - describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => { - test("filters candidate tiles strictly by dwDt1Mask", () => { - const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 10, rarityFrameIndex: 50 }]) - const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 20, rarityFrameIndex: 50 }]) - const libraries = [dt1A, dt1B] - - // dwDt1Mask = 0x01 (slot 0 only) - const res0 = DRLGROOMTILE_GetTileCache(libraries, 0x01, { type: 0, seed: 0 }) - expect(res0).not.toBeNull() - expect(res0?.sequence).toBe(10) - expect(res0?.libraryIndex).toBe(0) - - // dwDt1Mask = 0x02 (slot 1 only) - const res1 = DRLGROOMTILE_GetTileCache(libraries, 0x02, { type: 0, seed: 0 }) - expect(res1).not.toBeNull() - expect(res1?.sequence).toBe(20) - expect(res1?.libraryIndex).toBe(1) - - // dwDt1Mask = 0x00 (no slots active) - const resNone = DRLGROOMTILE_GetTileCache(libraries, 0x00, { type: 0, seed: 0 }) - expect(resNone).toBeNull() - }) - - test("filters candidate tiles by query type, style, and sequence", () => { - const dt1 = makeMockDt1([ - { type: 0, style: 2, sequence: 24, rarityFrameIndex: 10 }, - { type: 0, style: 2, sequence: 0, rarityFrameIndex: 10 }, - { type: 1, style: 2, sequence: 24, rarityFrameIndex: 10 }, // wall - ]) - - // Query specific sequence - const resSeq = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 0, style: 2, sequence: 24, seed: 0 }) - expect(resSeq?.type).toBe(0) - expect(resSeq?.style).toBe(2) - expect(resSeq?.sequence).toBe(24) - - // Query wall type - const resWall = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 1, seed: 0 }) - expect(resWall?.type).toBe(1) - - // Non-existent style - const resMissing = DRLGROOMTILE_GetTileCache([dt1], 0x01, { style: 99, seed: 0 }) - expect(resMissing).toBeNull() - }) - - test("supports tile rarityFrameIndex, rarity, and Rarity field names", () => { - const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 45 }]) - const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 2, rarity: 75 }]) - const dt1C = makeMockDt1([{ type: 0, style: 0, sequence: 3, Rarity: 95 }]) - - const resA = DRLGROOMTILE_GetTileCache([dt1A], 1, { type: 0, seed: 0 }) - expect(resA?.weight).toBe(45) - - const resB = DRLGROOMTILE_GetTileCache([dt1B], 1, { type: 0, seed: 0 }) - expect(resB?.weight).toBe(75) - - const resC = DRLGROOMTILE_GetTileCache([dt1C], 1, { type: 0, seed: 0 }) - expect(resC?.weight).toBe(95) - }) - - test("distributes tile selection proportionally to rarity weights", () => { - const dt1 = makeMockDt1([ - { type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 }, - { type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 }, - ]) - - let count0 = 0 - let count1 = 0 - const totalSamples = 5000 - - for (let i = 0; i < totalSamples; i += 1) { - // Roll deterministically with coordinates (i, 0) and seed - const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x: i, y: 0 }) - if (res?.sequence === 0) count0 += 1 - if (res?.sequence === 1) count1 += 1 - } - - const ratio0 = count0 / totalSamples - const ratio1 = count1 / totalSamples - - // Expected ~0.80 and ~0.20 within +/- 0.04 - expect(ratio0).toBeGreaterThan(0.75) - expect(ratio0).toBeLessThan(0.85) - expect(ratio1).toBeGreaterThan(0.15) - expect(ratio1).toBeLessThan(0.25) - }) - - test("deriveGroundTilesFromDt1Libraries respects dwDt1Mask, rarities and sorts descending", () => { - const dt1 = makeMockDt1([ - { type: 0, style: 0, sequence: 0, rarity: 15 }, - { type: 0, style: 0, sequence: 1, Rarity: 85 }, - ]) - - const result = deriveGroundTilesFromDt1Libraries([dt1], 1) - expect(result).toHaveLength(2) - // Sequence 1 has higher rarity (85), so it must be sorted first - expect(result[0]!.sequence).toBe(1) - expect(result[0]!.weight).toBe(85) - expect(result[1]!.sequence).toBe(0) - expect(result[1]!.weight).toBe(15) - }) - }) - - describe('Canonical Ground Pools (Phase D3)', () => { - test('contains authentic pools for all wilderness level types', () => { - const expectedKeys = [ - 'Act 2 - Desert', - 'Act 3 - Jungle', - 'Act 3 - Kurast', - 'Act 4 - Mesa', - 'Act 4 - Lava', - 'Act 5 - Siege', - 'Act 5 - Barricade', - 'Act 5 - Barricade Snow', - ] - for (const key of expectedKeys) { - const pool = CANONICAL_GROUND_POOLS[key] - expect(pool).toBeDefined() - expect(pool!.length).toBeGreaterThanOrEqual(1) - for (const tile of pool!) { - expect(tile.weight).toBeGreaterThan(0) - expect(tile.style).toBeGreaterThanOrEqual(0) - expect(tile.sequence).toBeGreaterThanOrEqual(0) - } - } - // Act I outdoor ground comes from the DRLG port, not from a canonical pool. - expect(CANONICAL_GROUND_POOLS['Act 1 - Wilderness']).toBeUndefined() - }) - - test('Act 3 Kurast canonical ground pool uses authentic style 1 from DarkGrass.dt1', () => { - expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).toBe(1) - const kurastPool = CANONICAL_GROUND_POOLS['Act 3 - Kurast']! - for (const tile of kurastPool) { - expect(tile.style).toBe(1) - } - }) - - test('Act 5 Barricade distinguishes dirt (style 0) from snow (style 6)', () => { - expect(CANONICAL_GROUND_TILES['Act 5 - Barricade'].style).toBe(0) - expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).toBe(6) - for (const tile of CANONICAL_GROUND_POOLS['Act 5 - Barricade']!) { - expect(tile.style).toBe(0) - } - for (const tile of CANONICAL_GROUND_POOLS['Act 5 - Barricade Snow']!) { - expect(tile.style).toBe(6) - } - }) - - test('generateWilderness resolves snow barricade ground tiles when isSnowBarricade is true', () => { - const pieces = makeTestPieces() - const result = generateWilderness({ - levelId: 117, - levelName: 'Frozen Tundra', - levelTypeName: 'Act 5 - Barricade', - sizeX: 64, - sizeY: 64, - subType: 11, - subTheme: 0, - seed: 42, - pieces, - substitutions: [], - }) - expect(result.stats.groundTile?.style).toBe(6) - const tiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(tiles).toBeDefined() - expect(tiles.every(t => t.style === 6)).toBe(true) - }) - - test('generateWilderness resolves Kurast ground tiles with style 1', () => { - const pieces = makeTestPieces() - const result = generateWilderness({ - levelId: 79, - levelName: 'Kurast Bazaar', - levelTypeName: 'Act 3 - Kurast', - sizeX: 64, - sizeY: 64, - subType: 0, - subTheme: 0, - seed: 42, - pieces, - substitutions: [], - }) - expect(result.stats.groundTile?.style).toBe(1) - const tiles = result.stats.groundTiles as readonly WeightedGroundTile[] - expect(tiles).toBeDefined() - expect(tiles.every(t => t.style === 1)).toBe(true) - }) - }) -}) diff --git a/tests/wilderness-jungles.test.ts b/tests/wilderness-jungles.test.ts deleted file mode 100644 index 2b2ea74..0000000 --- a/tests/wilderness-jungles.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { describe, expect, test } from 'vitest' -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' -import { - generateWilderness, - UNIMPLEMENTED_PASSES, - buildJungleTopology, - DRLG_GenerateJungles, - type WildernessPiece, - type WildernessRequest, -} from '../src/game/wilderness.ts' -import { Rng } from '../src/game/rng.ts' - -/** - * Creates a mock DS1 piece of arbitrary size (e.g. 32x32 for jungle macro-cells, 64x32 for head/tail). - */ -function makeMockDs1(width: number, height: number, isWater = false): 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: isWater ? 194 : 2, - sequence: 0, - style: isWater ? 1 : 0, - 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, - } -} - -/** - * Creates a complete mock suite of Act 3 Jungle pieces. - */ -function makeMockJunglePieces(): WildernessPiece[] { - const pieces: WildernessPiece[] = [] - - // 1. Head (TransL.ds1, 64x32) and Tail (TravL.ds1, 64x32) - pieces.push({ - name: 'Act 3 - Jungle Head', - border: false, - levels: [makeMockDs1(64, 32)], - }) - pieces.push({ - name: 'Act 3 - Jungle Tail', - border: false, - levels: [makeMockDs1(64, 32)], - }) - - // 2. Wooden Bridge (Act 3 - Bridge, 32x32) - pieces.push({ - name: 'Act 3 - Bridge', - border: false, - levels: [makeMockDs1(32, 32)], - }) - - // 3. Directional River Presets (32x32) - const riverTokens = [ - 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', - 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW', - 'NS W', 'NS E', 'EW N', 'EW S', - ] - for (const token of riverTokens) { - pieces.push({ - name: `Act 3 - Jungle ${token}`, - border: false, - levels: [makeMockDs1(32, 32, true)], - }) - } - - // 4. Clearings for each of the 3 jungle areas (32x32) - const clearingThemes = ['Webby', 'Boggy', 'Pygmy'] - const openings = ['', ' E', ' W', ' EW', ' NE', ' NW', ' SE', ' SW'] - - for (const theme of clearingThemes) { - for (const opening of openings) { - pieces.push({ - name: `Act 3 - Clearing ${theme}${opening}`, - border: false, - levels: [makeMockDs1(32, 32)], - }) - } - } - - return pieces -} - -describe('Act 3 Jungle Branching Topology (buildJungleTopology)', () => { - test('guarantees Flayer Jungle (78) is always reachable from Spider Forest (76)', () => { - // Test across 100 random seeds - for (let seed = 1; seed <= 100; seed += 1) { - const topology = buildJungleTopology(seed) - - expect(topology.zones).toHaveLength(3) - const spider = topology.zones.find(z => z.levelId === 76)! - const marsh = topology.zones.find(z => z.levelId === 77)! - const flayer = topology.zones.find(z => z.levelId === 78)! - - expect(spider.name).toBe('Spider Forest') - expect(marsh.name).toBe('Great Marsh') - expect(flayer.name).toBe('Flayer Jungle') - - // Check graph reachability: BFS from 76 to 78 - const visited = new Set([76]) - const queue = [76] - while (queue.length > 0) { - const curr = queue.shift()! - const outgoing = topology.connections.filter(c => c.fromLevelId === curr) - for (const conn of outgoing) { - if (!visited.has(conn.toLevelId)) { - visited.add(conn.toLevelId) - queue.push(conn.toLevelId) - } - } - } - - expect(visited.has(78)).toBe(true) - } - }) - - test('exhibits both Great Marsh skip active and inactive across different seeds', () => { - let skipsSeen = 0 - let noSkipsSeen = 0 - let marshDeadEndsSeen = 0 - let marshThroughwaysSeen = 0 - - for (let seed = 1; seed <= 100; seed += 1) { - const topology = buildJungleTopology(seed) - if (topology.hasMarshSkip) { - skipsSeen += 1 - const directSkipConn = topology.connections.find( - c => c.fromLevelId === 76 && c.toLevelId === 78, - ) - expect(directSkipConn).toBeDefined() - expect(directSkipConn!.connectionType).toBe('skip') - } else { - noSkipsSeen += 1 - // Without skip, Great Marsh must connect forward to Flayer Jungle - const marshToFlayer = topology.connections.find( - c => c.fromLevelId === 77 && c.toLevelId === 78, - ) - expect(marshToFlayer).toBeDefined() - expect(topology.marshIsDeadEnd).toBe(false) - } - - if (topology.marshIsDeadEnd) { - marshDeadEndsSeen += 1 - expect(topology.hasMarshSkip).toBe(true) - } else { - marshThroughwaysSeen += 1 - } - } - - expect(skipsSeen).toBeGreaterThan(0) - expect(noSkipsSeen).toBeGreaterThan(0) - expect(marshDeadEndsSeen).toBeGreaterThan(0) - expect(marshThroughwaysSeen).toBeGreaterThan(0) - }) -}) - -describe('DRLG_GenerateJungles River Network Generation', () => { - test('generates meandering river paths that wander across columns', () => { - const pieces = makeMockJunglePieces() - const request: WildernessRequest = { - levelId: 76, - levelName: 'Spider Forest', - levelTypeName: 'Act 3 - Jungle', - seed: 42, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - } - - const result = generateWilderness(request) - const layout = result.stats.jungleLayout as any - expect(layout).toBeDefined() - expect(layout.riverNetwork).toBeDefined() - - const { meanderingPath, meanderCount } = layout.riverNetwork - expect(meanderingPath.length).toBeGreaterThanOrEqual(6) - expect(meanderCount).toBeGreaterThanOrEqual(1) - - // Verify that the river visits multiple columns - const columnsVisited = new Set(meanderingPath.map((p: any) => p.col)) - expect(columnsVisited.size).toBeGreaterThanOrEqual(2) - }) - - test('places river forks / tributary junctions with branching presets', () => { - const pieces = makeMockJunglePieces() - const request: WildernessRequest = { - levelId: 77, - levelName: 'Great Marsh', - levelTypeName: 'Act 3 - Jungle', - seed: 12345, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - } - - const result = generateWilderness(request) - const layout = result.stats.jungleLayout as any - expect(layout.riverNetwork.forks.length).toBeGreaterThan(0) - - const fork = layout.riverNetwork.forks[0]! - expect(fork.row).toBeGreaterThanOrEqual(1) - expect(fork.row).toBeLessThanOrEqual(4) - expect(result.stats.riverForks).toBe(layout.riverNetwork.forks.length) - }) - - test('identifies and creates river island clearings flanked by river channels', () => { - const pieces = makeMockJunglePieces() - const request: WildernessRequest = { - levelId: 76, - levelName: 'Spider Forest', - levelTypeName: 'Act 3 - Jungle', - seed: 999, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - } - - const result = generateWilderness(request) - const layout = result.stats.jungleLayout as any - expect(layout.riverNetwork.islands.length).toBeGreaterThan(0) - - const island = layout.riverNetwork.islands[0]! - expect(island.col).toBeGreaterThanOrEqual(0) - expect(island.row).toBeGreaterThanOrEqual(1) - expect(result.stats.riverIslands).toBe(layout.riverNetwork.islands.length) - - // Check that clearings array flags the island - const islandClearing = layout.riverNetwork.clearings.find( - (c: any) => c.col === island.col && c.row === island.row, - ) - expect(islandClearing).toBeDefined() - expect(islandClearing.isIsland).toBe(true) - }) - - test('places wooden bridges crossing river channels to connect islands and trails', () => { - const pieces = makeMockJunglePieces() - const request: WildernessRequest = { - levelId: 78, - levelName: 'Flayer Jungle', - levelTypeName: 'Act 3 - Jungle', - seed: 777, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - } - - const result = generateWilderness(request) - const layout = result.stats.jungleLayout as any - expect(layout.riverNetwork.bridges.length).toBeGreaterThan(0) - - // Bridge recorded in stats.substitutions and special presets - const bridgeSub = (result.stats.substitutions as any[]).find( - s => s.name === 'Act 3 - Bridge', - ) - expect(bridgeSub).toBeDefined() - expect(bridgeSub.enabled).toBe(true) - expect(result.stats.bridgesPlaced).toBe(layout.riverNetwork.bridges.length) - - // Bridge coordinates must align to grid cells - for (const bridge of layout.riverNetwork.bridges) { - expect(bridge.x).toBe(bridge.col * 32) - expect(bridge.y).toBe(bridge.row * 32) - expect(bridge.pieceName).toBe('Act 3 - Bridge') - } - }) -}) - -describe('End-to-End generateWilderness Integration for Act 3 Jungles', () => { - test('DRLG_GenerateJungles is removed from UNIMPLEMENTED_PASSES', () => { - expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles') - }) - - test('generates Spider Forest (76) with Webby clearings and complete jungle metadata', () => { - const pieces = makeMockJunglePieces() - const result = generateWilderness({ - levelId: 76, - levelName: 'Spider Forest', - levelTypeName: 'Act 3 - Jungle', - seed: 555, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - }) - - expect(result.level.width).toBe(64) - expect(result.level.height).toBe(192) - expect(result.level.act).toBe(3) - - const subs = result.stats.substitutions as any[] - expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Webby'))).toBe(true) - expect(subs.some(s => s.name === 'Act 3 - Jungle Tail')).toBe(true) - expect(subs.some(s => s.name === 'Act 3 - Jungle Head')).toBe(true) - expect(subs.some(s => s.name === 'Act 3 - Bridge')).toBe(true) - - expect(result.stats.jungleLayout).toBeDefined() - expect(result.stats.jungleTopology).toBeDefined() - }) - - test('generates Great Marsh (77) with Boggy clearings', () => { - const pieces = makeMockJunglePieces() - const result = generateWilderness({ - levelId: 77, - levelName: 'Great Marsh', - levelTypeName: 'Act 3 - Jungle', - seed: 888, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - }) - - const subs = result.stats.substitutions as any[] - expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Boggy'))).toBe(true) - }) - - test('generates Flayer Jungle (78) with Pygmy clearings', () => { - const pieces = makeMockJunglePieces() - const result = generateWilderness({ - levelId: 78, - levelName: 'Flayer Jungle', - levelTypeName: 'Act 3 - Jungle', - seed: 999, - sizeX: 64, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - }) - - const subs = result.stats.substitutions as any[] - expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Pygmy'))).toBe(true) - }) - - test('supports wider 3-column (96x192) jungle layouts', () => { - const pieces = makeMockJunglePieces() - const result = generateWilderness({ - levelId: 76, - levelName: 'Spider Forest', - levelTypeName: 'Act 3 - Jungle', - seed: 1234, - sizeX: 96, - sizeY: 192, - subType: 0, - subTheme: 0, - pieces, - substitutions: [], - }) - - expect(result.level.width).toBe(96) - expect(result.level.height).toBe(192) - - const layout = result.stats.jungleLayout as any - expect(layout.gridColumns).toBe(3) - expect(layout.gridRows).toBe(6) - expect(layout.riverNetwork.meanderCount).toBeGreaterThanOrEqual(1) - }) -}) diff --git a/tests/wilderness-population.test.ts b/tests/wilderness-population.test.ts deleted file mode 100644 index cc89548..0000000 --- a/tests/wilderness-population.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, test } from "vitest" -import { UNIMPLEMENTED_PASSES } from "../src/game/wilderness.ts" -import { - rollEliteModifiers, - applyEliteModifiers, - CANONICAL_ELITE_MODIFIERS, - planLevelMonsters, -} from "../src/game/monsters.ts" -import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts" -import { parseTable } from "../src/game/acts.ts" -import { Rng } from "../src/game/rng.ts" - -// Act I outdoor population (the Act I waypoints, shrines, Flavie and the SuperUniques of Cold Plains, -// Stony Field, Dark Wood, Burial Grounds and Moo Moo Farm) comes from the DRLG port and is checked on -// its output in tests/drlg-act1-population.test.ts. -describe("Wilderness Population Phase (Issue #36)", () => { - test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => { - expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint") - expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines") - }) - - test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => { - const rng = new Rng(0xabcdef) - const mods = rollEliteModifiers("unique", rng) - expect(mods.length).toBeGreaterThanOrEqual(1) - const allowedNames = CANONICAL_ELITE_MODIFIERS.map(m => m.name) - for (const m of mods) { - expect(allowedNames).toContain(m) - } - - const baseStats = { - id: "fallen1", - name: "Fallen", - hp: 20, - damage: 4, - speed: 2, - reach: 32, - aggroRadius: 220, - cooldownTicks: 25, - xp: 10, - } - - const strongFast = applyEliteModifiers(baseStats, "unique", ["strong", "fast"], "Rakanishu") - expect(strongFast.rank).toBe("unique") - expect(strongFast.superUniqueId).toBe("Rakanishu") - expect(strongFast.modifiers).toEqual(["strong", "fast"]) - expect(strongFast.damage).toBe(6) // 4 * 1.5 - expect(strongFast.speed).toBeCloseTo(2.8) // 2 * 1.4 - }) - - test("spawnMonsterPacks enforces safeZones exclusion radius and anchors fixedPosition packs", () => { - const world = createWorld(500, 500) - const safeZones: SafeZone[] = [ - { x: 500, y: 500, radius: 200 }, - ] - const packs: MonsterPack[] = [ - { - members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }], - }, - { - members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }], - }, - { - superUniqueId: "Rakanishu", - fixedPosition: { x: 1200, y: 1200 }, - members: [{ id: "fallen1", name: "Rakanishu", hp: 80, damage: 10, speed: 3, reach: 30, aggroRadius: 250, cooldownTicks: 20, xp: 50, rank: "unique", superUniqueId: "Rakanishu" }], - }, - ] - - const placed = spawnMonsterPacks(world, packs, { x: 500, y: 500 }, 800, { overlap: () => 0 }, safeZones) - expect(placed).toBe(3) - expect(world.monsters.length).toBe(3) - - for (const monster of world.monsters) { - if (monster.stats.superUniqueId === "Rakanishu") { - // Anchored to fixedPosition (1200, 1200) - expect(Math.hypot(monster.x - 1200, monster.y - 1200)).toBeLessThan(80) - } else { - // Must respect safeZone around (500, 500) with radius 200 - const dist = Math.hypot(monster.x - 500, monster.y - 500) - expect(dist).toBeGreaterThanOrEqual(200) - } - } - }) - - test("planLevelMonsters plans fixed SuperUniques for Cold Plains (3), Stony Field (4), Dark Wood (5), and Burial Grounds (17)", () => { - const enc = new TextEncoder() - const levelsTxt = parseTable( - enc.encode( - [ - "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", - "3\tCold Plains\t600\t2\t4\t2\tfallen1\tfallen2\tzombie1", - "4\tStony Field\t600\t2\t4\t4\tfallen1\tfallen2\tskeleton1", - "5\tDark Wood\t600\t2\t4\t5\tbrute1\tfallen1\tskeleton1", - "17\tBurial Grounds\t500\t1\t2\t3\tzombie1\tskeleton1\tcorruptrogue1", - ].join("\n"), - ), - ) - const monstatsTxt = parseTable( - enc.encode( - [ - "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", - "fallen1\tfallen1\tFallen\t1\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1", - "fallen2\tfallen1\tCarver\t2\t3\t6\t8\t12\t8\t25\t12\t20\t2\t4\t28\t1\t1\t1\t1", - "fallenshaman1\tfallenshaman1\tFallen Shaman\t2\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1", - "zombie1\tzombie1\tZombie\t1\t2\t4\t5\t8\t6\t18\t14\t22\t2\t5\t22\t1\t1\t1\t1", - "skeleton1\tskeleton1\tSkeleton\t2\t3\t5\t7\t10\t8\t22\t12\t18\t2\t5\t26\t1\t1\t1\t1", - "brute1\tbrute1\tGargantuan Beast\t5\t2\t4\t7\t10\t18\t65\t30\t45\t5\t10\t45\t1\t1\t1\t1", - "corruptrogue1\tcorruptrogue1\tCorrupt Rogue\t3\t3\t5\t8\t12\t12\t30\t15\t24\t3\t6\t35\t1\t1\t1\t1", - ].join("\n"), - ), - ) - const monlvlTxt = parseTable( - enc.encode( - [ - "Level\tAC\tTH\tHP\tDM\tXP", - "1\t100\t100\t100\t100\t100", - "2\t100\t100\t100\t100\t100", - "3\t100\t100\t100\t100\t100", - "4\t100\t100\t100\t100\t100", - "5\t100\t100\t100\t100\t100", - "6\t100\t100\t100\t100\t100", - "7\t100\t100\t100\t100\t100", - "8\t100\t100\t100\t100\t100", - ].join("\n"), - ), - ) - - const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } - - for (const [lvlId, suName] of [ - [3, "Bishibosh"], - [4, "Rakanishu"], - [5, "Treehead WoodFist"], - [17, "Blood Raven"], - ] as const) { - const planned = planLevelMonsters(tables, lvlId, 6400, 0x12345 + lvlId, 3.5, "normal", { - landmarks: [{ id: suName, tileX: 40, tileY: 40, fixedPosition: { x: 1000, y: 1000 } }], - }) - - expect(planned.superUniques).toContain(suName) - const suPack = planned.packs.find(p => p.superUniqueId === suName) - expect(suPack).toBeDefined() - expect(suPack?.fixedPosition).toEqual({ x: 1000, y: 1000 }) - expect(suPack?.landmarkTile).toEqual({ tileX: 40, tileY: 40 }) - expect(suPack?.members[0]?.rank).toBe("unique") - expect(suPack?.members[0]?.superUniqueId).toBe(suName) - expect(suPack?.members[0]?.modifiers?.length).toBeGreaterThan(0) - } - }) -}) diff --git a/tests/wilderness-roads-multi-act.test.ts b/tests/wilderness-roads-multi-act.test.ts deleted file mode 100644 index fedd485..0000000 --- a/tests/wilderness-roads-multi-act.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - createCanvas, - stampAct2DesertRoads, - stampAct2SandstoneBerms, - stampAct3KurastAvenues, - stampAct3KurastBalustrades, - stampAct5SnowRoads, - stampAct5FrozenTrailDivides, - stampOutdoorRoads, - stampOutdoorDividers, - generateWilderness, - TILES_PER_BLOCK, -} from '../src/game/wilderness.ts' -import type { WildernessPiece, PlacedPresetRect, Canvas } from '../src/game/wilderness.ts' -import { Rng } from '../src/game/rng.ts' -import type { Ds1, Ds1Cell, Ds1Wall } from '../src/formats/ds1.ts' - -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: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function countFloorTiles(canvas: Canvas, style: number, prop1: number): number { - let count = 0 - for (let y = 0; y < canvas.height; y += 1) { - const row = canvas.cells[y] - if (!row) continue - for (let x = 0; x < canvas.width; x += 1) { - const cell = row[x] - if (cell && cell.floors.some(f => f.style === style && f.prop1 === prop1)) { - count += 1 - } - } - } - return count -} - -function countWallTiles(canvas: Canvas, style: number, prop1: number): number { - let count = 0 - for (let y = 0; y < canvas.height; y += 1) { - const row = canvas.cells[y] - if (!row) continue - for (let x = 0; x < canvas.width; x += 1) { - const cell = row[x] - if (cell && cell.walls.some(w => w.style === style && w.prop1 === prop1)) { - count += 1 - } - } - } - return count -} - -describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => { - const gridW = 10 - const gridH = 10 - const width = gridW * TILES_PER_BLOCK - const height = gridH * TILES_PER_BLOCK - const inX = Math.floor(gridW / 2) - const outY = Math.floor(gridH / 2) - - describe('Act 2 Desert Roads & Sandstone Berms', () => { - it('stampAct2DesertRoads paints caravan routes with style: 1, prop1: 194 and connects spurs', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(42) - const placedRects: PlacedPresetRect[] = [ - { name: 'Act 2 - Desert Tomb 1', x: 24, y: 24, w: 16, h: 16 }, - ] - - stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) - - const roadFloors = countFloorTiles(canvas, 1, 194) - expect(roadFloors).toBeGreaterThan(50) - - // Verify the main highway corridor has caravan road floors - const highwayTile = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4] - expect(highwayTile).toBeDefined() - expect(highwayTile?.floors.some(f => f.style === 1 && f.prop1 === 194)).toBe(true) - - // Verify spur path connected towards the Tomb preset - let spurFound = false - for (let y = 30; y <= 34; y += 1) { - for (let x = 38; x <= 44; x += 1) { - const c = canvas.cells[y]?.[x] - if (c?.floors.some(f => f.style === 1 && f.prop1 === 194)) { - spurFound = true - break - } - } - } - expect(spurFound).toBe(true) - }) - - it('stampAct2SandstoneBerms is a no-op removing artificial cross dividers (Issue #65)', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(42) - const placedRects: PlacedPresetRect[] = [] - - // First stamp roads so the divider can create 3-tile archway breaks across them - stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) - stampAct2SandstoneBerms(canvas, gridW, gridH, inX, outY, placedRects, rng) - - const bermWalls = countWallTiles(canvas, 1, 129) - expect(bermWalls).toBe(0) - }) - }) - - describe('Act 3 Kurast Avenues & Balustrades', () => { - it('stampAct3KurastAvenues paints flagstone avenues with style: 1, prop1: 194', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(100) - const placedRects: PlacedPresetRect[] = [ - { name: 'Act 3 - Burbs Temple', x: 20, y: 20, w: 16, h: 16 }, - ] - - stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng) - - const avenueFloors = countFloorTiles(canvas, 1, 194) - expect(avenueFloors).toBeGreaterThan(50) - }) - - it('stampAct3KurastBalustrades is a no-op removing artificial cross dividers (Issue #65)', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(100) - const placedRects: PlacedPresetRect[] = [] - - stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng) - stampAct3KurastBalustrades(canvas, gridW, gridH, inX, outY, placedRects, rng) - - const balustradeWalls = countWallTiles(canvas, 1, 129) - expect(balustradeWalls).toBe(0) - }) - }) - - describe('Act 5 Snow Roads & Frozen Trail Divides', () => { - it('stampAct5SnowRoads paints snow march roads with style: 1, prop1: 194', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(555) - const placedRects: PlacedPresetRect[] = [ - { name: 'Act 5 - Barricade Building', x: 20, y: 20, w: 16, h: 16 }, - ] - - stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) - - const snowRoadFloors = countFloorTiles(canvas, 1, 194) - expect(snowRoadFloors).toBeGreaterThan(50) - }) - - it('stampAct5FrozenTrailDivides is a no-op removing artificial cross dividers (Issue #65)', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(555) - const placedRects: PlacedPresetRect[] = [] - - stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) - stampAct5FrozenTrailDivides(canvas, gridW, gridH, inX, outY, placedRects, rng) - - const frozenDivides = countWallTiles(canvas, 2, 129) - expect(frozenDivides).toBe(0) - }) - }) - - describe('Unified Dispatchers: stampOutdoorRoads & stampOutdoorDividers', () => { - it('dispatches Act 2 Desert based on levelId 41 without artificial cross dividers', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(123) - stampOutdoorRoads(canvas, 1, 41, gridW, gridH, inX, outY, [], rng) - stampOutdoorDividers(canvas, 1, 41, gridW, gridH, inX, outY, [], rng) - - expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(canvas, 1, 129)).toBe(0) - }) - - it('dispatches Act 3 Kurast based on levelId 80 without artificial cross dividers', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(123) - stampOutdoorRoads(canvas, 1, 80, gridW, gridH, inX, outY, [], rng) - stampOutdoorDividers(canvas, 1, 80, gridW, gridH, inX, outY, [], rng) - - expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(canvas, 1, 129)).toBe(0) - }) - - it('dispatches Act 5 Snow based on levelId 111 without artificial cross dividers', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(123) - stampOutdoorRoads(canvas, 1, 111, gridW, gridH, inX, outY, [], rng) - stampOutdoorDividers(canvas, 1, 111, gridW, gridH, inX, outY, [], rng) - - expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(canvas, 2, 129)).toBe(0) - }) - - it('refuses Act I outdoor levels (levelId 3): they come from the DRLG port (src/game/drlg)', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(123) - expect(() => stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/) - expect(() => stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/) - }) - - it('dispatches based on levelTypeName when levelId is generic', () => { - const c2 = createCanvas(width, height, 1, 1, 0) - stampOutdoorRoads(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert') - stampOutdoorDividers(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert') - expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(c2, 1, 129)).toBe(0) - - const c3 = createCanvas(width, height, 1, 1, 0) - stampOutdoorRoads(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast') - stampOutdoorDividers(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast') - expect(countFloorTiles(c3, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(c3, 1, 129)).toBe(0) - - const c5 = createCanvas(width, height, 1, 1, 0) - stampOutdoorRoads(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade') - stampOutdoorDividers(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade') - expect(countFloorTiles(c5, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(c5, 2, 129)).toBe(0) - }) - - it('dispatches based on act number fallback', () => { - const c2 = createCanvas(width, height, 1, 1, 0) - stampOutdoorRoads(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1)) - stampOutdoorDividers(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1)) - expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40) - expect(countWallTiles(c2, 1, 129)).toBe(0) - }) - - it('refuses the Cow Level (39): an Act I outdoor level generated by the DRLG port', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(42) - expect(() => stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/) - expect(() => stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/) - }) - }) - - describe('generateWilderness Integration for Acts 2, 3, 5', () => { - const borderDs1 = makeMockDs1(8, 8) - const borderPiece: WildernessPiece = { - name: 'Act 2 - Desert Border 1', - border: true, - levels: [borderDs1], - } - - it('generates Act 2 Rocky Waste (Level 41) with caravan routes and authentic canyon topography walls', () => { - const result = generateWilderness({ - levelId: 41, - levelName: 'Rocky Waste', - levelTypeName: 'Act 2 - Desert', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 77777, - pieces: [borderPiece], - substitutions: [], - }) - - expect(result.level.act).toBe(2) - let caravanRoads = 0 - let sandstoneBerms = 0 - for (let y = 8; y < 72; y += 1) { - for (let x = 8; x < 72; x += 1) { - const cell = result.level.cells[y]?.[x] - if (!cell) continue - if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) caravanRoads += 1 - if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) sandstoneBerms += 1 - } - } - - expect(caravanRoads).toBeGreaterThan(30) - expect(sandstoneBerms).toBeGreaterThan(15) - }) - - it('generates Act 3 Kurast Bazaar (Level 80) with Kurast avenues and authentic causeway balustrades', () => { - const act3Border: WildernessPiece = { - name: 'Act 3 - Kurast Border', - border: true, - levels: [borderDs1], - } - const result = generateWilderness({ - levelId: 80, - levelName: 'Kurast Bazaar', - levelTypeName: 'Act 3 - Kurast', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 88888, - pieces: [act3Border], - substitutions: [], - }) - - expect(result.level.act).toBe(3) - let avenues = 0 - let balustrades = 0 - for (let y = 8; y < 72; y += 1) { - for (let x = 8; x < 72; x += 1) { - const cell = result.level.cells[y]?.[x] - if (!cell) continue - if (cell.floors.some(f => (f.style === 1 || f.style === 5 || f.style === 20) && f.prop1 === 194)) avenues += 1 - if (cell.walls.some(w => (w.style === 1 || w.style === 20) && w.prop1 === 129)) balustrades += 1 - } - } - - expect(avenues).toBeGreaterThan(30) - expect(balustrades).toBeGreaterThan(15) - }) - - it('generates Act 5 Frigid Highlands (Level 111) with snow roads and authentic siege barricade walls', () => { - const act5Border: WildernessPiece = { - name: 'Act 5 - Barricade Border', - border: true, - levels: [borderDs1], - } - const result = generateWilderness({ - levelId: 111, - levelName: 'Frigid Highlands', - levelTypeName: 'Act 5 - Barricade', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 99999, - pieces: [act5Border], - substitutions: [], - }) - - expect(result.level.act).toBe(5) - let snowRoads = 0 - let frozenDivides = 0 - for (let y = 8; y < 72; y += 1) { - for (let x = 8; x < 72; x += 1) { - const cell = result.level.cells[y]?.[x] - if (!cell) continue - if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) snowRoads += 1 - if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) frozenDivides += 1 - } - } - - expect(snowRoads).toBeGreaterThan(30) - expect(frozenDivides).toBeGreaterThan(15) - }) - - it.each([2, 3, 4, 5, 6, 7, 17, 39])('refuses Act I outdoor level %i: it comes from the DRLG port (src/game/drlg)', (levelId) => { - const act1Border: WildernessPiece = { - name: 'Act 1 - Wild Border 1', - border: true, - levels: [borderDs1], - } - expect(() => generateWilderness({ - levelId, - levelName: `Act I level ${String(levelId)}`, - levelTypeName: 'Act 1 - Wilderness', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 12345, - pieces: [act1Border], - substitutions: [], - })).toThrow(/DRLG port/) - }) - - it('stampThemedRoads preserves preset walls on the highway spine', () => { - const canvas = createCanvas(width, height, 1, 1, 0) - const rng = new Rng(42) - const hubX = inX * TILES_PER_BLOCK + 4 - const hubY = outY * TILES_PER_BLOCK + 4 - // Preset directly intersects the road spine - const placedRects: PlacedPresetRect[] = [ - { name: 'Protected Preset', x: hubX - 2, y: hubY - 2, w: 5, h: 5 }, - ] - // Paint preset wall inside the preset on the highway spine - const targetCell = canvas.cells[hubY]?.[hubX] - expect(targetCell).toBeDefined() - ;(targetCell!.walls as Ds1Wall[]).push({ - prop1: 100, - sequence: 0, - style: 99, - type: 1, - unknown1: 0, - unknown2: 0, - hidden: false, - }) - - // Act 2 caravan routes go through the shared stampThemedRoads rasterizer. - stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng) - - // The wall inside the preset must NOT have been wiped - expect(targetCell!.walls.some(w => w.style === 99 && w.prop1 === 100)).toBe(true) - }) - }) -}) diff --git a/tests/wilderness-seams.test.ts b/tests/wilderness-seams.test.ts deleted file mode 100644 index 34a6f6a..0000000 --- a/tests/wilderness-seams.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - generateWilderness, - getPerimeterOpenings, - type WildernessPiece, - type SeamConstraint, - type GateOpening, - type PlannedGate, -} from "../src/game/wilderness.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - const isGap = hasGapInWalls && x === Math.floor(width / 2) - row.push({ - walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeTestPieces(): WildernessPiece[] { - return [ - { name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] }, - { name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] }, - { name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] }, - ] -} - -describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", () => { - describe("getPerimeterOpenings unit tests", () => { - test("uses forced gate.blockCoord directly", () => { - const gates: PlannedGate[] = [ - { label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 5 }, - ] - const openings = getPerimeterOpenings(2, 10, 10, gates) - // East side is at x = 9, y = 5 - expect(openings.has("9,5")).toBe(true) - }) - - test("clamps gate.blockCoord away from corners [1, size - 2]", () => { - const gates: PlannedGate[] = [ - { label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 0 }, // Out of bounds / corner -> clamp to 1 - { label: "Dark Wood", side: "south", toLevelId: 5, blockCoord: 9 }, // Out of bounds / corner -> clamp to 8 - ] - const openings = getPerimeterOpenings(3, 10, 10, gates) - // North opening at x = 1, y = 0 - expect(openings.has("1,0")).toBe(true) - // South opening at x = 8, y = 9 - expect(openings.has("8,9")).toBe(true) - }) - - test("matches seamConstraints by side and level ID", () => { - const gates: PlannedGate[] = [ - { label: "Blood Moor", side: "west", toLevelId: 2 }, - ] - const seamConstraints: SeamConstraint[] = [ - { side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: 4 }, - ] - const openings = getPerimeterOpenings(3, 10, 10, gates, seamConstraints) - // West opening at x = 0, y = 4 - expect(openings.has("0,4")).toBe(true) - }) - - test("converts tileCoord to blockCoord when blockCoord is not specified", () => { - const gates: PlannedGate[] = [ - { label: "Stony Field", side: "north", toLevelId: 4, tileCoord: { x: 48, y: 0 } }, - ] - // tile x: 48 -> block x: 48 / 8 = 6 - const openings = getPerimeterOpenings(3, 10, 10, gates) - expect(openings.has("6,0")).toBe(true) - }) - - test("handles extreme out-of-bounds coordinates safely", () => { - const gates: PlannedGate[] = [ - { label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: -100 }, - { label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 1000 }, - ] - const openings = getPerimeterOpenings(2, 10, 10, gates) - expect(openings.has("0,1")).toBe(true) - expect(openings.has("9,8")).toBe(true) - }) - }) - - // Act I outdoor levels come from the DRLG port (src/game/drlg); their seams are checked on the - // port's act layout in tests/drlg-act1-population.test.ts. The planned-gate / seam-constraint - // machinery below is shared by Acts 2-5 and runs on the Act II desert levels (41 Rocky Waste, - // 42 Dry Hills, 43 Far Oasis, 56 Halls of the Dead). - describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => { - test("honors gates with explicit blockCoord", () => { - const pieces = makeTestPieces() - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x999999, - pieces, - substitutions: [], - gates: [ - { label: "Rocky Waste", side: "west", toLevelId: 41, blockCoord: 3 }, - { label: "Far Oasis", side: "north", toLevelId: 43, blockCoord: 6 }, - { label: "Halls of the Dead", side: "east", toLevelId: 56, blockCoord: 7 }, - ], - }) - - const openings = result.stats.gateOpenings - expect(openings).toBeDefined() - - const west = openings!.find(g => g.side === "west") - expect(west?.blockCoord).toBe(3) - - const north = openings!.find(g => g.side === "north") - expect(north?.blockCoord).toBe(6) - - const east = openings!.find(g => g.side === "east") - expect(east?.blockCoord).toBe(7) - }) - - test("honors gates with explicit tileCoord and positions road anchors", () => { - const pieces = makeTestPieces() - const targetTileY = 8 * 4 + 3 // block 4, tile 35 - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x777777, - pieces, - substitutions: [], - gates: [ - { label: "Rocky Waste", side: "west", toLevelId: 41, tileCoord: { x: 3, y: targetTileY } }, - ], - }) - - const openings = result.stats.gateOpenings - expect(openings).toBeDefined() - const west = openings!.find(g => g.side === "west") - expect(west?.blockCoord).toBe(4) - expect(west?.tileCoord).toEqual({ x: 3, y: targetTileY }) - }) - }) - - describe("Bidirectional level links & seam alignment consistency", () => { - test("level A (East) and level B (West) have matching seam and opening block", () => { - const pieces = makeTestPieces() - const targetSeamY = 5 - - const levelA = generateWilderness({ - levelId: 41, - levelName: "Level A", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x111, - pieces, - substitutions: [], - gates: [{ label: "Level B", side: "east", toLevelId: 42, blockCoord: targetSeamY }], - }) - - const levelB = generateWilderness({ - levelId: 42, - levelName: "Level B", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x222, - pieces, - substitutions: [], - seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: targetSeamY }], - }) - - const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")! - const gateB = levelB.stats.gateOpenings!.find(g => g.side === "west")! - - expect(gateA.blockCoord).toBe(targetSeamY) - expect(gateB.blockCoord).toBe(targetSeamY) - // Along-seam tile coordinate: Y coordinate matches - expect(gateA.tileCoord!.y).toBe(gateB.tileCoord!.y) - }) - - test("level B (North) and level C (South) have matching seam and opening block", () => { - const pieces = makeTestPieces() - const targetSeamX = 6 - - const levelB = generateWilderness({ - levelId: 42, - levelName: "Level B", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x333, - pieces, - substitutions: [], - gates: [{ label: "Level C", side: "north", toLevelId: 43, blockCoord: targetSeamX }], - }) - - const levelC = generateWilderness({ - levelId: 43, - levelName: "Level C", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x444, - pieces, - substitutions: [], - // Only the west/east gates are defaults; the south seam gate is planned and takes its block - // from the seam constraint. - gates: [{ label: "Level B", side: "south", toLevelId: 42 }], - seamConstraints: [{ side: "south", fromLevelId: 42, toLevelId: 43, blockCoord: targetSeamX }], - }) - - const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")! - const gateC = levelC.stats.gateOpenings!.find(g => g.side === "south")! - - expect(gateB.blockCoord).toBe(targetSeamX) - expect(gateC.blockCoord).toBe(targetSeamX) - // Along-seam tile coordinate: X coordinate matches - expect(gateB.tileCoord!.x).toBe(gateC.tileCoord!.x) - }) - }) - - describe("Out of bounds clamping gracefully preserves valid playable borders", () => { - test("clamps negative blockCoord to 1 without crashing", () => { - const pieces = makeTestPieces() - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x555, - pieces, - substitutions: [], - seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: -10 }], - }) - - const west = result.stats.gateOpenings!.find(g => g.side === "west") - expect(west?.blockCoord).toBe(1) - }) - - test("clamps excessive blockCoord to gridHeight - 2 without crashing", () => { - const pieces = makeTestPieces() - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x666, - pieces, - substitutions: [], - seamConstraints: [{ side: "east", fromLevelId: 43, toLevelId: 42, blockCoord: 999 }], - }) - - const east = result.stats.gateOpenings!.find(g => g.side === "east") - expect(east?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8 - }) - }) -}) diff --git a/tests/wilderness-seed-sensitivity.test.ts b/tests/wilderness-seed-sensitivity.test.ts deleted file mode 100644 index d6e3353..0000000 --- a/tests/wilderness-seed-sensitivity.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * Wilderness Seed Sensitivity Test Suite (Milestone M1). - * - * Verifies: - * 1. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5. - * 2. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway). - * 3. Deterministic re-run invariance holds unconditionally. - * - * Act I outdoor levels come from the DRLG port (src/game/drlg) and are covered by - * tests/drlg-act1-oracle.test.ts (bit-exact parity with D2) and tests/drlg-act1-population.test.ts. - */ - -import { describe, expect, it, beforeAll } from 'vitest' -import { existsSync } from 'node:fs' -import { MountedArchives } from '../src/mpq/mount.ts' -import { MpqArchive } from '../src/mpq/archive.ts' -import { fileSource } from '../src/mpq/file-source.ts' -import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts' -import type { D2Table } from '../src/game/acts.ts' -import { decodeDs1 } from '../src/formats/ds1.ts' -import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' -import { - generateWilderness, - createCanvas, - type Canvas, - type WildernessPiece, - type WildernessSubstitution, - type WildernessStats, -} from '../src/game/wilderness.ts' -import { stampAct3KurastCauseway } from '../src/game/wilderness-acts.ts' -import { stampAct5SiegeStrips } from '../src/game/wilderness-siege.ts' -import { Rng } from '../src/game/rng.ts' - -/* ------------------------------------------------------------------------- * - * FNV-1a Canonical Map Hasher - * ------------------------------------------------------------------------- */ - -class Hasher { - private hash = 2166136261 - - push(value: number): this { - const word = Math.trunc(value) | 0 - for (let byte = 0; byte < 4; byte += 1) { - this.hash ^= (word >>> (byte * 8)) & 0xff - this.hash = Math.imul(this.hash, 16777619) >>> 0 - } - return this - } - - get digest(): string { - return (this.hash >>> 0).toString(16).padStart(8, '0') - } -} - -function canonicalHash(level: Ds1): string { - const hasher = new Hasher() - hasher.push(level.version).push(level.width).push(level.height).push(level.act) - hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers) - for (let y = 0; y < level.height; y += 1) { - const row = level.cells[y] - if (row === undefined) continue - for (let x = 0; x < level.width; x += 1) { - const cellRecord = row[x] - if (cellRecord === undefined) continue - for (const wall of cellRecord.walls) { - hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0) - } - for (const floor of cellRecord.floors) { - hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0) - } - for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style) - for (const substitution of cellRecord.substitutions) hasher.push(substitution.value) - } - } - for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags) - return hasher.digest -} - -/* ------------------------------------------------------------------------- * - * Mock Helpers for Hermetic Testing - * ------------------------------------------------------------------------- */ - -function makeMockDs1(width: number, height: number, floorStyle: number = 1): 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: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { - const ds1 = makeMockDs1(8, 8) - return [ - { name: `${actPrefix} - Border 1`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 2`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 3`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 4`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 5`, border: true, levels: [ds1] }, - { name: `${actPrefix} - Border 6`, border: true, levels: [ds1] }, - ] -} - -/* ------------------------------------------------------------------------- * - * Canonical Master Test Seeds - * ------------------------------------------------------------------------- */ - -const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const - -/* ------------------------------------------------------------------------- * - * Test Suite 1: Hermetic API & Strict Contract Safety - * ------------------------------------------------------------------------- */ - -describe('Hermetic Contract & RNG Parameter Safety', () => { - it('stampAct5SiegeStrips rejects missing or invalid Rng parameter with Error', () => { - const canvas: Canvas = createCanvas(64, 64, 1, 1, 1) - const stats: WildernessStats = { - substitutions: [], - notes: [], - } as unknown as WildernessStats - - expect(() => { - stampAct5SiegeStrips(canvas, [], null as unknown as Rng, stats) - }).toThrow(/missing required Rng parameter/) - - expect(() => { - stampAct5SiegeStrips(canvas, [], {} as unknown as Rng, stats) - }).toThrow(/missing required Rng parameter/) - }) - - it('stampAct5SiegeStrips rejects missing WildernessStats parameter with Error', () => { - const canvas: Canvas = createCanvas(64, 64, 1, 1, 1) - const rng = new Rng(42) - - expect(() => { - stampAct5SiegeStrips(canvas, [], rng, null as unknown as WildernessStats) - }).toThrow(/missing required WildernessStats parameter/) - }) - - it('stampAct3KurastCauseway rejects missing or invalid Rng parameter with Error', () => { - const canvas: Canvas = createCanvas(64, 64, 1, 1, 1) - const stats: WildernessStats = { - substitutions: [], - notes: [], - } as unknown as WildernessStats - - expect(() => { - stampAct3KurastCauseway(canvas, [], null as unknown as Rng, stats) - }).toThrow(/missing required Rng instance/) - - expect(() => { - stampAct3KurastCauseway(canvas, [], {} as unknown as Rng, stats) - }).toThrow(/missing required Rng instance/) - }) - - it('hermetic mock generation produces distinct hashes for different seeds and deterministic re-runs', () => { - const pieces = makeMockBorderPieces('Act 2 - Desert') - const hashes: string[] = [] - - for (const seed of TEST_SEEDS) { - const run1 = generateWilderness({ - levelId: 41, - levelName: 'Rocky Waste Mock', - levelTypeName: 'Act 2 - Desert', - sizeX: 64, - sizeY: 64, - subType: 0, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - const run2 = generateWilderness({ - levelId: 41, - levelName: 'Rocky Waste Mock', - levelTypeName: 'Act 2 - Desert', - sizeX: 64, - sizeY: 64, - subType: 0, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - - const hash1 = canonicalHash(run1.level) - const hash2 = canonicalHash(run2.level) - expect(hash1).toBe(hash2) - hashes.push(hash1) - } - - // Changing seed produces distinct layouts - expect(new Set(hashes).size).toBe(TEST_SEEDS.length) - }) -}) - -/* ------------------------------------------------------------------------- * - * Test Suite 2: MPQ Ground-Truth Baseline & Seed Sensitivity - * ------------------------------------------------------------------------- */ - -const hasMpqArchives = existsSync('samples/d2/d2data.mpq') - -// Act I outdoor levels come from the DRLG port (src/game/drlg): its determinism and seed sensitivity -// are checked in tests/drlg-act1-population.test.ts, its parity with D2 in tests/drlg-act1-oracle.test.ts. -describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 2..5', () => { - let archives: MountedArchives - let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } - let lvlsub: D2Table - - const ds1Cache = new Map() - - async function loadDs1(relative: string): Promise { - const member = tileMemberPath(relative) - const cached = ds1Cache.get(member) - if (cached !== undefined) return cached - const decoded = decodeDs1(await archives.read(member)) - ds1Cache.set(member, decoded) - return decoded - } - - async function rowDs1s(table: D2Table, row: readonly string[]): Promise { - const levels: Ds1[] = [] - for (let slot = 1; slot <= 6; slot += 1) { - const value = cell(table, row, `File${String(slot)}`) - if (value === '' || value === '0') continue - levels.push(await loadDs1(value)) - } - return levels - } - - const WILDERNESS_PIECE_FAMILIES: Readonly> = { - 'Act 2 - Desert': ['Act 2 - Desert'], - 'Act 3 - Jungle': ['Act 3 - Jungle'], - 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], - 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], - 'Act 5 - Siege': ['Act 5 - Siege'], - 'Act 5 - Barricade': ['Act 5 - Barricade'], - } - - async function buildWildernessPieces(levelTypeName: string): Promise { - const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] - const pieces: WildernessPiece[] = [] - for (const row of tables.lvlprest.rows) { - const name = cell(tables.lvlprest, row, 'Name') - if (!families.some(family => name.startsWith(family))) continue - if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const isBorder = /border|cliff/i.test(name) - pieces.push({ name, levels, border: isBorder }) - } - return pieces - } - - async function buildSubstitutions(type: number): Promise { - if (type < 0) return [] - const rows: WildernessSubstitution[] = [] - for (const row of lvlsub.rows) { - if (Number(cell(lvlsub, row, 'Type')) !== type) continue - const file = cell(lvlsub, row, 'File') - if (file === '' || file === '0') continue - const levels = [await loadDs1(file)] - rows.push({ - name: cell(lvlsub, row, 'Name'), - type, - gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, - bordType: Number(cell(lvlsub, row, 'BordType')), - dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, - prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0), - trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0), - max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0), - levels, - }) - } - return rows - } - - beforeAll(async () => { - archives = new MountedArchives() - for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - try { - archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) - } catch { - // Skip missing optional archive - } - } - const actTables = await loadActTables(archives) - tables = { - levels: actTables.levels, - lvltypes: actTables.lvltypes, - lvlprest: actTables.lvlprest, - } - lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) - }) - - async function generateLevelForSeed(levelId: number, seed: number): Promise { - const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)! - const typeId = cell(tables.levels, levelRow, 'LevelType') - const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId) - const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : '' - const name = cell(tables.levels, levelRow, 'Name') - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const subType = Number(cell(tables.levels, levelRow, 'SubType')) - const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) - const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) - - const pieces = await buildWildernessPieces(levelTypeName) - const subs = await buildSubstitutions(subType) - const shrineSubs = await buildSubstitutions(subShrine) - - const result = generateWilderness({ - levelId, - levelName: name, - levelTypeName, - sizeX, - sizeY, - subType, - subTheme: Math.max(0, subTheme), - seed, - pieces, - substitutions: subs, - shrineSubstitutions: shrineSubs, - }) - return result.level - } - - it('Act 2 procedural outdoor levels produce distinct hashes across seeds', async () => { - const act2Levels = [41, 42, 43, 44, 45, 46] - - for (const levelId of act2Levels) { - const hashes: string[] = [] - for (const seed of TEST_SEEDS) { - const level = await generateLevelForSeed(levelId, seed) - hashes.push(canonicalHash(level)) - } - expect(new Set(hashes).size).toBe(3) - } - }, 60000) - - it('Act 3 procedural outdoor levels produce distinct hashes across seeds', async () => { - const act3Levels = [76, 77, 78, 79, 80, 81] - - for (const levelId of act3Levels) { - const hashes: string[] = [] - for (const seed of TEST_SEEDS) { - const level = await generateLevelForSeed(levelId, seed) - hashes.push(canonicalHash(level)) - } - expect(new Set(hashes).size).toBe(3) - } - }, 60000) - - it('Act 4 procedural outdoor levels produce distinct hashes across seeds', async () => { - const act4Levels = [104, 105, 106] - - for (const levelId of act4Levels) { - const hashes: string[] = [] - for (const seed of TEST_SEEDS) { - const level = await generateLevelForSeed(levelId, seed) - hashes.push(canonicalHash(level)) - } - expect(new Set(hashes).size).toBe(3) - } - }, 60000) - - it('Act 5 procedural outdoor levels produce distinct hashes across seeds', async () => { - const act5Levels = [110, 111, 112, 117, 134] - - for (const levelId of act5Levels) { - const hashes: string[] = [] - for (const seed of TEST_SEEDS) { - const level = await generateLevelForSeed(levelId, seed) - hashes.push(canonicalHash(level)) - } - expect(new Set(hashes).size).toBe(3) - } - }, 60000) - - it('Wilderness generation determinism invariance holds across repeated runs', async () => { - // Spot check one level per Act (Act I: tests/drlg-act1-population.test.ts) - const sampleLevels = [41, 79, 104, 111] - for (const levelId of sampleLevels) { - const seed = TEST_SEEDS[0] - const first = await generateLevelForSeed(levelId, seed) - const second = await generateLevelForSeed(levelId, seed) - expect(canonicalHash(first)).toBe(canonicalHash(second)) - } - }, 60000) -}) diff --git a/tests/wilderness-siege-strips.test.ts b/tests/wilderness-siege-strips.test.ts deleted file mode 100644 index 6b00f79..0000000 --- a/tests/wilderness-siege-strips.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - generateWilderness, - UNIMPLEMENTED_PASSES, -} from "../src/game/wilderness.ts" -import type { WildernessPiece } from "../src/game/wilderness.ts" -import { - ACT5_SIEGE_STRIP_NAMES, - type SiegeTopographyStats, -} from "../src/game/wilderness-siege.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockStripDs1( - width: number, - height: number, - tagSequence: 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: x === 0 && y === 0 ? tagSequence : 0, - style: 0, - unknown1: 0, - unknown2: 0, - hidden: false, - }, - ], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 5, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function createMockSiegePieces(): WildernessPiece[] { - return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({ - name, - border: false, - levels: [makeMockStripDs1(16, 48, 100 + idx)], - })) -} - -describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => { - test("DRLGOUTSIEGE_InitAct5OutdoorLevel is removed from UNIMPLEMENTED_PASSES", () => { - expect(UNIMPLEMENTED_PASSES).not.toContain( - "DRLGOUTSIEGE_InitAct5OutdoorLevel", - ) - }) - - test("generates 240x48 Bloody Foothills canvas with 15 sequential strips from West to East", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 12345, - pieces, - substitutions: [], - }) - - expect(result.level.width).toBe(240) - expect(result.level.height).toBe(48) - expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 }) - expect(result.stats.substitutedClusters).toBe(15) - - // Strip 0 (Siege To Barricade) is at x=0, Strip 14 (Siege To Town) is at x=224 - expect(result.level.cells[0]![0]!.floors[0]!.sequence).toBe(100) - expect(result.level.cells[0]![224]!.floors[0]!.sequence).toBe(114) - }) - - test("supports dynamic strip preset level variants selected by seed", () => { - const pieces: WildernessPiece[] = ACT5_SIEGE_STRIP_NAMES.map((name, idx) => { - // Provide 3 different DS1 variants with distinct floor tags at (0,1) - const varA = makeMockStripDs1(16, 48, 100 + idx) - const varB = makeMockStripDs1(16, 48, 200 + idx) - const varC = makeMockStripDs1(16, 48, 300 + idx) - ;(varA.cells[1]![0]!.floors as any)[0].sequence = 1 - ;(varB.cells[1]![0]!.floors as any)[0].sequence = 2 - ;(varC.cells[1]![0]!.floors as any)[0].sequence = 3 - - return { - name, - border: false, - levels: [varA, varB, varC], - } - }) - - const runA = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const runB = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 999999, - pieces, - substitutions: [], - }) - - // Compare variant selections across strips - const tagsA = [16, 32, 48, 64, 80, 96, 112].map( - x => runA.level.cells[1]![x]!.floors[0]!.sequence, - ) - const tagsB = [16, 32, 48, 64, 80, 96, 112].map( - x => runB.level.cells[1]![x]!.floors[0]!.sequence, - ) - - // At least one strip variant differs between the two seeds - expect(tagsA).not.toEqual(tagsB) - }) - - test("supports dynamic strip selection with named variant pieces", () => { - const pieces = createMockSiegePieces() - // Replace Strip 5 with a named variant piece - const strip5Idx = pieces.findIndex(p => p.name === "Act 5 - Siege Strip 5") - pieces[strip5Idx] = { - name: "Act 5 - Siege Strip 5 Variant Bravo", - border: false, - levels: [makeMockStripDs1(16, 48, 777)], - } - - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 777, - pieces, - substitutions: [], - }) - - // Strip 5 is at slot index 9 (x = 9 * 16 = 144) - expect(result.level.cells[0]![144]!.floors[0]!.sequence).toBe(777) - }) - - test("generates battlefield trenches with cleared wall channels", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const stats = result.stats.siegeTopography as SiegeTopographyStats - expect(stats).toBeDefined() - expect(stats.trenchCells).toBeGreaterThan(60) - - // Scan level for trench floor cells (style: 5, prop1: 194) - let foundTrenchCells = 0 - let wallsClearedInTrenches = true - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - const hasTrenchFloor = cell.floors.some( - f => f.style === 5 && f.prop1 === 194, - ) - if (hasTrenchFloor) { - foundTrenchCells += 1 - // Trenches must be cleared of blocking walls - if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) { - wallsClearedInTrenches = false - } - } - } - } - - expect(foundTrenchCells).toBe(stats.trenchCells) - expect(wallsClearedInTrenches).toBe(true) - }) - - test("generates breached wooden barricades with splintered end-caps and open breach gaps", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const stats = result.stats.siegeTopography as SiegeTopographyStats - expect(stats.barricadeWalls).toBeGreaterThan(30) - - // Check for barricade walls (style: 2, prop1: 129) - let foundBarricadeWalls = 0 - let foundSplinteredEndCaps = 0 - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - const barricadeWall = cell.walls.find( - w => !w.hidden && w.style === 2 && w.prop1 === 129, - ) - if (barricadeWall) { - foundBarricadeWalls += 1 - if (barricadeWall.type === 7 && (barricadeWall.sequence === 10 || barricadeWall.sequence === 11)) { - foundSplinteredEndCaps += 1 - } - } - } - } - - expect(foundBarricadeWalls).toBe(stats.barricadeWalls) - // Splintered end-caps (type: 7, seq: 10/11) must flank the breaches - expect(foundSplinteredEndCaps).toBeGreaterThan(0) - }) - - test("generates catapult defense platforms with parapets", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const stats = result.stats.siegeTopography as SiegeTopographyStats - expect(stats.catapultPlatforms).toBe(3) - - // Scan for catapult platform floors (style: 4, prop1: 2) - let platformFloorCount = 0 - let platformParapetCount = 0 - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) { - platformFloorCount += 1 - } - if (cell.walls.some(w => !w.hidden && (w.style === 4 || w.style === 2) && w.prop1 === 2)) { - platformParapetCount += 1 - } - } - } - - expect(platformFloorCount).toBeGreaterThan(30) - expect(platformParapetCount).toBeGreaterThan(15) - }) - - test("generates fort ruins interspersed between siege strips", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const stats = result.stats.siegeTopography as SiegeTopographyStats - expect(stats.fortRuins).toBe(4) - - // Scan for fort ruin flagstone floors (style: 1 or 18, prop1: 2) and stone walls (style: 1 or 18, prop1: 129) - let ruinFloorCount = 0 - let ruinWallCount = 0 - - for (let y = 0; y < result.level.height; y += 1) { - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[y]![x]! - if (cell.floors.some(f => (f.style === 1 || f.style === 18) && f.prop1 === 2)) { - ruinFloorCount += 1 - } - if (cell.walls.some(w => !w.hidden && (w.style === 1 || w.style === 18) && w.prop1 === 129)) { - ruinWallCount += 1 - } - } - } - - expect(ruinFloorCount).toBeGreaterThan(40) - expect(ruinWallCount).toBeGreaterThan(15) - }) - - test("enforces progressive frontline fortification density from East (Harrogath) to West (Barricade)", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const stats = result.stats.siegeTopography as SiegeTopographyStats - const { westFrontlineObstacles, midFieldObstacles, eastRearObstacles } = - stats.frontlineDensity - - // West Frontline (Shenk / Arreat Barricade) > Mid Field > East Rear (Harrogath) - expect(westFrontlineObstacles).toBeGreaterThan(midFieldObstacles) - expect(midFieldObstacles).toBeGreaterThanOrEqual(eastRearObstacles) - expect(eastRearObstacles).toBeGreaterThan(0) - - expect(westFrontlineObstacles).toBe(10) - expect(midFieldObstacles).toBe(6) - expect(eastRearObstacles).toBe(3) - }) - - test("preserves traversable central thoroughfare along y=24 from Harrogath exit to Barricade entrance", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - // Walk horizontally across y = 24 from East (x = 239) to West (x = 0) - const yCorridor = 24 - let blockedCells = 0 - - for (let x = 0; x < result.level.width; x += 1) { - const cell = result.level.cells[yCorridor]![x]! - const hasBlockingWall = cell.walls.some( - w => !w.hidden && w.prop1 !== 0, - ) - if (hasBlockingWall) { - blockedCells += 1 - } - } - - // Central line along y = 24 should have 0 blocking walls - expect(blockedCells).toBe(0) - }) - - test("records notes and telemetry in result.stats", () => { - const pieces = createMockSiegePieces() - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 42, - pieces, - substitutions: [], - }) - - const notes = result.stats.notes as string[] - expect(notes).toContain("generated dynamic siege trenches and fortifications") - expect(result.stats.siegeTopography).toBeDefined() - }) - - test("generates varied obstacle layouts with different seeds (seed diversity)", () => { - const pieces = createMockSiegePieces() - const result1 = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 11001, - pieces, - substitutions: [], - }) - - const result2 = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 11002, - pieces, - substitutions: [], - }) - - // Collect obstacle wall coordinates for both seeds - const walls1 = new Set() - const walls2 = new Set() - - for (let y = 0; y < result1.level.height; y += 1) { - for (let x = 0; x < result1.level.width; x += 1) { - if (result1.level.cells[y]![x]!.walls.some(w => !w.hidden && w.prop1 !== 0)) { - walls1.add(`${x},${y}`) - } - if (result2.level.cells[y]![x]!.walls.some(w => !w.hidden && w.prop1 !== 0)) { - walls2.add(`${x},${y}`) - } - } - } - - // Both should have placed walls - expect(walls1.size).toBeGreaterThan(0) - expect(walls2.size).toBeGreaterThan(0) - - // The obstacle coordinates must differ across different seeds - let diffCount = 0 - for (const coord of walls1) { - if (!walls2.has(coord)) diffCount += 1 - } - expect(diffCount).toBeGreaterThan(0) - }) -}) diff --git a/tests/wilderness-spline-roads.test.ts b/tests/wilderness-spline-roads.test.ts deleted file mode 100644 index 3227e97..0000000 --- a/tests/wilderness-spline-roads.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { Rng } from '../src/game/rng.ts' -import { - catmullRomPoint, - sampleCatmullRomSpline, - createDrlgVertices, - DRLGVER_CreateVertices, - buildOutdoorRoadPolylines, - buildRoadNetworkPolylines, - TILES_PER_BLOCK, -} from '../src/game/wilderness.ts' -import type { Act1OutdoorPlan } from '../src/game/wilderness.ts' - -// Act I outdoor levels (2-7, 17, 39) come from the 1:1 D2MOO DRLG port in -// src/game/drlg (verified by tests/drlg-act1-oracle.test.ts); the Act I dirt -// path rasterizer and its end-to-end cases were removed with the invented -// generator. The vertex-graph / spline helpers below are still used by the -// Acts 2-5 wilderness generator. -describe('Control Vertex Graph & Spline Roads (Issue #66)', () => { - describe('Catmull-Rom Spline Mathematics & Sampling', () => { - it('accurately interpolates start and end control points at t=0 and t=1', () => { - const p0 = { x: 0, y: 0 } - const p1 = { x: 10, y: 20 } - const p2 = { x: 30, y: 50 } - const p3 = { x: 60, y: 60 } - - const start = catmullRomPoint(p0, p1, p2, p3, 0) - expect(start.x).toBeCloseTo(p1.x) - expect(start.y).toBeCloseTo(p1.y) - - const end = catmullRomPoint(p0, p1, p2, p3, 1) - expect(end.x).toBeCloseTo(p2.x) - expect(end.y).toBeCloseTo(p2.y) - - const mid = catmullRomPoint(p0, p1, p2, p3, 0.5) - expect(mid.x).toBeGreaterThan(p1.x) - expect(mid.x).toBeLessThan(p2.x) - expect(mid.y).toBeGreaterThan(p1.y) - expect(mid.y).toBeLessThan(p2.y) - }) - - it('generates smooth continuous points along a multi-segment spline', () => { - const controlPoints = [ - { x: 10, y: 10 }, - { x: 25, y: 18 }, - { x: 45, y: 35 }, - { x: 70, y: 65 }, - ] - const bounds = { width: 100, height: 100 } - const sampled = sampleCatmullRomSpline(controlPoints, bounds) - - expect(sampled.length).toBeGreaterThan(controlPoints.length) - expect(sampled[0]).toEqual({ x: 10, y: 10 }) - expect(sampled[sampled.length - 1]).toEqual({ x: 70, y: 65 }) - - // Verify continuity: adjacent points have step <= 2 cells - for (let i = 0; i < sampled.length - 1; i += 1) { - const dx = Math.abs(sampled[i + 1]!.x - sampled[i]!.x) - const dy = Math.abs(sampled[i + 1]!.y - sampled[i]!.y) - expect(Math.max(dx, dy)).toBeLessThanOrEqual(2) - } - }) - - it('exhibits natural non-orthogonal/non-45° curvature with continuous angles', () => { - // S-curve spline - const controlPoints = [ - { x: 10, y: 10 }, - { x: 20, y: 35 }, - { x: 50, y: 40 }, - { x: 60, y: 70 }, - ] - const bounds = { width: 80, height: 80 } - const sampled = sampleCatmullRomSpline(controlPoints, bounds) - - // Calculate slopes / directions between points sampled along the spline - const uniqueAngles = new Set() - for (let i = 0; i < sampled.length - 3; i += 3) { - const dx = sampled[i + 3]!.x - sampled[i]!.x - const dy = sampled[i + 3]!.y - sampled[i]!.y - if (dx !== 0 || dy !== 0) { - const angle = Math.round((Math.atan2(dy, dx) * 180) / Math.PI) - uniqueAngles.add(angle) - } - } - - // In a pure 45°/90° grid path, angles would only be {0, 45, 90, 135, 180, -45, -90, -135}. - // A Catmull-Rom spline curves smoothly with many distinct angles across the S-curve. - expect(uniqueAngles.size).toBeGreaterThanOrEqual(4) - const nonGridAngles = Array.from(uniqueAngles).filter(a => a % 45 !== 0) - expect(nonGridAngles.length).toBeGreaterThan(0) - }) - - it('clamps spline points strictly within bounds', () => { - const controlPoints = [ - { x: -10, y: -5 }, - { x: 20, y: 30 }, - { x: 120, y: 110 }, - ] - const bounds = { width: 80, height: 80 } - const sampled = sampleCatmullRomSpline(controlPoints, bounds) - - for (const pt of sampled) { - expect(pt.x).toBeGreaterThanOrEqual(0) - expect(pt.x).toBeLessThan(80) - expect(pt.y).toBeGreaterThanOrEqual(0) - expect(pt.y).toBeLessThan(80) - } - }) - - it('deflects spline points around obstacle blocks', () => { - const bounds = { width: 80, height: 80 } - const roadObstacleBlocks = Array.from({ length: 10 }, () => - Array.from({ length: 10 }, () => false), - ) - // Mark block (bx=3, by=3) as an obstacle (cells 24..31 in x and y) - roadObstacleBlocks[3]![3] = true - - const controlPoints = [ - { x: 10, y: 28 }, - { x: 45, y: 28 }, - ] - const sampled = sampleCatmullRomSpline(controlPoints, bounds, roadObstacleBlocks) - - // No point should fall inside the obstacle block (bx=3, by=3) - for (const pt of sampled) { - const bx = Math.floor(pt.x / TILES_PER_BLOCK) - const by = Math.floor(pt.y / TILES_PER_BLOCK) - expect(roadObstacleBlocks[by]?.[bx]).toBe(false) - } - }) - }) - - describe('DRLGVER Control Vertex Graph Generation', () => { - it('creates vertices for gates, hub, presets, and inflections with polylines', () => { - const plan: Act1OutdoorPlan = { - openGates: new Map([ - ['top_4', 3], - ['bottom_4', 3], - ['left_4', 3], - ]), - claimedBorderBlocks: new Set(['top_4', 'bottom_4', 'left_4']), - claimedBlocks: Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false)), - roadObstacleBlocks: Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false)), - specialPresets: [ - { - name: 'Den of Evil', - ds1: null as any, - bx: 7, - by: 7, - blocksX: 2, - blocksY: 2, - }, - ], - anchors: [ - { - label: 'North Gate', - anchorCell: { x: 36, y: 0 }, - interiorCell: { x: 36, y: 8 }, - side: 'north', - kind: 'gate', - }, - { - label: 'South Gate', - anchorCell: { x: 36, y: 79 }, - interiorCell: { x: 36, y: 71 }, - side: 'south', - kind: 'gate', - }, - { - label: 'West Gate', - anchorCell: { x: 0, y: 36 }, - interiorCell: { x: 8, y: 36 }, - side: 'west', - kind: 'gate', - }, - { - label: 'Den of Evil Entrance', - anchorCell: { x: 56, y: 56 }, - interiorCell: { x: 56, y: 52 }, - kind: 'preset', - }, - ], - } - - const rng = new Rng(0x1234_5678) - const res = createDrlgVertices(plan, 10, 10, rng) - - expect(res.vertices.length).toBeGreaterThanOrEqual(4) - expect(res.polylines.length).toBe(plan.anchors.length) - expect(res.lines).toBe(res.polylines) - - // Verify vertex types - const types = res.vertices.map(v => v.type) - expect(types).toContain('hub') - expect(types).toContain('gate') - expect(types).toContain('preset') - - const hub = res.vertices.find(v => v.type === 'hub')! - expect(hub).toBeDefined() - expect(hub.x).toBeGreaterThan(0) - expect(hub.y).toBeGreaterThan(0) - - // Verify DRLGVER_CreateVertices alias parity - const aliasRes = DRLGVER_CreateVertices(plan, 10, 10, new Rng(0x1234_5678)) - expect(aliasRes.vertices.length).toBe(res.vertices.length) - expect(aliasRes.polylines.length).toBe(res.polylines.length) - - // Verify buildOutdoorRoadPolylines and buildRoadNetworkPolylines - const polylines = buildOutdoorRoadPolylines(plan, 10, 10, new Rng(0x1234_5678)) - expect(polylines.length).toBe(res.polylines.length) - const aliasPolylines = buildRoadNetworkPolylines(plan, 10, 10, new Rng(0x1234_5678)) - expect(aliasPolylines).toEqual(polylines) - }) - - it('ensures central hub is relocated if primary centroid is blocked by obstacles', () => { - const obstacleBlocks = Array.from({ length: 10 }, () => - Array.from({ length: 10 }, () => false), - ) - // Block centroid block (5, 5) - obstacleBlocks[5]![5] = true - - const plan: Act1OutdoorPlan = { - openGates: new Map(), - claimedBorderBlocks: new Set(), - claimedBlocks: obstacleBlocks, - roadObstacleBlocks: obstacleBlocks, - specialPresets: [], - anchors: [ - { - label: 'West Gate', - anchorCell: { x: 0, y: 40 }, - interiorCell: { x: 8, y: 40 }, - side: 'west', - kind: 'gate', - }, - { - label: 'East Gate', - anchorCell: { x: 79, y: 40 }, - interiorCell: { x: 71, y: 40 }, - side: 'east', - kind: 'gate', - }, - ], - } - - const res = createDrlgVertices(plan, 10, 10, new Rng(42)) - const hub = res.vertices.find(v => v.type === 'hub')! - const hubBx = Math.floor(hub.x / TILES_PER_BLOCK) - const hubBy = Math.floor(hub.y / TILES_PER_BLOCK) - expect(obstacleBlocks[hubBy]![hubBx]).toBe(false) - }) - }) -}) diff --git a/tests/wilderness-superuniques.test.ts b/tests/wilderness-superuniques.test.ts deleted file mode 100644 index c950e66..0000000 --- a/tests/wilderness-superuniques.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - generateWilderness, - buildLandmarkEntitiesFromTables, - spawnLandmarkEntities, - CANONICAL_LANDMARK_ENTITIES, - type LandmarkEntityDef, - type WildernessPiece, -} from "../src/game/wilderness.ts" -import { - planLevelMonsters, - CANONICAL_SUPER_UNIQUES_BY_LEVEL, - CANONICAL_ELITE_MODIFIERS, - lookupSuperUnique, - buildSuperUniqueLandmarkSpecs, - CANONICAL_SUPERUNIQUES_TABLE, -} from "../src/game/monsters.ts" -import { parseTable } from "../src/game/acts.ts" -import type { D2Table } from "../src/game/acts.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function mockTable(tsv: string): D2Table { - const encoder = new TextEncoder() - return parseTable(encoder.encode(tsv.trim())) -} - -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: 1, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => { - describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => { - test("contains all 19 canonical landmark entities across Acts 2-5", () => { - expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(19) - - // Act 1: none. Act I outdoor levels come from the DRLG port (src/game/drlg), whose preset units - // place Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven, the Cow King and Flavie; that is - // checked on the port's output in tests/drlg-act1-population.test.ts. - expect(CANONICAL_LANDMARK_ENTITIES.filter(e => e.act < 2 || e.levelIds.some(id => id < 40))).toEqual([]) - - // Act 2 (5 entities) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dark Elder" && e.levelIds.includes(44))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Creeping Feature" && e.levelIds.includes(59))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bloodwitch the Wild" && e.levelIds.includes(60))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Fangskin" && e.levelIds.includes(61))).toBe(true) - - // Act 3 (4 entities) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Stormtree" && e.levelIds.includes(78))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Sszark the Burning" && e.levelIds.includes(84))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Witch Doctor Endugu" && e.levelIds.includes(91))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Battlemaid Sarina" && e.levelIds.includes(94))).toBe(true) - - // Act 4 (3 entities: 2 SuperUniques + 1 NPC) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Izual" && e.levelIds.includes(105))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hephasto the Armorer" && e.levelIds.includes(107))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hadriel" && e.levelIds.includes(108))).toBe(true) - - // Act 5 (7 entities) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dac Farren" && e.levelIds.includes(110))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Shenk the Overseer" && e.levelIds.includes(110))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Eyeback the Unleashed" && e.levelIds.includes(111))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Thresh Socket" && e.levelIds.includes(112))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Frozenstein" && e.levelIds.includes(114))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bonesaw Breaker" && e.levelIds.includes(115))).toBe(true) - expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Nihlathak" && e.levelIds.includes(124))).toBe(true) - }) - }) - - describe("buildLandmarkEntitiesFromTables", () => { - test("returns canonical definitions when no tables are supplied", () => { - const entities = buildLandmarkEntitiesFromTables() - expect(entities.length).toBe(CANONICAL_LANDMARK_ENTITIES.length) - expect(entities[0].name).toBe(CANONICAL_LANDMARK_ENTITIES[0].name) - }) - - test("overrides boss modifiers and properties from SuperUniques.txt", () => { - // Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast' - const superUniquesTsv = [ - "Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)", - "Beetleburst\tscarab3\t8\t9\t0\t2\t5\tscarab1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A", - "Dark Elder\tzombie4\t17\t6\t0\t3\t6\tzombie1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A", - ].join("\n") - - const suTable = mockTable(superUniquesTsv) - const entities = buildLandmarkEntitiesFromTables(suTable) - - const beetleburst = entities.find(e => e.id === "Beetleburst") - expect(beetleburst).toBeDefined() - expect(beetleburst?.monsterId).toBe("scarab3") - expect(beetleburst?.modifiers).toEqual(["magicresistant", "fireenchant"]) - expect(beetleburst?.minionCount).toEqual([2, 5]) - - const darkElder = entities.find(e => e.id === "Dark Elder") - expect(darkElder).toBeDefined() - expect(darkElder?.monsterId).toBe("zombie4") - expect(darkElder?.modifiers).toEqual(["lightenchant", "fast"]) - expect(darkElder?.minionCount).toEqual([3, 6]) - }) - - test("resolves objectId from MonPreset.txt", () => { - const monPresetTsv = [ - "Act\tPlace\tComponent\tObjectId", - "2\tDesert Oasis 1\t1\t105", - "4\tChaos Sanctuary\t1\t350", - ].join("\n") - - const mpTable = mockTable(monPresetTsv) - const entities = buildLandmarkEntitiesFromTables(undefined, mpTable) - - const beetleburst = entities.find(e => e.name === "Beetleburst") - expect(beetleburst?.objectId).toBe(105) - - const hadriel = entities.find(e => e.name === "Hadriel") - expect(hadriel?.objectId).toBe(350) - }) - }) - - describe("spawnLandmarkEntities in Wilderness Generator", () => { - // Act I SuperUniques (Rakanishu on the Cairn Stones, ...) are preset units of the DRLG port's - // maps; tests/drlg-act1-population.test.ts checks them. The same anchoring for Acts 2-5: - test("places Creeping Feature centered on the Desert Tomb 1 preset piece", () => { - const tombDs1 = makeMockDs1(16, 16) - const borderDs1 = makeMockDs1(8, 8) - const pieces: WildernessPiece[] = [ - { name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }, - { name: "Act 2 - Desert Tomb 1", border: false, levels: [tombDs1] }, - ] - - const result = generateWilderness({ - levelId: 41, - levelName: "Rocky Waste", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 42, - pieces, - substitutions: [], - specialPresets: ["Act 2 - Desert Tomb 1"], - }) - - expect(result.stats.landmarkEntities).toBeDefined() - const creeping = result.stats.landmarkEntities?.find(e => e.name === "Creeping Feature") - expect(creeping).toBeDefined() - expect(creeping?.levelId).toBe(41) - - // Verify subtile coordinates: tileX * 5 + 2 - expect(creeping?.subtileX).toBe(creeping!.tileX * 5 + 2) - expect(creeping?.subtileY).toBe(creeping!.tileY * 5 + 2) - - // Verify canvas.objects contains the Type 1 entity - const obj = result.level.objects.find( - o => o.type === 1 && o.x === creeping!.subtileX && o.y === creeping!.subtileY - ) - expect(obj).toBeDefined() - expect(obj?.id).toBe(creeping?.objectId) - }) - - test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => { - const stripDs1 = makeMockDs1(16, 48) - const pieces: WildernessPiece[] = [ - { name: "Act 5 - Siege To Barricade", border: false, levels: [stripDs1] }, - ] - - const result = generateWilderness({ - levelId: 110, - levelName: "Bloody Foothills", - levelTypeName: "Act 5 - Siege", - sizeX: 240, - sizeY: 48, - subType: -1, - subTheme: -1, - seed: 110, - pieces, - substitutions: [], - }) - - const landmarks = result.stats.landmarkEntities ?? [] - const dacFarren = landmarks.find(e => e.name === "Dac Farren") - const shenk = landmarks.find(e => e.name === "Shenk the Overseer") - - expect(dacFarren).toBeDefined() - expect(shenk).toBeDefined() - expect(dacFarren?.objectId).toBe(87) - expect(shenk?.objectId).toBe(88) - - // Both must be recorded in canvas.objects as type 1 - const dacObj = result.level.objects.find( - o => o.type === 1 && o.x === dacFarren!.subtileX && o.y === dacFarren!.subtileY - ) - const shenkObj = result.level.objects.find( - o => o.type === 1 && o.x === shenk!.subtileX && o.y === shenk!.subtileY - ) - expect(dacObj).toBeDefined() - expect(shenkObj).toBeDefined() - }) - - test("places Act 4 Hadriel on Chaos Sanctuary (Level 108)", () => { - const csDs1 = makeMockDs1(25, 25) - const pieces: WildernessPiece[] = [ - { name: "Act 4 - Diablo Entry", border: false, levels: [csDs1] }, - ] - - const result = generateWilderness({ - levelId: 108, - levelName: "Chaos Sanctuary", - levelTypeName: "Act 4 - Lava", - sizeX: 120, - sizeY: 120, - subType: -1, - subTheme: -1, - seed: 108, - pieces, - substitutions: [], - }) - - const hadriel = result.stats.landmarkEntities?.find(e => e.name === "Hadriel") - expect(hadriel).toBeDefined() - expect(hadriel?.objectId).toBe(86) - expect(hadriel?.role).toBe("npc_guard") - - const obj = result.level.objects.find( - o => o.type === 1 && o.x === hadriel!.subtileX && o.y === hadriel!.subtileY - ) - expect(obj).toBeDefined() - expect(obj?.id).toBe(86) - }) - - test("supports customLandmarks override in WildernessRequest", () => { - const borderDs1 = makeMockDs1(8, 8) - const customLandmarks: LandmarkEntityDef[] = [ - { - id: "CustomBoss", - name: "Test Custom Boss", - role: "superunique", - act: 2, - levelIds: [42], - monsterId: "fallen1", - defaultLandmark: "Custom Landmark", - modifiers: ["lightenchant", "fast"], - minionCount: [4, 6], - minionMonsterId: "fallen1", - objectId: 999, - defaultRelativePos: { x: 0.5, y: 0.5 }, - }, - ] - - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 123, - pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }], - substitutions: [], - customLandmarks, - }) - - expect(result.stats.landmarkEntities?.length).toBe(1) - const custom = result.stats.landmarkEntities?.[0] - expect(custom?.name).toBe("Test Custom Boss") - expect(custom?.tileX).toBe(40) - expect(custom?.tileY).toBe(40) - expect(custom?.objectId).toBe(999) - }) - - test("falls back to relative canvas coordinates when preset pieces are absent", () => { - const borderDs1 = makeMockDs1(8, 8) - const result = generateWilderness({ - levelId: 43, // Far Oasis (Beetleburst) - levelName: "Far Oasis", - levelTypeName: "Act 2 - Desert", - sizeX: 100, - sizeY: 100, - subType: 6, - subTheme: 0, - seed: 99, - pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }], - substitutions: [], - }) - - const beetleburst = result.stats.landmarkEntities?.find(e => e.name === "Beetleburst") - expect(beetleburst).toBeDefined() - expect(beetleburst?.tileX).toBeGreaterThanOrEqual(1) - expect(beetleburst?.tileX).toBeLessThan(99) - expect(beetleburst?.tileY).toBeGreaterThanOrEqual(1) - expect(beetleburst?.tileY).toBeLessThan(99) - }) - }) - - describe("Integration with planLevelMonsters (src/game/monsters.ts)", () => { - test("Aura Enchanted modifier exists in CANONICAL_ELITE_MODIFIERS", () => { - const aura = CANONICAL_ELITE_MODIFIERS.find(m => m.name === "aura") - expect(aura).toBeDefined() - expect(aura?.label).toBe("Aura Enchanted") - expect(aura?.id).toBe(30) - }) - - test("CANONICAL_SUPER_UNIQUES_BY_LEVEL maps all 5 Acts correctly", () => { - // Act 1 - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[3]).toBeDefined() // Bishibosh - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[4]).toBeDefined() // Rakanishu - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[5]).toBeDefined() // Treehead WoodFist - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[17]).toBeDefined() // Blood Raven - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[25]).toBeDefined() // Countess - - // Act 2 - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[43]).toBeDefined() // Beetleburst - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[44]).toBeDefined() // Dark Elder - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[59]).toBeDefined() // Creeping Feature - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[60]).toBeDefined() // Bloodwitch - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[61]).toBeDefined() // Fangskin - - // Act 3 - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[76]).toBeDefined() // Sszark - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[78]).toBeDefined() // Stormtree & Endugu - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[84]).toBeDefined() // Sszark - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[88]).toBeDefined() // Endugu - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[91]).toBeDefined() // Endugu - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[94]).toBeDefined() // Sarina - - // Act 4 - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[105]).toBeDefined() // Izual - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[107]).toBeDefined() // Hephasto - - // Act 5 - const act5Bloody = CANONICAL_SUPER_UNIQUES_BY_LEVEL[110] // Dac Farren & Shenk - expect(Array.isArray(act5Bloody)).toBe(true) - expect((act5Bloody as readonly unknown[]).length).toBe(2) - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[111]).toBeDefined() // Eyeback - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[112]).toBeDefined() // Thresh Socket - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[114]).toBeDefined() // Frozenstein - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[115]).toBeDefined() // Bonesaw - expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[124]).toBeDefined() // Nihlathak - }) - - test("planLevelMonsters creates deterministic boss packs for multi-boss level 110", () => { - const levelsTxt = mockTable([ - "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", - "110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1", - ].join("\n")) - const monstatsTxt = mockTable([ - "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", - "imp1\timp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1", - "demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1", - "overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1", - "siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1", - ].join("\n")) - const monlvlTxt = mockTable([ - "Level\tAC\tTH\tHP\tDM\tXP", - "35\t100\t100\t100\t100\t100", - ].join("\n")) - const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } - - const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, "normal") - - expect(plan.superUniques).toContain("Dac Farren") - expect(plan.superUniques).toContain("Shenk the Overseer") - - const dacPack = plan.packs.find(p => p.superUniqueId === "Dac Farren") - const shenkPack = plan.packs.find(p => p.superUniqueId === "Shenk the Overseer") - - expect(dacPack).toBeDefined() - expect(shenkPack).toBeDefined() - expect(dacPack?.members[0]?.rank).toBe("unique") - expect(shenkPack?.members[0]?.rank).toBe("unique") - expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true) - expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true) - }) - - test("lookupSuperUnique retrieves canonical superuniques by id or name", () => { - const bishibosh = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "Bishibosh") - expect(bishibosh).toBeDefined() - expect(bishibosh?.monsterId).toBe("fallenshaman1") - expect(bishibosh?.minMinions).toBe(5) - expect(bishibosh?.maxMinions).toBe(7) - - const caseInsensitive = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "rakanishu") - expect(caseInsensitive).toBeDefined() - expect(caseInsensitive?.id).toBe("Rakanishu") - expect(caseInsensitive?.monsterId).toBe("fallen1") - - expect(lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "nonexistent")).toBeUndefined() - }) - - test("buildSuperUniqueLandmarkSpecs builds specs from custom SuperUniques table", () => { - const customSuTable = mockTable([ - "Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)", - "Bishibosh\tBishibosh Custom\tfallenshaman2\t6\t28\t0\t8\t10\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A", - ].join("\n")) - - const specsByLevel = buildSuperUniqueLandmarkSpecs(customSuTable) - const bishiboshSpec = specsByLevel[3] - expect(bishiboshSpec).toBeDefined() - expect(Array.isArray(bishiboshSpec)).toBe(false) - const spec = bishiboshSpec as import("../src/game/monsters.ts").SuperUniqueLandmarkSpec - expect(spec.name).toBe("Bishibosh Custom") - expect(spec.monsterId).toBe("fallenshaman2") - expect(spec.minMinions).toBe(8) - expect(spec.maxMinions).toBe(10) - expect(spec.modifiers).toEqual(["fast", "stoneskin"]) - }) - - test("planLevelMonsters skips boss without falling back to fallen1 when boss monster is missing", () => { - const levelsTxt = mockTable([ - "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", - "4\tStony Field\t600\t2\t4\t7\tzombie1\tquillrat1\tbrute1", - ].join("\n")) - // Only define zombie1 in monstats - Rakanishu is fallen1, which is absent - const monstatsTxt = mockTable([ - "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", - "zombie1\tzombie1\tZombie\t7\t1\t2\t4\t6\t10\t20\t10\t20\t2\t5\t20\t1\t1\t1\t1", - ].join("\n")) - const monlvlTxt = mockTable([ - "Level\tAC\tTH\tHP\tDM\tXP", - "7\t100\t100\t100\t100\t100", - ].join("\n")) - const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } - - const plan = planLevelMonsters(tables, 4, 6400, 12345, 3.5, "normal") - // Rakanishu should NOT be spawned as zombie1 or fallen1 (fallen1 fallback was removed) - expect(plan.superUniques?.includes("Rakanishu")).toBeFalsy() - const rakanishuPack = plan.packs.find(p => p.superUniqueId === "Rakanishu") - expect(rakanishuPack).toBeUndefined() - }) - - test("planLevelMonsters uses tables.superuniques when provided", () => { - const levelsTxt = mockTable([ - "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", - "3\tCold Plains\t600\t2\t4\t5\tfallenshaman1\tfallen1\tzombie1", - ].join("\n")) - const monstatsTxt = mockTable([ - "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", - "fallenshaman1\tfallenshaman1\tFallen Shaman\t5\t1\t1\t6\t9\t10\t20\t15\t25\t2\t4\t20\t1\t1\t1\t1", - "fallen1\tfallen1\tFallen\t5\t3\t5\t8\t12\t5\t10\t5\t10\t1\t2\t15\t1\t1\t1\t1", - ].join("\n")) - const monlvlTxt = mockTable([ - "Level\tAC\tTH\tHP\tDM\tXP", - "5\t100\t100\t100\t100\t100", - ].join("\n")) - const customSuTable = mockTable([ - "Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)", - "Bishibosh\tBishibosh Ultra\tfallenshaman1\t6\t0\t0\t3\t3\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A", - ].join("\n")) - - const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt, superuniques: customSuTable } - const plan = planLevelMonsters(tables, 3, 6400, 54321, 3.5, "normal") - - expect(plan.superUniques).toContain("Bishibosh") - const bishiboshPack = plan.packs.find(p => p.superUniqueId === "Bishibosh") - expect(bishiboshPack).toBeDefined() - expect(bishiboshPack?.members[0]?.name).toBe("Bishibosh Ultra") - expect(bishiboshPack?.members[0]?.modifiers).toEqual(["fast"]) - // 1 boss + 3 minions = 4 members - expect(bishiboshPack?.members.length).toBe(4) - }) - }) -}) diff --git a/tests/wilderness-telemetry.test.ts b/tests/wilderness-telemetry.test.ts deleted file mode 100644 index e823832..0000000 --- a/tests/wilderness-telemetry.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * 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: [], - proceduralRoadCells: 0, - proceduralDividerCells: 0, - proceduralWaterCells: 0, - proceduralWallCells: 0, - } -} - -/** 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: 42, - levelName: 'Dry Hills', - levelTypeName: 'Act 2 - Desert', - sizeX: 64, - sizeY: 64, - subType: 1, - subTheme: 0, - seed: 2001, - pieces: makeMockBorderPieces('Act 2'), - 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) - }) -}) - -// Act I outdoor levels (and their dirt paths: DRLGOUTDOORS_SpawnAct1DirtPaths) come from the DRLG -// port (src/game/drlg), so the telemetry below covers the procedural passes of Acts 2-5. -describe('Procedural terrain pass telemetry tracks real cell counts (Issue #62 & #65)', () => { - test('Act 2 desert tracks procedural roads, water (oasis), and walls (canyon + berms)', () => { - const rocky = generateWilderness({ - levelId: 41, - levelName: 'Rocky Waste', - levelTypeName: 'Act 2 - Desert', - sizeX: 64, - sizeY: 64, - subType: 0, - subTheme: 0, - seed: 41001, - pieces: makeMockBorderPieces('Act 2'), - substitutions: [], - }) - - expect(rocky.stats.proceduralRoadCells).toBeGreaterThan(0) - expect(rocky.stats.proceduralWallCells).toBeGreaterThan(0) - - const oasis = generateWilderness({ - levelId: 43, - levelName: 'Far Oasis', - levelTypeName: 'Act 2 - Desert', - sizeX: 64, - sizeY: 64, - subType: 0, - subTheme: 0, - seed: 43001, - pieces: makeMockBorderPieces('Act 2'), - substitutions: [], - }) - - expect(oasis.stats.proceduralRoadCells).toBeGreaterThan(0) - expect(oasis.stats.proceduralWaterCells).toBeGreaterThan(0) - }) - - test('Act 3 Kurast Causeway tracks causeway, canal, and balustrade cells', () => { - const result = generateWilderness({ - levelId: 82, - levelName: 'Kurast Causeway', - levelTypeName: 'Act 3 - Kurast', - sizeX: 32, - sizeY: 64, - subType: 0, - subTheme: 0, - seed: 82001, - pieces: [{ name: 'Act 3 - Bridge', border: false, levels: [makeMockDs1(16, 16)] }], - substitutions: [], - }) - - expect(result.stats.proceduralRoadCells).toBeGreaterThan(0) - expect(result.stats.proceduralWaterCells).toBeGreaterThan(0) - expect(result.stats.proceduralWallCells).toBeGreaterThan(0) - }) - - test('Act 5 Siege levels track barricade walls, trenches, and frozen lakes', () => { - const result = generateWilderness({ - levelId: 112, - levelName: 'Arreat Plateau', - levelTypeName: 'Act 5 - Barricade', - sizeX: 80, - sizeY: 80, - subType: 0, - subTheme: 0, - seed: 112001, - pieces: makeMockBorderPieces('Act 5'), - substitutions: [], - }) - - expect(result.stats.proceduralWallCells).toBeGreaterThan(0) - expect(result.stats.proceduralRoadCells).toBeGreaterThan(0) - expect(result.stats.proceduralWaterCells).toBeGreaterThan(0) - }) -}) - - diff --git a/tests/wilderness-waypoints-shrines.test.ts b/tests/wilderness-waypoints-shrines.test.ts deleted file mode 100644 index d8ec38e..0000000 --- a/tests/wilderness-waypoints-shrines.test.ts +++ /dev/null @@ -1,734 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - generateWilderness, - OUTDOOR_WAYPOINT_LEVELS, - actOfLevel, - hasOutdoorWaypoint, - ACT_POPULATION_CONFIG, - createCanvas, - DRLGOUTDOORS_SpawnAct12Waypoint, - DRLGOUTDOORS_SpawnAct12Shrines, - spawnOutdoorWaypoint, - spawnOutdoorShrines, - type Canvas, - type PlacedPresetRect, - type WildernessPiece, - type WildernessStats, - type WildernessSubstitution, -} from "../src/game/wilderness.ts" -import { - actOfLevel as actsActOfLevel, - hasOutdoorWaypoint as actsHasOutdoorWaypoint, - type D2Table, -} from "../src/game/acts.ts" -import { Rng } from "../src/game/rng.ts" -import { - findWaypointSpot, - type LinkGrid, -} from "../src/game/level-links.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - let isWall: boolean - if (isOpen) { - // Clearings have open interior floor with perimeter walls - isWall = (x === 0 || y === 0 || y === height - 1 || (x === width - 1 && !hasGapInWalls)) - } else { - isWall = !(hasGapInWalls && x === Math.floor(width / 2)) - } - row.push({ - walls: isWall ? [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [], - floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function makeGenericOutdoorPieces(prefix: string, landmarkName?: string): WildernessPiece[] { - const pieces: WildernessPiece[] = [] - for (let i = 1; i <= 12; i += 1) { - pieces.push({ - name: `${prefix} Border ${i}`, - border: true, - levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)], - }) - } - pieces.push({ name: `${prefix} Town Transition East`, border: true, levels: [makeMockDs1(8, 8, true)] }) - pieces.push({ name: `${prefix} Town Transition South`, border: true, levels: [makeMockDs1(8, 8, true)] }) - if (landmarkName) { - pieces.push({ name: landmarkName, border: false, levels: [makeMockDs1(8, 8, true, true)] }) - } - return pieces -} - -function makeAct3JunglePieces(): WildernessPiece[] { - const pieces: WildernessPiece[] = [ - { name: "Act 3 - Jungle Tail", border: false, levels: [makeMockDs1(32, 32, true, true)] }, - { name: "Act 3 - Jungle Head", border: false, levels: [makeMockDs1(32, 32, true, true)] }, - { name: "Act 3 - Clearing Pygmy E", border: false, levels: [makeMockDs1(32, 32, true, true)] }, - { name: "Act 3 - Clearing Webby E", border: false, levels: [makeMockDs1(32, 32, true, true)] }, - { name: "Act 3 - Clearing Boggy E", border: false, levels: [makeMockDs1(32, 32, true, true)] }, - { name: "Act 3 - Jungle NS W", border: false, levels: [makeMockDs1(32, 32, true, false)] }, - ] - return pieces -} - -describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () => { - test("actOfLevel correctly identifies acts from levelId or levelTypeName", () => { - expect(actOfLevel(3, "Act 1 - Wilderness")).toBe(1) - expect(actOfLevel(43, "Act 2 - Desert")).toBe(2) - expect(actOfLevel(78, "Act 3 - Jungle")).toBe(3) - expect(actOfLevel(106, "Act 4 - Mesa")).toBe(4) - expect(actOfLevel(112, "Act 5 - Barricade")).toBe(5) - - // Falls back to numeric levelId ranges when levelTypeName is generic - expect(actOfLevel(4, undefined)).toBe(1) - expect(actOfLevel(42, undefined)).toBe(2) - expect(actOfLevel(76, undefined)).toBe(3) - expect(actOfLevel(107, undefined)).toBe(4) - expect(actOfLevel(117, undefined)).toBe(5) - }) - - test("actOfLevel resolves Act column from Levels.txt table when provided", () => { - const mockLevelsTable: D2Table = { - header: ["Id", "Name", "Act"], - rows: [ - ["2", "Blood Moor", "0"], - ["42", "Dry Hills", "1"], - ["76", "Spider Forest", "2"], - ["106", "City of the Damned", "3"], - ["112", "Arreat Plateau", "0"], // Expansion Act 5 row stores 0 in blizzard data - ], - } - - expect(actOfLevel(2, undefined, undefined, mockLevelsTable)).toBe(1) - expect(actOfLevel(42, undefined, undefined, mockLevelsTable)).toBe(2) - expect(actOfLevel(76, undefined, undefined, mockLevelsTable)).toBe(3) - expect(actOfLevel(106, undefined, undefined, mockLevelsTable)).toBe(4) - expect(actOfLevel(112, undefined, undefined, mockLevelsTable)).toBe(5) - - // Re-export from acts.ts is identical - expect(actsActOfLevel).toBe(actOfLevel) - expect(actsHasOutdoorWaypoint).toBe(hasOutdoorWaypoint) - }) - - test("hasOutdoorWaypoint reads Levels.txt Waypoint column dynamically", () => { - const mockLevelsTable: D2Table = { - header: ["Id", "Name", "Act", "DrlgType", "Waypoint"], - rows: [ - ["1", "Rogue Encampment", "0", "1", "0"], // Town level -> false for outdoor waypoint - ["2", "Blood Moor", "0", "3", "255"], // Outdoor, no waypoint (255) -> false - ["3", "Cold Plains", "0", "3", "1"], // Outdoor, waypoint (1) -> true - ["40", "Lut Gholein", "1", "1", "2"], // Town level -> false - ["43", "Far Oasis", "1", "3", "3"], // Outdoor, waypoint -> true - ["200", "Custom Field", "0", "3", "4"], // Custom outdoor level with waypoint -> true - ["201", "Custom Field No WP", "0", "3", "255"], // Custom outdoor level without waypoint -> false - ], - } - - // Dynamic resolution via table - expect(hasOutdoorWaypoint(3, mockLevelsTable)).toBe(true) - expect(hasOutdoorWaypoint(2, mockLevelsTable)).toBe(false) - expect(hasOutdoorWaypoint(1, mockLevelsTable)).toBe(false) // Town - expect(hasOutdoorWaypoint(40, mockLevelsTable)).toBe(false) // Town - expect(hasOutdoorWaypoint(43, mockLevelsTable)).toBe(true) - expect(hasOutdoorWaypoint(200, mockLevelsTable)).toBe(true) - expect(hasOutdoorWaypoint(201, mockLevelsTable)).toBe(false) - - // Fallback when no table provided - expect(hasOutdoorWaypoint(3)).toBe(true) - expect(hasOutdoorWaypoint(2)).toBe(false) - expect(hasOutdoorWaypoint(43)).toBe(true) - expect(hasOutdoorWaypoint(41)).toBe(false) - }) - - test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => { - // Act 1 - expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains - expect(OUTDOOR_WAYPOINT_LEVELS.has(4)).toBe(true) // Stony Field - expect(OUTDOOR_WAYPOINT_LEVELS.has(5)).toBe(true) // Dark Wood - expect(OUTDOOR_WAYPOINT_LEVELS.has(6)).toBe(true) // Black Marsh - expect(OUTDOOR_WAYPOINT_LEVELS.has(2)).toBe(false) // Blood Moor (no WP) - - // Act 2 - expect(OUTDOOR_WAYPOINT_LEVELS.has(42)).toBe(true) // Dry Hills - expect(OUTDOOR_WAYPOINT_LEVELS.has(43)).toBe(true) // Far Oasis - expect(OUTDOOR_WAYPOINT_LEVELS.has(44)).toBe(true) // Lost City - expect(OUTDOOR_WAYPOINT_LEVELS.has(46)).toBe(true) // Canyon of the Magi - expect(OUTDOOR_WAYPOINT_LEVELS.has(41)).toBe(false) // Rocky Waste (no WP) - - // Act 3 - expect(OUTDOOR_WAYPOINT_LEVELS.has(76)).toBe(true) // Spider Forest - expect(OUTDOOR_WAYPOINT_LEVELS.has(77)).toBe(true) // Great Marsh - expect(OUTDOOR_WAYPOINT_LEVELS.has(78)).toBe(true) // Flayer Jungle - expect(OUTDOOR_WAYPOINT_LEVELS.has(79)).toBe(true) // Lower Kurast - expect(OUTDOOR_WAYPOINT_LEVELS.has(80)).toBe(true) // Kurast Bazaar - expect(OUTDOOR_WAYPOINT_LEVELS.has(81)).toBe(true) // Upper Kurast - expect(OUTDOOR_WAYPOINT_LEVELS.has(83)).toBe(true) // Travincal - - // Act 4 - expect(OUTDOOR_WAYPOINT_LEVELS.has(106)).toBe(true) // City of the Damned - expect(OUTDOOR_WAYPOINT_LEVELS.has(107)).toBe(true) // River of Flame - expect(OUTDOOR_WAYPOINT_LEVELS.has(104)).toBe(false) // Outer Steppes (no WP) - - // Act 5 - expect(OUTDOOR_WAYPOINT_LEVELS.has(111)).toBe(true) // Frigid Highlands - expect(OUTDOOR_WAYPOINT_LEVELS.has(112)).toBe(true) // Arreat Plateau - expect(OUTDOOR_WAYPOINT_LEVELS.has(117)).toBe(true) // Frozen Tundra - expect(OUTDOOR_WAYPOINT_LEVELS.has(110)).toBe(false) // Bloody Foothills (no WP) - }) - - test("Act 2 roadless maps (Far Oasis 43, Lost City 44) spawn Waypoint id 10 near oasis/ruins or hub", () => { - const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Ruin 1") - const result = generateWilderness({ - levelId: 43, - levelName: "Far Oasis", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 8, - subTheme: 0, - seed: 0x434343, - pieces, - substitutions: [], - }) - - expect(result.stats.waypointsSpawned).toBe(1) - expect(result.stats.waypointTile).toBeDefined() - expect(result.stats.waypointTile).not.toBeNull() - - // Waypoint object for Act 2 is ID 10 - const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 10) - expect(wpObj).toBeDefined() - - // Shrines in Act 2 are from Act 2 shrine pool (54, 55, 103, 104, 105, 126) - const act2ShrinePool = new Set(ACT_POPULATION_CONFIG[2].shrinePool) - const shrineObjs = result.level.objects.filter(o => o.type === 2 && act2ShrinePool.has(o.id)) - expect(shrineObjs.length).toBeGreaterThanOrEqual(1) - - // Well for Act 2 is ID 234 - const wellObj = result.level.objects.find(o => o.type === 2 && o.id === 234) - expect(wellObj).toBeDefined() - }) - - test("Act 2 Rocky Waste (level 41) does not spawn waypoint", () => { - const pieces = makeGenericOutdoorPieces("Act 2 - Desert") - const result = generateWilderness({ - levelId: 41, - levelName: "Rocky Waste", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 8, - subTheme: 0, - seed: 0x414141, - pieces, - substitutions: [], - }) - - expect(result.stats.waypointsSpawned).toBe(0) - expect(result.stats.waypointTile).toBeNull() - }) - - test("Act 3 Flayer Jungle (level 78) spawns Waypoint id 1 and Act 3 shrines in jungle clearings", () => { - const pieces = makeAct3JunglePieces() - const result = generateWilderness({ - levelId: 78, - levelName: "Flayer Jungle", - levelTypeName: "Act 3 - Jungle", - sizeX: 64, - sizeY: 192, - subType: 10, - subTheme: 0, - seed: 0x787878, - pieces, - substitutions: [], - }) - - expect(result.stats.waypointsSpawned).toBe(1) - expect(result.stats.waypointTile).toBeDefined() - expect(result.stats.waypointTile).not.toBeNull() - - // Waypoint object for Act 3 is ID 1 - const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1) - expect(wpObj).toBeDefined() - - // Waypoint is inside the level bounds - const wpTile = result.stats.waypointTile as { x: number; y: number } - expect(wpTile.x).toBeGreaterThan(0) - expect(wpTile.x).toBeLessThan(64) - - // Act 3 shrines spawned - const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool) - const shrineObjs = result.level.objects.filter(o => o.type === 2 && act3ShrinePool.has(o.id)) - expect(shrineObjs.length).toBeGreaterThanOrEqual(1) - }) - - test("Act 4 City of the Damned (level 106) spawns Waypoint id 0 and Act 4 shrines", () => { - const pieces = makeGenericOutdoorPieces("Act 4 - Mesa") - const result = generateWilderness({ - levelId: 106, - levelName: "City of the Damned", - levelTypeName: "Act 4 - Mesa", - sizeX: 80, - sizeY: 80, - subType: 12, - subTheme: 0, - seed: 0x106106, - pieces, - substitutions: [], - }) - - expect(result.stats.waypointsSpawned).toBe(1) - expect(result.stats.waypointTile).toBeDefined() - - // Act 4 waypoint id is 0 - const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 0) - expect(wpObj).toBeDefined() - - // Outer Steppes (104) has no waypoint - const steppesResult = generateWilderness({ - levelId: 104, - levelName: "Outer Steppes", - levelTypeName: "Act 4 - Mesa", - sizeX: 80, - sizeY: 80, - subType: 12, - subTheme: 0, - seed: 0x104104, - pieces, - substitutions: [], - }) - expect(steppesResult.stats.waypointsSpawned).toBe(0) - expect(steppesResult.stats.waypointTile).toBeNull() - }) - - test("Act 5 Arreat Plateau (level 112) spawns Waypoint id 12 near ruins or central hub", () => { - const pieces = makeGenericOutdoorPieces("Act 5 - Barricade", "Act 5 - Ruins 1") - const result = generateWilderness({ - levelId: 112, - levelName: "Arreat Plateau", - levelTypeName: "Act 5 - Barricade", - sizeX: 80, - sizeY: 80, - subType: 14, - subTheme: 0, - seed: 0x112112, - pieces, - substitutions: [], - }) - - expect(result.stats.waypointsSpawned).toBe(1) - expect(result.stats.waypointTile).toBeDefined() - - // Act 5 waypoint id is 12 - const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 12) - expect(wpObj).toBeDefined() - - // Act 5 shrines spawned - const act5ShrinePool = new Set(ACT_POPULATION_CONFIG[5].shrinePool) - const shrineObjs = result.level.objects.filter(o => o.type === 2 && act5ShrinePool.has(o.id)) - expect(shrineObjs.length).toBeGreaterThanOrEqual(1) - }) - - test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => { - const pieces = makeGenericOutdoorPieces("Act 2 - Desert") - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 96, - sizeY: 96, - subType: 6, - subTheme: 0, - seed: 0x8899aabb, - pieces, - substitutions: [], - }) - - const shrineIds = new Set(ACT_POPULATION_CONFIG[2].shrinePool) - const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id)) - - // If multiple shrines are spawned, check pairwise distance >= 7 - if (shrines.length >= 2) { - for (let i = 0; i < shrines.length; i += 1) { - for (let j = i + 1; j < shrines.length; j += 1) { - const s1 = shrines[i]! - const s2 = shrines[j]! - const dist = Math.hypot( - Math.floor(s1.x / 5) - Math.floor(s2.x / 5), - Math.floor(s1.y / 5) - Math.floor(s2.y / 5), - ) - expect(dist).toBeGreaterThanOrEqual(7) - } - } - } - }) - - test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => { - const pieces = makeGenericOutdoorPieces("Act 2 - Desert") - // Use multiple seeds to observe companion spawning (60% probability) - let foundClusteredCompanion = false - const act2ChestsAndScatter = new Set([ - ...ACT_POPULATION_CONFIG[2].chestPool, - ...ACT_POPULATION_CONFIG[2].scatterPool, - ]) - const act2Shrines = new Set(ACT_POPULATION_CONFIG[2].shrinePool) - - for (let s = 0; s < 10; s += 1) { - const result = generateWilderness({ - levelId: 42, - levelName: "Dry Hills", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 6, - subTheme: 0, - seed: 0x5000 + s * 17, - pieces, - substitutions: [], - }) - - const shrines = result.level.objects.filter(o => o.type === 2 && act2Shrines.has(o.id)) - const companions = result.level.objects.filter(o => o.type === 2 && act2ChestsAndScatter.has(o.id)) - - for (const shrine of shrines) { - const sx = Math.floor(shrine.x / 5) - const sy = Math.floor(shrine.y / 5) - for (const comp of companions) { - const cx = Math.floor(comp.x / 5) - const cy = Math.floor(comp.y / 5) - const dist = Math.hypot(cx - sx, cy - sy) - if (dist >= 1.9 && dist <= 4.5) { - foundClusteredCompanion = true - break - } - } - if (foundClusteredCompanion) break - } - if (foundClusteredCompanion) break - } - - expect(foundClusteredCompanion).toBe(true) - }) - - test("shrineSubstitutions scales shrine count based on LvlSub max", () => { - const pieces = makeGenericOutdoorPieces("Act 2 - Desert") - const mockShrineSub: WildernessSubstitution = { - name: "Act 2 - Shrines", - type: 8, - gridSize: 1, - bordType: 0, - dt1Mask: 0, - prob: [100, 100, 100, 100, 100], - trials: [1, 1, 1, 1, 1], - max: [4, 4, 4, 4, 4], - levels: [], - } - - const result = generateWilderness({ - levelId: 43, - levelName: "Far Oasis", - levelTypeName: "Act 2 - Desert", - sizeX: 96, - sizeY: 96, - subType: 8, - subTheme: 0, - seed: 0x777777, - pieces, - substitutions: [], - shrineSubstitutions: [mockShrineSub], - }) - - // With max = 4 plus healing well, shrinesSpawned should scale up (>= 2) - expect(Number(result.stats.shrinesSpawned)).toBeGreaterThanOrEqual(2) - }) - - test("Determinism: same seed produces identical waypoint and shrine layouts", () => { - const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Oasis 1") - const seed = 0xabcdef12 - - const run1 = generateWilderness({ - levelId: 43, - levelName: "Far Oasis", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 8, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - - const run2 = generateWilderness({ - levelId: 43, - levelName: "Far Oasis", - levelTypeName: "Act 2 - Desert", - sizeX: 80, - sizeY: 80, - subType: 8, - subTheme: 0, - seed, - pieces, - substitutions: [], - }) - - expect(run1.stats.waypointsSpawned).toBe(run2.stats.waypointsSpawned) - expect(run1.stats.waypointTile).toEqual(run2.stats.waypointTile) - expect(run1.stats.shrinesSpawned).toBe(run2.stats.shrinesSpawned) - expect(run1.stats.interactivesSpawned).toBe(run2.stats.interactivesSpawned) - expect(run1.level.objects).toEqual(run2.level.objects) - }) -}) - -describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Topological Placement (Issue #68)", () => { - function makeOutdoorCanvas(width: number, height: number): Canvas { - 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: 0, - unknown1: 0, - unknown2: 0, - hidden: false, - }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - width, - height, - cells, - wallLayers: 1, - floorLayers: 1, - substitutionLayers: 1, - objects: [], - } - } - - function makeEmptyStats(): WildernessStats { - return { - substitutions: [], - borderPieces: {}, - unresolved: [], - notes: [], - borderStamped: 0, - groundCells: 0, - groundTile: null, - sizeSource: "default", - roadCells: 0, - roadSegments: 0, - anchors: 0, - specialPresets: [], - entrances: [], - waypointsSpawned: 0, - shrinesSpawned: 0, - interactivesSpawned: 0, - } - } - - test("Priority 1: DRLGOUTDOORS_SpawnAct12Waypoint prioritizes preset waypoint objects over roadside and campsite", () => { - const canvas = makeOutdoorCanvas(48, 48) - // Pre-place a waypoint object at (12, 12) (tile 12 => x = 60, y = 60) - canvas.objects.push({ - type: 2, - id: 37, // Act 1 waypoint id - x: 12 * 5 + 2, - y: 12 * 5 + 2, - flags: 0, - }) - - // Also supply a dirt path road down x = 24 - const dirtPathGrid = new Uint8Array(48 * 48) - for (let y = 0; y < 48; y += 1) { - dirtPathGrid[y * 48 + 24] = 1 - } - - // Also place a campsite preset at (36, 36) - const placedRects: PlacedPresetRect[] = [ - { name: "Act 1 - Camp", x: 36, y: 36, w: 8, h: 8 }, - ] - - const stats = makeEmptyStats() - const rng = new Rng(0x1234) - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toEqual({ x: 12, y: 12 }) - }) - - test("Branch A: DRLGOUTDOORS_SpawnAct12Waypoint uses Cold Plains special placement (0 RNG rolls)", () => { - const canvas = makeOutdoorCanvas(64, 64) - const placedRects: PlacedPresetRect[] = [] - const stats = makeEmptyStats() - const rng = new Rng(0x5678) - const initialSeed = rng.seed - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, undefined, placedRects, rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - // Cold Plains special consumes exactly 0 RNG rolls - expect(rng.seed).toBe(initialSeed) - }) - - test("Branch B: DRLGOUTDOORS_SpawnAct12Waypoint uses 8x8 cell grid placement avoiding preset obstacles", () => { - const canvas = makeOutdoorCanvas(64, 64) - // Preset obstacle placed in cell (1, 1) -> tiles [8..15, 8..15] - const placedRects: PlacedPresetRect[] = [ - { name: "Obstacle", x: 8, y: 8, w: 8, h: 8 }, - ] - - const stats = makeEmptyStats() - const rng = new Rng(0x9abc) - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 43, 2, undefined, placedRects, rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - expect(stats.waypointTile).toBeDefined() - const wp = stats.waypointTile! - // Waypoint should be placed in a valid cell not overlapping the obstacle [8..15, 8..15] - const inObstacle = wp.x >= 8 && wp.x < 16 && wp.y >= 8 && wp.y < 16 - expect(inObstacle).toBe(false) - }) - - test("Player landing safety buffer around placed waypoints is obstacle-free", () => { - const canvas = makeOutdoorCanvas(48, 48) - // Add walls at specific tiles to test obstacle-free landing buffer - const obstacleCell = canvas.cells[24]![22]! - canvas.cells[24]![22] = { - ...obstacleCell, - walls: [{ - prop1: 2, - sequence: 0, - style: 1, - type: 0, - unknown1: 0, - unknown2: 0, - hidden: false, - }], - } - - const dirtPathGrid = new Uint8Array(48 * 48) - for (let y = 0; y < 48; y += 1) { - dirtPathGrid[y * 48 + 16] = 1 - } - - const stats = makeEmptyStats() - const rng = new Rng(0x1122) - DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, [], rng, stats) - - expect(stats.waypointsSpawned).toBe(1) - const wp = stats.waypointTile! - - // Verify 2-tile perimeter (5x5 tiles around wp) is 100% obstacle-free - for (let dy = -2; dy <= 2; dy += 1) { - for (let dx = -2; dx <= 2; dx += 1) { - const tx = wp.x + dx - const ty = wp.y + dy - const cell = canvas.cells[ty]![tx]! - // Must have walkable floor - expect(cell.floors.some(f => !f.hidden && f.prop1 !== 0)).toBe(true) - // Must have NO walls - expect(cell.walls.some(w => !w.hidden && w.prop1 !== 0)).toBe(false) - } - } - }) - - test("DRLGOUTDOORS_SpawnAct12Shrines spaces shrines across exploration branches away from main road", () => { - const canvas = makeOutdoorCanvas(64, 64) - // Main spline road running vertically at x = 32 - const dirtPathGrid = new Uint8Array(64 * 64) - for (let y = 0; y < 64; y += 1) { - dirtPathGrid[y * 64 + 32] = 1 - } - - const stats = makeEmptyStats() - const rng = new Rng(0x4455) - DRLGOUTDOORS_SpawnAct12Shrines(canvas, 3, 1, dirtPathGrid, [], rng, stats) - - const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool) - const shrines = canvas.objects.filter(o => o.type === 2 && act1Shrines.has(o.id)) - - // Shrines should be spawned along secondary branches (not directly on or within 2 tiles of main road) - for (const shrine of shrines) { - const sx = Math.floor(shrine.x / 5) - expect(Math.abs(sx - 32)).toBeGreaterThanOrEqual(3) - } - - // Pairwise distance between shrines must be >= 10 tiles - for (let i = 0; i < shrines.length; i += 1) { - for (let j = i + 1; j < shrines.length; j += 1) { - const s1 = shrines[i]! - const s2 = shrines[j]! - const dist = Math.hypot( - Math.floor(s1.x / 5) - Math.floor(s2.x / 5), - Math.floor(s1.y / 5) - Math.floor(s2.y / 5), - ) - expect(dist).toBeGreaterThanOrEqual(10) - } - } - }) - - test("findWaypointSpot in level-links.ts selects high-clearance topological locations rather than blind center search", () => { - // 40x40 sub-tiles grid - const W = 40 - const H = 40 - const blocked = new Uint8Array(W * H).fill(1) // all blocked initially - - // Create a narrow 1-sub-tile corridor at the exact center (y = 20, x = 15..25) - for (let x = 15; x <= 25; x += 1) { - blocked[20 * W + x] = 0 - } - - // Create a wide open room at (10, 10) with 7x7 clear walkable sub-tiles - for (let y = 7; y <= 13; y += 1) { - for (let x = 7; x <= 13; x += 1) { - blocked[y * W + x] = 0 - } - } - - const grid: LinkGrid = { - cellsX: 8, - cellsY: 8, - gridWidth: W, - gridHeight: H, - blocked, - } - - // The naive BFS from center would pick (20, 20) in the narrow corridor - // findWaypointSpot evaluates clearance (>= 3x3) and selects inside the open room! - const spot = findWaypointSpot(grid) - expect(spot).not.toBeNull() - expect(spot!.x).toBeGreaterThanOrEqual(8) - expect(spot!.x).toBeLessThanOrEqual(12) - expect(spot!.y).toBeGreaterThanOrEqual(8) - expect(spot!.y).toBeLessThanOrEqual(12) - }) -}) diff --git a/tests/world-graph.test.ts b/tests/world-graph.test.ts index a52712d..072b597 100644 --- a/tests/world-graph.test.ts +++ b/tests/world-graph.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect } from 'vitest' import type { D2Table } from '../src/game/acts.ts' -import { ACT_TOWNS, townLevelForAct } from '../src/game/acts.ts' -import { actOfLevel } from '../src/game/wilderness.ts' +import { ACT_TOWNS, townLevelForAct, actOfLevel } from '../src/game/acts.ts' import { parseLevelRows, buildWorldGraph, - assignGateSides, + PRESET_SEAM_SIDES, seamlessPairs, reachableFrom, edgesFrom, @@ -16,8 +15,6 @@ import { SIDES, SEAMLESS_ADJACENCY, DROPPED_VIS_EDGES, - resolveWorldOffsets, - type WorldOffset, } from '../src/game/world-graph.ts' import type { Side } from '../src/game/world-graph.ts' import { @@ -216,92 +213,72 @@ describe('oppositeSide', () => { }) }) -describe('assignGateSides', () => { - /** A chain of five outdoor levels plus a branch, as Act 1 is shaped. */ - const chain = buildWorldGraph( - parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }, { Id: 4 }, { Id: 5 }, { Id: 17 }])), +describe('PRESET_SEAM_SIDES', () => { + const allPresetAndSampleLevels = [1, 2, 3, 7, 26, 27, 28, 32, 33, 40, 41, 75, 76, 77, 78, 79, 103, 104, 107, 108, 109, 110] + const graph = buildWorldGraph( + parseLevelRows( + levelsTable( + allPresetAndSampleLevels.map(Id => { + if (Id === 26) return { Id, Vis1: 27 } + if (Id === 27) return { Id, Vis0: 26, Vis1: 28 } + if (Id === 28) return { Id, Vis0: 27 } + if (Id === 32) return { Id, Vis1: 33 } + if (Id === 33) return { Id, Vis0: 32 } + if (Id === 107) return { Id, Vis1: 108 } + if (Id === 108) return { Id, Vis0: 107 } + return { Id } + }), + ), + ), ) - it('finds a side for every seam', () => { - const graph = assignGateSides(chain, 7) + it('populates opposite sides for every preset-anchored seam', () => { + expect(PRESET_SEAM_SIDES.size).toBe(10) + for (const [key, expectedSide] of PRESET_SEAM_SIDES.entries()) { + const [aStr, bStr] = key.split(':') + const a = Number(aStr) + const b = Number(bStr) + const forward = graph.edges.find(edge => edge.from === a && edge.to === b) + const backward = graph.edges.find(edge => edge.from === b && edge.to === a) + expect(forward?.sideFrom).toBe(expectedSide) + expect(forward?.sideTo).toBe(oppositeSide(expectedSide)) + expect(backward?.sideFrom).toBe(oppositeSide(expectedSide)) + expect(backward?.sideTo).toBe(expectedSide) + } + }) + + it('leaves DRLG outdoor-to-outdoor seams without a static side', () => { + const outdoorEdge = graph.edges.find(edge => edge.from === 2 && edge.to === 3) + expect(outdoorEdge?.kind).toBe('seamless') + expect(outdoorEdge?.sideFrom).toBeNull() + expect(outdoorEdge?.sideTo).toBeNull() + }) + + it('never puts two of a level\u2019s preset seams on the same side', () => { + const used = new Map>() for (const edge of graph.edges) { - if (edge.kind !== 'seamless') continue - expect(edge.sideFrom).not.toBeNull() - expect(edge.sideTo).not.toBeNull() - } - }) - - it('always puts the two ends of a seam on opposite sides', () => { - for (let seed = 0; seed < 50; seed += 1) { - const graph = assignGateSides(chain, seed) - for (const edge of graph.edges) { - if (edge.kind !== 'seamless' || edge.sideFrom === null) continue - expect(edge.sideTo).toBe(oppositeSide(edge.sideFrom)) + if (edge.kind !== 'seamless' || edge.sideFrom === null) continue + let sides = used.get(edge.from) + if (sides === undefined) { + sides = new Set() + used.set(edge.from, sides) } + expect(sides.has(edge.sideFrom)).toBe(false) + sides.add(edge.sideFrom) } }) - it('never puts two of a level\u2019s seams on the same side', () => { - // The documented constraint: in the Cold Plains the Blood Moor entrance - // and the two exits may not share an edge. - for (let seed = 0; seed < 50; seed += 1) { - const graph = assignGateSides(chain, seed) - const used = new Map>() - for (const edge of graph.edges) { - if (edge.kind !== 'seamless' || edge.sideFrom === null) continue - let sides = used.get(edge.from) - if (sides === undefined) { - sides = new Set() - used.set(edge.from, sides) - } - expect(sides.has(edge.sideFrom)).toBe(false) - sides.add(edge.sideFrom) - } - } - }) - - it('replays the same layout for the same seed', () => { - const render = (seed: number): string => - assignGateSides(chain, seed) - .edges.filter(edge => edge.kind === 'seamless') - .map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`) - .sort() - .join('|') - expect(render(4242)).toBe(render(4242)) - }) - - it('produces different layouts for different seeds', () => { - // Side choice is per seed in the real game; pinning it would be stating a - // coincidence as a law. - const render = (seed: number): string => - assignGateSides(chain, seed) - .edges.filter(edge => edge.kind === 'seamless') - .map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`) - .sort() - .join('|') - const layouts = new Set(Array.from({ length: 20 }, (_unused, seed) => render(seed))) - expect(layouts.size).toBeGreaterThan(1) - }) - - it('honours the preset-anchored pin between town and the Blood Moor', () => { - // The Rogue Encampment's gate is part of a hand-authored preset: it faces - // south and cannot be re-rolled. - for (let seed = 0; seed < 20; seed += 1) { - const graph = assignGateSides(chain, seed) - const gate = graph.edges.find(edge => edge.from === 1 && edge.to === 2) - expect(gate?.sideFrom).toBe('south') - expect(gate?.sideTo).toBe('north') - } + it('includes the Act 3 Jungle 76<->78 alternate branch in SEAMLESS_ADJACENCY', () => { + const branch = graph.edges.find(edge => edge.from === 76 && edge.to === 78) + expect(branch).toBeDefined() + expect(branch?.kind).toBe('seamless') }) it('leaves warp edges without a side', () => { - const graph = assignGateSides( - buildWorldGraph( - parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])), - ), - 1, + const warpGraph = buildWorldGraph( + parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])), ) - const warp = graph.edges.find(edge => edge.kind === 'warp') + const warp = warpGraph.edges.find(edge => edge.kind === 'warp') expect(warp?.sideFrom).toBeNull() }) }) @@ -482,153 +459,3 @@ describe('ACT_TOWNS and townLevelForAct single source of truth', () => { expect(actOfLevel(136)).toBe(5) }) }) - -describe('resolveWorldOffsets', () => { - function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean { - return ( - a.offsetX < b.offsetX + b.sizeX && - a.offsetX + a.sizeX > b.offsetX && - a.offsetY < b.offsetY + b.sizeY && - a.offsetY + a.sizeY > b.offsetY - ) - } - - it('generates valid non-overlapping coordinates along SEAMLESS_ADJACENCY for Acts 1..5', () => { - for (const act of [1, 2, 3, 4, 5]) { - const offsets = resolveWorldOffsets(act, 42) - expect(offsets.size).toBeGreaterThan(0) - - // Collect all seamless levels in this act - const seamlessInAct = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b)) - const placedSeamlessIds = Array.from(new Set(seamlessInAct.flat())) - - // Verify all coordinates are positive and non-empty - for (const id of placedSeamlessIds) { - const offset = offsets.get(id)! - expect(offset.offsetX).toBeGreaterThan(0) - expect(offset.offsetY).toBeGreaterThan(0) - expect(offset.sizeX).toBeGreaterThan(0) - expect(offset.sizeY).toBeGreaterThan(0) - } - - // Verify no two placed seamless levels have overlapping interiors - for (let i = 0; i < placedSeamlessIds.length; i++) { - for (let j = i + 1; j < placedSeamlessIds.length; j++) { - const a = offsets.get(placedSeamlessIds[i]!)! - const b = offsets.get(placedSeamlessIds[j]!)! - expect(doRectanglesOverlap(a, b)).toBe(false) - } - } - } - }) - - it('aligns Act 5 Levels 110, 111, 112 West edge to 702 matching ka.ma0 Layer 79 automap cluster', () => { - const offsets = resolveWorldOffsets(5, 42) - - const l109 = offsets.get(109)! - const l110 = offsets.get(110)! - const l111 = offsets.get(111)! - const l112 = offsets.get(112)! - - expect(l109).toBeDefined() - expect(l110).toBeDefined() - expect(l111).toBeDefined() - expect(l112).toBeDefined() - - // Harrogath at (1000, 1000) - expect(l109.offsetX).toBe(1000) - expect(l109.offsetY).toBe(1000) - - // Bloody Foothills at (760, 1000), width 240, ending at X=1000 touching Harrogath - expect(l110.offsetX).toBe(760) - expect(l110.offsetY).toBe(1000) - expect(l110.offsetX + l110.sizeX).toBe(1000) - - // Level 110 West gate connects West to Level 111 - // With Level 111 derived width 58: offset X = 760 - 58 = 702 - expect(l111.sizeX).toBe(58) - expect(l111.offsetX).toBe(702) - expect(l111.offsetX + l111.sizeX).toBe(760) // Zero gap to Level 110 - - // Level 111 connects North to Level 112 - // Both 111 and 112 have West edge aligned to 702 - expect(l112.sizeX).toBe(58) - expect(l112.offsetX).toBe(702) - - // Combined bounding box spans X in [702..759] and Y in [717..1026] down to the single tile - const minX = Math.min(l111.offsetX, l112.offsetX) - const maxX = Math.max(l111.offsetX + l111.sizeX - 1, l112.offsetX + l112.sizeX - 1) - const minY = Math.min(l111.offsetY, l112.offsetY) - const maxY = Math.max(l111.offsetY + l111.sizeY, l112.offsetY + l112.sizeY) - - expect(minX).toBe(702) - expect(maxX).toBe(759) - expect(minY).toBe(717) - expect(maxY).toBe(1026) - - // Verify 111 and 112 touch seamlessly without overlap - expect(l112.offsetY + l112.sizeY).toBe(l111.offsetY) - expect(doRectanglesOverlap(l111, l112)).toBe(false) - expect(doRectanglesOverlap(l110, l111)).toBe(false) - expect(doRectanglesOverlap(l109, l110)).toBe(false) - }) - - it('preserves static offsets from Levels.txt for isolated levels across acts', () => { - // Act 1 isolated cave and crypt - const act1 = resolveWorldOffsets(1, 42) - expect(act1.get(8)?.offsetX).toBe(1500) - expect(act1.get(8)?.offsetY).toBe(1000) - expect(act1.get(18)?.offsetX).toBe(2000) - expect(act1.get(18)?.offsetY).toBe(1000) - - // Act 2 isolated Canyon of the Magi - const act2 = resolveWorldOffsets(2, 42) - expect(act2.get(46)?.offsetX).toBe(2500) - expect(act2.get(46)?.offsetY).toBe(1000) - - // Act 4 isolated Chaos Sanctuary - const act4 = resolveWorldOffsets(4, 42) - expect(act4.get(108)?.offsetX).toBe(1500) - expect(act4.get(108)?.offsetY).toBe(1000) - - // Act 5 isolated Frozen Tundra - const act5 = resolveWorldOffsets(5, 42) - expect(act5.get(117)?.offsetX).toBe(2000) - expect(act5.get(117)?.offsetY).toBe(1896) - }) - - it('correctly resolves dependent levels relative to isolated parent levels (Level 33 on 32)', () => { - const act1 = resolveWorldOffsets(1, 42) - const l32 = act1.get(32)! - const l33 = act1.get(33)! - expect(l32).toBeDefined() - expect(l33).toBeDefined() - expect(l32.offsetX).toBe(4000) - expect(l32.offsetY).toBe(1000) - expect(l33.offsetX).toBe(3996) // 4000 + (-4) - expect(l33.offsetY).toBe(966) // 1000 + (-34) - expect(l33.offsetY + l33.sizeY).toBe(l32.offsetY) // North boundary sharing - expect(doRectanglesOverlap(l32, l33)).toBe(false) - }) - - it('supports caller-supplied custom WorldGraph', () => { - const custom = buildWorldGraph( - parseLevelRows(levelsTable([ - { Id: 109, Act: 4, OffsetX: 1000, OffsetY: 1000, SizeX: 40, SizeY: 40 }, - { Id: 110, Act: 4, OffsetX: 760, OffsetY: 1000, SizeX: 240, SizeY: 48 }, - { Id: 111, Act: 4, OffsetX: -1, OffsetY: -1, SizeX: -1, SizeY: -1 }, - { Id: 112, Act: 4, OffsetX: -1, OffsetY: -1, SizeX: -1, SizeY: -1 }, - ])) - ) - const offsets = resolveWorldOffsets(5, 123, custom) - expect(offsets.get(110)?.offsetX).toBe(760) - expect(offsets.get(111)?.offsetX).toBe(702) - expect(offsets.get(112)?.offsetX).toBe(702) - }) - - it('replays identical offsets deterministically for the same seed', () => { - const run1 = resolveWorldOffsets(1, 9999) - const run2 = resolveWorldOffsets(1, 9999) - expect(Array.from(run1.entries())).toEqual(Array.from(run2.entries())) - }) -}) diff --git a/tools/d2moo-oracle/build.sh b/tools/d2moo-oracle/build.sh index 04b8560..b298291 100755 --- a/tools/d2moo-oracle/build.sh +++ b/tools/d2moo-oracle/build.sh @@ -25,8 +25,8 @@ fi # 32-bit so every D2MOO struct has the exact 1.13c layout of the compiled .bin tables. # -O0/-fwrapv/-fno-strict-aliasing keep MSVC-like integer and aliasing semantics. -FLAGS=(-m32 -std=c++17 -O0 -g -fwrapv -fno-strict-aliasing -fms-extensions -fpermissive - -Wno-invalid-offsetof -w "-DD2ORACLE_D2MOO_COMMIT=\"$PINNED_COMMIT\"") +FLAGS=(-m32 -std=c++17 -O0 -g -fwrapv -fno-strict-aliasing -fPIC -ffunction-sections -fms-extensions -fpermissive + -Wno-invalid-offsetof -w -DD2_VERSION_HAS_UBERS=1 -include DataTbls/LevelsIds.h "-DD2ORACLE_D2MOO_COMMIT=\"$PINNED_COMMIT\"") INCLUDES=(-I"$HERE/include" -I"$HERE/src") for component in D2Common D2CommonDefinitions Fog Storm D2CMP D2Lang D2Hell; do INCLUDES+=(-I"$D2MOO_SRC/source/$component/include") @@ -75,8 +75,15 @@ for obj in "${d2moo_objs[@]}"; do fi done -WRAP_SYMBOL=_Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc -"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "-Wl,--wrap=$WRAP_SYMBOL" -o "$WORK/d2moo-oracle" +WRAP_FLAGS=( + -Wl,--wrap=_Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc + -Wl,--wrap=_Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc + -Wl,--wrap=_Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc + -Wl,--wrap=_Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc + -Wl,--wrap=_Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc + -Wl,--wrap=_Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii +) +"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "${WRAP_FLAGS[@]}" -o "$WORK/d2moo-oracle" mkdir -p "$OUT" cp "$WORK/d2moo-oracle" "$OUT/d2moo-oracle.tmp" diff --git a/tools/d2moo-oracle/include/Windows.h b/tools/d2moo-oracle/include/Windows.h index a43ee11..b4132d3 100644 --- a/tools/d2moo-oracle/include/Windows.h +++ b/tools/d2moo-oracle/include/Windows.h @@ -181,3 +181,6 @@ inline void strcat_s(char (&dst)[N], const char* src) { strncat(dst, src, N - strlen(dst) - 1); } #define sprintf_s snprintf + +struct D2DrlgLevelLinkDataStrc; +BOOL __fastcall DRLGOUTPLACE_LinkAct5Ubers(D2DrlgLevelLinkDataStrc* pLevelLinkData, int nIteration); diff --git a/tools/d2moo-oracle/src/main.cpp b/tools/d2moo-oracle/src/main.cpp index 0d0b017..6761c6f 100644 --- a/tools/d2moo-oracle/src/main.cpp +++ b/tools/d2moo-oracle/src/main.cpp @@ -238,14 +238,57 @@ std::string LevelGrid(D2DrlgLevelStrc* pLevel) } // namespace extern "C" void __real__Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel); +extern "C" void __real__Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel); +extern "C" void __real__Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel); +extern "C" void __real__Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel); +extern "C" D2DrlgMapStrc* __real__Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc( + D2DrlgLevelStrc* pLevel, int nLvlPrestId, D2DrlgCoordStrc* pDrlgCoord, D2SeedStrc* pSeed); +extern "C" BOOL __real__Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii( + D2DrlgLevelStrc* pLevel, int nX, int nY, int nWidth, int nHeight, int dwRoomFlags, int dwOutdoorFlags, int dwOutdoorFlagsEx, int dwDT1Mask); -// Linked with -Wl,--wrap: DRLGOUTDOORS_GenerateLevel's call to DRLGOUTWILD_InitAct1OutdoorLevel lands -// here, so the level grids are captured exactly between InitAct1OutdoorLevel and room creation. +// Linked with -Wl,--wrap: DRLGOUTDOORS_GenerateLevel's call to InitAct{1,2,3,5}OutdoorLevel (and +// for Act 4, where DRLGOUTDOORS_InitAct4OutdoorLevel is in the same TU, the first room creation +// immediately after it) lands here so the level grids are captured between InitAct*OutdoorLevel +// and room creation. extern "C" void __wrap__Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel) { __real__Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc(pLevel); gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel); } +extern "C" void __wrap__Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel) +{ + __real__Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc(pLevel); + gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel); +} +extern "C" void __wrap__Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel) +{ + __real__Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc(pLevel); + gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel); +} +extern "C" void __wrap__Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel) +{ + __real__Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc(pLevel); + gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel); +} +extern "C" D2DrlgMapStrc* __wrap__Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc( + D2DrlgLevelStrc* pLevel, int nLvlPrestId, D2DrlgCoordStrc* pDrlgCoord, D2SeedStrc* pSeed) +{ + if (pLevel->nDrlgType == DRLGTYPE_OUTDOOR && gLevelGridJson.find(pLevel->nLevelId) == gLevelGridJson.end()) + { + gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel); + } + return __real__Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc(pLevel, nLvlPrestId, pDrlgCoord, pSeed); +} +extern "C" BOOL __wrap__Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii( + D2DrlgLevelStrc* pLevel, int nX, int nY, int nWidth, int nHeight, int dwRoomFlags, int dwOutdoorFlags, int dwOutdoorFlagsEx, int dwDT1Mask) +{ + if (pLevel->nDrlgType == DRLGTYPE_OUTDOOR && gLevelGridJson.find(pLevel->nLevelId) == gLevelGridJson.end()) + { + gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel); + } + return __real__Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii( + pLevel, nX, nY, nWidth, nHeight, dwRoomFlags, dwOutdoorFlags, dwOutdoorFlagsEx, dwDT1Mask); +} namespace { @@ -614,10 +657,6 @@ std::string MapTiles(const RefIndex& index, const D2DrlgRoomStrc* pRoom, int nLe { D2ORACLE_Fail("level %d room %d: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles", nLevelId, nRoom); } - if (pTileGrid->pAnimTiles) - { - D2ORACLE_Fail("level %d room %d: animated tiles (pAnimTiles) are not dumped", nLevelId, nRoom); - } const D2DrlgRoomTilesStrc& t = pTileGrid->pTiles; std::ostringstream o; o << "{\"nWalls\":" << pTileGrid->nWalls << ",\"nFloors\":" << pTileGrid->nFloors << ",\"nShadows\":" << pTileGrid->nShadows @@ -645,19 +684,20 @@ std::string InitLevelStage(D2DrlgStrc* pDrlg, int nLevelId, D2DrlgLevelStrc** pp D2ORACLE_SetTraceContext(szContext); D2DrlgLevelStrc* pLevel = DRLG_GetLevel(pDrlg, nLevelId); - if (pLevel->nDrlgType != DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) != ACT_I) + if (pLevel->nDrlgType != DRLGTYPE_OUTDOOR) { - D2ORACLE_Fail("level %d is not an Act I outdoor level", nLevelId); + D2ORACLE_Fail("level %d is not an outdoor level", nLevelId); } if (pLevel->pFirstRoomEx) { D2ORACLE_Fail("level %d was already generated before its own generation stage", nLevelId); } + gLevelGridJson.erase(nLevelId); DRLG_InitLevel(pLevel); auto it = gLevelGridJson.find(nLevelId); if (it == gLevelGridJson.end()) { - D2ORACLE_Fail("level %d: InitAct1OutdoorLevel hook did not fire", nLevelId); + D2ORACLE_Fail("level %d: outdoor levelGrid hook did not fire", nLevelId); } std::vector maps; @@ -679,14 +719,17 @@ std::string InitLevelStage(D2DrlgStrc* pDrlg, int nLevelId, D2DrlgLevelStrc** pp return o.str(); } -D2DrlgStrc* AllocAct1(D2DrlgActStrc* pAct, uint32_t nSeed, uint8_t nDifficulty) +D2DrlgStrc* AllocAct(D2DrlgActStrc* pAct, uint8_t nAct, uint32_t nSeed, uint8_t nDifficulty) { + static const int kTownLevelIds[5] = { + LEVEL_ROGUEENCAMPMENT, LEVEL_LUTGHOLEIN, LEVEL_KURASTDOCKTOWN, LEVEL_THEPANDEMONIUMFORTRESS, LEVEL_HARROGATH, + }; memset(pAct, 0, sizeof(*pAct)); - pAct->nAct = ACT_I; + pAct->nAct = nAct; D2ORACLE_SetTraceContext("drlg"); // D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act // (DRLGOUTPLACE_CreateLevelConnections) and generates the town. - return DRLG_AllocDrlg(pAct, ACT_I, nullptr, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nullptr, nDifficulty, nullptr, nullptr); + return DRLG_AllocDrlg(pAct, nAct, nullptr, nSeed, kTownLevelIds[nAct], 0, nullptr, nDifficulty, nullptr, nullptr); } std::vector ParseLevels(const char* sz) @@ -710,6 +753,7 @@ int main(int argc, char** argv) bool bHaveSeed = false; uint32_t nSeed = 0; int nDifficulty = 0; + int nExplicitAct = -1; bool bIsolated = false; std::vector levels; for (int i = 1; i < argc; ++i) @@ -722,6 +766,7 @@ int main(int argc, char** argv) if (arg == "--data") gsD2OracleDataDir = next(); else if (arg == "--seed") { nSeed = (uint32_t)strtoul(next(), nullptr, 0); bHaveSeed = true; } else if (arg == "--levels") levels = ParseLevels(next()); + else if (arg == "--act") nExplicitAct = atoi(next()); else if (arg == "--difficulty") nDifficulty = atoi(next()); else if (arg == "--isolated") bIsolated = true; else if (arg == "--trace") szTrace = next(); @@ -731,7 +776,7 @@ int main(int argc, char** argv) // Without --levels only the act stage (layout, links, town) is generated and dumped. if (gsD2OracleDataDir.empty() || !bHaveSeed || !szOut) { - D2ORACLE_Fail("usage: d2moo-oracle --data --seed [--levels 2,3] --out [--difficulty n] [--isolated] [--trace file]"); + D2ORACLE_Fail("usage: d2moo-oracle --data --seed [--levels 2,3] [--act 0..4] --out [--difficulty n] [--isolated] [--trace file]"); } if (nDifficulty < 0 || nDifficulty > 2) { @@ -744,12 +789,30 @@ int main(int argc, char** argv) D2ORACLE_OpenTrace(szTrace); } + uint8_t nAct = ACT_I; + if (nExplicitAct != -1) + { + if (nExplicitAct < 0 || nExplicitAct > 4) D2ORACLE_Fail("act must be 0..4"); + nAct = (uint8_t)nExplicitAct; + } + else if (!levels.empty()) + { + nAct = DRLG_GetActNoFromLevelId(levels[0]); + } + for (int id : levels) + { + if (DRLG_GetActNoFromLevelId(id) != nAct) + { + D2ORACLE_Fail("level %d belongs to act %d, not act %d", id, (int)DRLG_GetActNoFromLevelId(id), (int)nAct); + } + } + D2DrlgActStrc act = {}; std::ostringstream o; o << "{\"schema\":3,\"oracle\":{\"d2moo\":\"" << D2ORACLE_D2MOO_COMMIT << "\",\"arch\":\"i386\"},\"gameSeed\":" << nSeed << ",\"difficulty\":" << nDifficulty << ",\"isolated\":" << (bIsolated ? "true" : "false"); - D2DrlgStrc* pDrlg = AllocAct1(&act, nSeed, (uint8_t)nDifficulty); + D2DrlgStrc* pDrlg = AllocAct(&act, nAct, nSeed, (uint8_t)nDifficulty); o << ",\"drlg\":{\"startSeed\":" << pDrlg->dwStartSeed << ",\"seed\":" << Seed(pDrlg->pSeed) << "},\"act\":" << Act(pDrlg) << ",\"levels\":["; std::vector heads(levels.size()); std::vector generated(levels.size(), nullptr); @@ -761,7 +824,7 @@ int main(int argc, char** argv) { if (i > 0) { - pDrlg = AllocAct1(&act, nSeed, (uint8_t)nDifficulty); + pDrlg = AllocAct(&act, nAct, nSeed, (uint8_t)nDifficulty); } heads[i] = InitLevelStage(pDrlg, levels[i], &generated[i]); activations[i] = Activate(generated[i]); diff --git a/tools/d2moo-oracle/src/stubs.cpp b/tools/d2moo-oracle/src/stubs.cpp index 39f3f41..56cf55b 100644 --- a/tools/d2moo-oracle/src/stubs.cpp +++ b/tools/d2moo-oracle/src/stubs.cpp @@ -336,9 +336,9 @@ BOOL __stdcall D2Common_11099(D2FieldStrc*, D2ActiveRoomStrc*, int, int, uint16_ { D2ORACLE_Fail("unexpected D2Common_11099"); } -BOOL __fastcall MONSTERS_ValidateMonsterId(int) +BOOL __fastcall MONSTERS_ValidateMonsterId(int nMonsterId) { - D2ORACLE_Fail("unexpected MONSTERS_ValidateMonsterId (tile preset units are not generated)"); + return nMonsterId >= 0 && nMonsterId < sgptDataTables->nMonStatsTxtRecordCount ? nMonsterId : -1; } // ---- D2CMP: DT1 tile libraries ---- @@ -468,16 +468,13 @@ void D2ORACLE_DescribeTile(const D2TileLibraryEntryStrc* pTileLibraryEntry, std: *pnIndex = pTile->nIndex; } -//D2Cmp.#10087: stores the library in the first free slot of the 32-slot array. +//D2Cmp.#10087 (1.13c D2CMP.dll 0x6fe25480): stores the library in the first free slot of the 32-slot +// array without deduplicating (e.g. LvlTypes.txt row 21 Act 3 Jungle lists Bridge.dt1 in slots 0 and 8). void __stdcall D2CMP_10087_LoadTileLibrarySlot(D2TileLibraryHashStrc** ppTileLibraryHash, const char* szFileName) { OracleTileLibrary* pLibrary = LoadTileLibrary(szFileName); for (int i = 0; i < kTileSlots; ++i) { - if (ppTileLibraryHash[i] == &pLibrary->tHash) - { - D2ORACLE_Fail("tile library %s loaded twice into the same slot array (D2CMP behaviour unknown)", pLibrary->szPath.c_str()); - } if (!ppTileLibraryHash[i]) { ppTileLibraryHash[i] = &pLibrary->tHash;