diff --git a/package.json b/package.json index 3649de7..b6e6e3f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "verify:all": "tsx scripts/verify-combat.ts && tsx scripts/verify-items.ts && tsx scripts/verify-m4.ts && tsx scripts/verify-m5.ts && tsx scripts/verify-net.ts && tsx scripts/verify-collision-orientation.ts && tsx scripts/verify-tbl.ts && tsx scripts/verify-audio.ts && tsx scripts/verify-formats.ts", "verify:acts": "tsx scripts/verify-acts.ts", "verify:monsters": "tsx scripts/verify-monsters.ts", - "verify:generators": "tsx scripts/verify-generators.ts samples/d2", "verify:world-graph": "tsx scripts/verify-world-graph.ts samples/d2", "verify:world-walk": "tsx scripts/verify-world-walk.ts", "build:game": "vite build --base=/diablo2/ --outDir dist-game", diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 10e393b..97ea918 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -24,7 +24,7 @@ * * Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory] */ -import { mkdir, readdir, unlink, writeFile, rename } from 'node:fs/promises' +import { mkdir, readdir, rm, unlink, writeFile, rename } from 'node:fs/promises' import { createHash } from 'node:crypto' import { join } from 'node:path' import { isMainThread, Worker, parentPort, workerData } from 'node:worker_threads' @@ -34,14 +34,13 @@ import { performance } from 'node:perf_hooks' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' -import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath, townLevelForAct, ACT_TOWNS } from '../src/game/acts.ts' -import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts' -import { decodeDs1 } from '../src/formats/ds1.ts' +import { cell, loadActTables, parseTable, townLevelForAct, ACT_TOWNS } from '../src/game/acts.ts' +import type { ActTables } from '../src/game/acts.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 { decodePl2 } from '../src/formats/pl2.ts' -import { levelSeed, buildIsoMapScene, buildLevelAutomap, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' +import { buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.ts' import { planLevelMonsters } from '../src/game/monsters.ts' import type { IsoMapScene, IsoDraw } from '../src/game/d2map.ts' @@ -52,9 +51,7 @@ import { decodeCof } from '../src/formats/cof.ts' import { decodeDcc } from '../src/formats/dcc.ts' 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 { outdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts' +import { actLevelIds, 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,14 +65,6 @@ import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/dr * the bake gives it. */ type GeneratorEntrance = DrlgEntrance & { readonly label: string } -export interface MazeWarp { - readonly room: number - readonly direction: 'up' | 'down' - readonly kind?: string - readonly pieceName?: string - readonly centreX: number - readonly centreY: number -} import { buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp, parseLevelRows, parseWarpGeometry, SIDES, @@ -312,17 +301,6 @@ function frameHash(indices: Uint8Array): string { return hash.toString(16).padStart(8, '0') } -/** - * Turn a DS1 member name into a file-friendly slug. - * - * @param member - full member path. - * @returns the slug. - */ -function slugOf(member: string): string { - const base = member.split('\\').pop() ?? member - return base.replace(/\.ds1$/i, '').toLowerCase() -} - const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = isMainThread ? process.argv.slice(2) : [ @@ -461,33 +439,34 @@ function levelNameOf(levelId: number): string { * ------------------------------------------------------------------------- */ /** Copies the bake makes of every act the DRLG port generates, one game seed each. */ -const DRLG_ACT_VARIANTS = 3 +const DRLG_ACT_VARIANTS = 1 + +/** Canonical single game seed for all 5 acts (matches Act I oracle fixture seed 0x5eed0100). */ +const GAME_SEED = 0x5eed_0100 /** * The game seed copy `variant` of `act` is generated from. * * D2 generates a whole act from one seed (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections -> - * DRLG_InitLevel), so the seed belongs to the (act, copy) pair, never to a level. Act I's three are the - * seeds of the committed oracle fixtures (tests/fixtures/d2moo-oracle/act1-seed-5eed010{0,1,2}), so every - * baked copy is one the native D2MOO oracle generates identically. + * DRLG_InitLevel), so the seed belongs to the (act, copy) pair, never to a level. * - * @param act - act number (1..5). + * @param _act - act number (1..5). * @param variant - copy number (0-based, the index entries' `actVariant`). * @returns the game seed. */ -function actGameSeed(act: number, variant: number): number { - return (0x5eed_0000 + act * 0x100 + variant) >>> 0 +function actGameSeed(_act: number, variant: number): number { + return (GAME_SEED + variant) >>> 0 } /** - * The levels of an act the DRLG port generates: all outdoor levels (Levels.txt DrlgType 3) of that act. + * The levels of an act the DRLG port generates: all levels (preset, maze, outdoor) 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 outdoorLevelIds(t, act - 1) + return actLevelIds(t, act - 1) } /** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */ @@ -682,9 +661,7 @@ function buildSceneLinks( ds1: Ds1, grid: IsoMapScene, entrances: readonly GeneratorEntrance[], - mazeWarps: readonly MazeWarp[], waypointCells: readonly { x: number; y: number }[], - mazeWaypoints?: readonly MazeWaypoint[], options: { readonly exactLinks?: boolean } = {}, ): SceneLinks { const exactLinks = options.exactLinks === true @@ -752,6 +729,13 @@ function buildSceneLinks( const placedWarpCounts = new Map() const maxWarpsTo = (toLevelId: number): number => { if (levelRow === undefined) return 1 + if (levelRow.drlgType !== 3) { + let count = 0 + for (let slot = 0; slot < 8; slot += 1) { + if ((levelRow.vis[slot] ?? 0) === toLevelId) count += 1 + } + return Math.max(1, count) + } const byWarpId = new Map() let maxCount = 1 for (let slot = 0; slot < 8; slot += 1) { @@ -775,25 +759,17 @@ function buildSceneLinks( // the UNIT_TILE warp units D2 itself creates for them. for (const tile of exactLinks ? [] : findWarpTiles(ds1)) { const edge = warpEdgeBySlot.get(tile.slot) - // A marker for a slot this level does not use: the piece was drawn for a - // level that connects there and reused here. Ignore it rather than invent - // a destination. if (edge === undefined) continue - // A generator can stamp the same stair piece twice; the first wins. if (placedWarps.has(edge.to)) continue outWarps.push(makeWarp(edge, directionTo(edge.to), tile.cellX, tile.cellY, grid, 'tile', region)) placedWarps.add(edge.to) } - // 2. Outdoor levels: the generator already cut the seams, and knows where the - // interior presets it stamped sit. + // 2. DRLG levels: the generator already cut the seams, and knows where the + // interior presets and warp units it stamped sit. for (const anchor of entrances) { const span = 'span' in anchor ? anchor.span : undefined if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0 && span !== undefined) { - // A DRLG seam: the level link names the stretch of this edge that opens - // onto the neighbour (the open border block, or the whole shared edge - // for the town and the Monastery approach), so the walkable gap is - // searched there and nowhere else. const range = { from: span.from * SUB_TILES_PER_TILE, to: span.to * SUB_TILES_PER_TILE } const opening = findBorderOpening(grid, anchor.side, region, undefined, range) if (opening === null) { @@ -874,44 +850,7 @@ function buildSceneLinks( } } - // 3. Mazes: staircase rooms, for pieces whose artwork carries no marker. - if (kind === 'maze') { - const leftover = [...edgesFrom(worldGraph, levelId)] - .filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to)) - const queues = { - up: leftover.filter(edge => directionTo(edge.to) === 'up'), - down: leftover.filter(edge => directionTo(edge.to) === 'down'), - } - for (const room of mazeWarps) { - const edge = queues[room.direction].shift() - if (edge === undefined) continue - outWarps.push(makeWarp(edge, room.direction, room.centreX, room.centreY, grid, 'room', region)) - placedWarps.add(edge.to) - } - } - // 4. Seams nobody cut: read them off the artwork's walkable border. - // - // 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). - // - // The graph's side is usually right, but not always. The Rogue Encampment - // ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which - // are the same camp slid to four different corners of a 57x41 canvas, so - // that the palisade opens off a different edge of the map in each. The - // graph has one side per *level*, so three variants in four would be told - // to put the gate where this particular camp has none. - // - // What distinguishes the two cases is `inset`: an opening at inset 0 is - // walkable ground running off the edge of the map, which is what a gate - // looks like, while a large inset means the scan gave up on the border and - // settled for the far end of the play area. So when the level has exactly - // one seam and some side reaches the border while the graph's side does - // not, the artwork wins and the widest such contact is the gate. With more - // 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' && !placedSeams.has(edge.to) && !isInactiveJungleSeam(edge.to)) for (const edge of loneSeams) { @@ -928,8 +867,6 @@ function buildSceneLinks( const atBorder = [...measured].filter(([, found]) => found.inset === 0) if (loneSeams.length === 1 && atBorder.length > 0 && (asked === undefined || asked.inset > 0)) { - // Widest contact wins: `TownE1` grazes the north border with a one-cell - // path as well as opening its whole east flank, and the flank is the gate. const [bestSide, bestOpening] = atBorder .reduce((best, next) => (next[1].width > best[1].width ? next : best)) side = bestSide @@ -937,7 +874,6 @@ function buildSceneLinks( } if (opening === undefined || side === null) { - // Nothing on the asked-for side and nothing at any border either. const any = [...measured].sort((a, b) => a[1].inset - b[1].inset)[0] if (any === undefined) { unplaced.push({ toLevelId: edge.to, reason: 'every edge is solid' }) @@ -959,50 +895,6 @@ function buildSceneLinks( placedSeams.add(edge.to) } - // 5. Last resort. Several level types — the Act 2 Harem and Basement, the - // Act 3 Spider Cavern, the Act 5 Infernal Pit — have an empty `specials` - // table in `MAZE_LEVEL_TYPE_PROFILES` because their DRLG staircase pass - // has not been transcribed (`unimplementedPassesFor` names them), so they - // stamp no stair room, and their pieces carry no marker either. Leaving - // those edges unplaced makes a level you can enter and never leave, which - // is a worse lie than an approximate staircase. Each one is spread to a - // different quarter of the map so two of them never coincide, and each is - // tagged `fallback` so nothing mistakes it for the real position. - const needFallback = [...edgesFrom(worldGraph, levelId)] - .filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to)) - needFallback.forEach((edge, index) => { - // Quarter centres, in reading order, so the choice is deterministic. - const quarters = [[1, 1], [3, 1], [1, 3], [3, 3], [2, 2]] as const - const [qx, qy] = quarters[index % quarters.length]! - const wanted = { - x: Math.floor(grid.gridWidth * qx / 4), - y: Math.floor(grid.cellsY * SUB_TILES_PER_TILE * qy / 4), - } - const spot = nearestWalkable(grid, wanted.x, wanted.y, Math.max(grid.gridWidth, grid.gridHeight), region) - if (spot === null) { - unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' }) - return - } - const fromName = worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}` - const toName = worldGraph.levels.get(edge.to)?.name ?? `level ${String(edge.to)}` - notes.push(`fallback warp placed for edge ${fromName} -> ${toName}`) - outWarps.push(makeWarp( - edge, - directionTo(edge.to), - Math.floor(spot.x / SUB_TILES_PER_TILE), - Math.floor(spot.y / SUB_TILES_PER_TILE), - grid, - 'fallback', - region, - )) - placedWarps.add(edge.to) - }) - - // Anything the graph wanted and nothing produced. Portals are excluded on - // purpose: a town portal or a quest portal is conjured at run time by the - // thing that opens it, so there is nothing for the bake to cut. Seams pass 4 - // already complained about are skipped, or every solid edge would be counted - // 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) && !isInactiveJungleSeam(edge.to)) { @@ -1010,12 +902,8 @@ function buildSceneLinks( } } - // Waypoints. The artwork wins when it has one. The DRLG port's levels always - // do (DRLGOUTDOORS_SpawnAct12Waypoint puts the object in the level), so for - // them a missing or unreachable waypoint is a bug and throws. Legacy - // generators do not run that pass, so their levels get a stand-in: a - // waypoint the network knows about but the map does not show is worse than - // an approximate position. + // Waypoints. The DRLG port's levels always place the waypoint object in the level, + // so a missing or unreachable waypoint is a bug and throws. const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255 const waypoints: SceneWaypoint[] = [] // Level 134 (LEVEL_PANDEMONIUMRUN2, Forgotten Sands) stamps LVLPREST_ACT2_DESERT_VALLEY_WARP @@ -1028,7 +916,7 @@ function buildSceneLinks( if (exactLinks && waypointCells.length !== 1) { throw new Error(`level ${String(levelId)}: the generator placed ${String(waypointCells.length)} waypoints for waypoint ${String(waypointId)}`) } - const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined) + const fromArt = waypointCells[0] const spot = fromArt ?? findWaypointSpot(grid, region) if (spot !== null && spot !== undefined) { const reached = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) @@ -1070,12 +958,6 @@ const allNames = await archives.listFiles() LEVELS = jobs } -/** - * Decoded DT1 libraries, kept across levels. - * - * A hundred-plus levels share a few dozen level types, so decoding each library - * once instead of once per level turns the bake from repeated work into one pass. - */ /** * Decoded DT1 libraries, keyed by member name. * @@ -1126,9 +1008,6 @@ for (const name of allNames) { const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase()) const isMon = name.toLowerCase().startsWith(MONSTER_ROOT.toLowerCase()) if (!isObj && !isMon) continue - // Derive the length; a hand-counted magic number here was off by one (22 vs - // the real 21), which sliced the first letter off every monster token and - // silently broke all NPC art lookups. const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_ROOT.length const rest = name.slice(prefixLen).split('\\') if (rest.length < 3) continue @@ -1153,12 +1032,6 @@ function compRank(path: string): number { /** * Pick the member that stands in for an object's art. * - * A token ships many variants (lit/unlit, per-mode, per-weapon-class); the pack - * is static art, so one deterministic pick is recorded — mode order first, then - * component preference (body layer 'tr' first), then file format (DCC before DC6) - * — and COF definition files are filtered out since they are compositing metadata, - * not sprites. - * * @param token - object token from the lookup table. * @param modeToken - animation mode token the engine places the object in (`NU`/`OP`/…). * @returns the member name and how many candidates there were. @@ -1238,73 +1111,6 @@ async function isObjectCofAdditive(token: string, modeIndex: number, baseIsMonst } } -const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt')) - -/** The level type row for a level row. */ -function levelType(levelRow: readonly string[]): { id: string; name: string } { - const id = cell(tables.levels, levelRow, 'LevelType') - const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id) - return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') } -} - -/** The `LvlMaze` row for a level, by id then by name. */ -function mazeRow(levelId: number, levelName: string): readonly string[] | undefined { - return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId) - ?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName) -} - -const ds1Cache = new Map() - -async function loadDs1(member: string): Promise { - 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(tileMemberPath(value))) - } - return levels -} - -const mazePiecesCache = new Map>() -function getMazePieces(levelTypeName: string, levelTypeId: string): Promise { - void levelTypeId - const cached = mazePiecesCache.get(levelTypeName) - if (cached !== undefined) return cached - const promise = (async () => { - const pieces: MazePiece[] = [] - for (const row of tables.lvlprest.rows) { - if (cell(tables.lvlprest, row, 'LevelId') !== '0' && cell(tables.lvlprest, row, 'LevelId') !== '') continue - const name = cell(tables.lvlprest, row, 'Name') - const classified = classifyMazePieceName(name, levelTypeName) - if (classified === null) continue - const levels = await rowDs1s(tables.lvlprest, row) - if (levels.length === 0) continue - const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0 - const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined - pieces.push({ - name, - kind: classified.kind satisfies MazePieceKind, - sides: classified.sides, - levels, - // `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set. - killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1', - ...(animSpeed !== undefined ? { animSpeed } : {}), - }) - } - return pieces - })() - mazePiecesCache.set(levelTypeName, promise) - return promise -} - const paletteCache = new Map() async function getPalette(paletteName: string): Promise { const cached = paletteCache.get(paletteName) @@ -1317,14 +1123,13 @@ async function getPalette(paletteName: string): Promise { interface SceneJob { readonly jobId: number readonly entry: LevelJob - readonly type: 'preset-ds1' | 'maze' | 'drlg' - readonly ds1Name?: string - /** Copy number: 1-based for the maze generator, the 0-based act copy (`actVariant`) for 'drlg'. */ - readonly variant?: number + readonly type: 'drlg' + /** The 0-based act copy (`actVariant`) for 'drlg'. */ + readonly variant: number readonly label: string readonly seed: number - /** 'drlg' jobs: the level as the main thread's DRLG run of the act copy generated it. */ - readonly drlg?: { readonly gameSeed: number; readonly input: DrlgLevelInput } + /** The level as the main thread's DRLG run of the act copy generated it. */ + readonly drlg: { readonly gameSeed: number; readonly input: DrlgLevelInput } } interface SceneResult { @@ -1351,16 +1156,8 @@ async function bakeDs1Variant( ds1Name: string, label: string, seed: number, - /** - * What the generator learned while building this level. - * - * Empty for presets, which are not generated: their openings are recovered - * from the collision map instead. - */ generatorLinks: { readonly entrances: readonly GeneratorEntrance[] - readonly mazeWarps: readonly MazeWarp[] - readonly mazeWaypoints?: readonly MazeWaypoint[] readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] readonly notes?: readonly string[] | undefined readonly animSpeed?: number | undefined @@ -1373,7 +1170,7 @@ async function bakeDs1Variant( * Unset: the generator approximates the level. */ readonly fidelity?: 'exact' | 'layout' - } = { entrances: [], mazeWarps: [] }, + } = { entrances: [] }, animSpeedOverride?: number, ): Promise { let localSkippedArtless = 0 @@ -1404,7 +1201,7 @@ async function bakeDs1Variant( const waypointCells: { x: number; y: number }[] = [] for (const object of level.objects) { try { - const resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable) + const resolved = resolveDs1Object(objectsTableTyped, object.act ?? entry.act, object.type, object.id, monstersTable) const resolvedName = resolved.name ?? resolved.row?.name ?? resolved.token const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? resolved.row?.id) || WAYPOINT_TOKENS.has(resolved.token.toUpperCase()) @@ -1424,9 +1221,7 @@ async function bakeDs1Variant( level, scene, generatorLinks.entrances, - generatorLinks.mazeWarps, waypointCells, - generatorLinks.mazeWaypoints, { exactLinks: generatorLinks.exactLinks === true }, ) @@ -1511,7 +1306,7 @@ async function bakeDs1Variant( for (const object of level.objects) { let resolved try { - resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable) + resolved = resolveDs1Object(objectsTableTyped, object.act ?? entry.act, object.type, object.id, monstersTable) } catch (err) { missingObjects.push((err as Error).message) continue @@ -1621,8 +1416,6 @@ async function bakeDs1Variant( const list = resolved.kind === 'npc' ? npcs : objects let resolvedName = resolved.name ?? row?.name ?? resolved.token - // The pedestal's own sub-tile, taken here because everything downstream is - // in screen pixels and cannot be converted back. const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? row?.id) || WAYPOINT_TOKENS.has(resolved.token.toUpperCase()) || resolved.token.toLowerCase() === 'wp' @@ -1664,16 +1457,12 @@ async function bakeDs1Variant( name: resolvedName, token: resolved.token, mode: modeToken, - // `objectsTxtId` is the Objects.txt row the table points at; -1 means the - // engine picks the art straight from the table's token, with no row. objectsTxtId: resolved.entry?.objectsTxtId ?? -1, trans: row?.trans ?? 0, blendMode: isAdditive ? 'additive' : 'normal', hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0), member: pick === null ? null : pick.member, alternatives: pick === null ? 0 : pick.candidates, - // Screen point of the object's sub-tile; the sprite anchor is applied - // when a frame exists. x: Math.round(orthoX), y: Math.round(orthoY), depth: (object.x + object.y) / 5, @@ -1687,7 +1476,7 @@ async function bakeDs1Variant( } : {}), }) if (pick !== null) objectsWithArt += 1 - else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`) + else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(object.act ?? entry.act)}) has no art members`) } const objectFiles = objectPages.encode(0) @@ -1710,10 +1499,6 @@ async function bakeDs1Variant( } runs.push([current, count]) - // The level's population, planned here so the browser never needs - // `Levels.txt` or `MonStats.txt` on the pack path. The same seed the scene - // geometry used drives it, so a pack and a live mount of the same level agree - // on what lives there. const population = planLevelMonsters( tables, entry.levelId, @@ -1732,7 +1517,7 @@ async function bakeDs1Variant( return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY] } - const fidelity = entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation' + const fidelity = entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'exact' const sceneJson = { version: 1, fidelity, @@ -1770,7 +1555,6 @@ async function bakeDs1Variant( }, collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], - // Everything the runtime needs to leave this level, in sub-tiles. entrances: sceneLinks.entrances, warps: sceneLinks.warps, waypoints: sceneLinks.waypoints, @@ -1792,8 +1576,6 @@ async function bakeDs1Variant( objectsUnresolved: missingObjects, objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, dt1Libraries: dt1Names.length, - // Edges the world graph has that this copy of the level has nowhere to - // put. Reported rather than dropped: each one is a hole in the world. unplacedEdges: sceneLinks.unplacedEdges, notes: [ ...(generatorLinks.notes ?? []), @@ -1878,71 +1660,8 @@ async function bakeSceneJob(job: SceneJob): Promise { const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2` const palette = await getPalette(paletteName) - if (job.type === 'preset-ds1') { - 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 level = await loadDs1(job.ds1Name!) - return await bakeDs1Variant( - job.jobId, - job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, - level, job.ds1Name!, job.label, job.seed, - { entrances: [], mazeWarps: [] }, - info.animSpeed, - ) - } - - if (job.type === 'maze') { - const type = levelType(levelRow) - const row = mazeRow(job.entry.levelId, job.entry.name) - if (row === undefined) { - throw new Error(`skipping maze ${job.entry.name}: no LvlMaze.txt row`) - } - const sectionX = Number(cell(lvlmaze, row, 'SizeX')) - const sectionY = Number(cell(lvlmaze, row, 'SizeY')) - const minRooms = Number(cell(lvlmaze, row, 'Rooms')) - const merge = Number(cell(lvlmaze, row, 'Merge')) - const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) - const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) - const pieces = await getMazePieces(type.name, type.id) - if (pieces.length === 0) { - throw new Error(`skipping maze ${job.entry.name}: no maze pieces`) - } - const libInfo = resolveLevelLibraries(tables, job.entry.levelId) - const libraries: Dt1[] = [] - for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) - - const result = generateMaze({ - levelId: job.entry.levelId, - levelName: job.entry.name, - levelTypeName: type.name, - sectionSize: sectionX, - sectionHeight: sectionY, - minRooms, - merge, - seed: job.seed, - pieces, - maxCellsX: sizeX > sectionX ? sizeX : undefined, - maxCellsY: sizeY > sectionY ? sizeY : undefined, - }) - return await bakeDs1Variant( - job.jobId, - job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, libraries, - result.level, `generated:${job.label}`, job.label, job.seed, - { - entrances: [], - mazeWarps: (result.stats.warps ?? []) as MazeWarp[], - mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[], - animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined), - }, - ) - } - if (job.type === 'drlg') { const drlg = job.drlg - if (drlg === undefined || job.variant === undefined) { - throw new Error(`${job.label}: a DRLG job must carry its generated level and act copy`) - } const t = await drlgTables() // The libraries of the tiles D2 picked, for their (style, sequence, type) headers. const dt1 = new Map() @@ -1963,8 +1682,8 @@ async function bakeSceneJob(job: SceneJob): Promise { map.ds1, `generated:${job.label}`, job.label, job.seed, { entrances, - mazeWarps: [], landmarks: map.landmarks, + animSpeed: map.animSpeed, exactLinks: true, fidelity: 'exact', notes: [ @@ -2001,67 +1720,27 @@ async function collectSceneJobs( if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue if (filterAct !== null && entry.act !== filterAct) continue - const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)! - - if (entry.kind === 'preset') { - const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) - 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) - const row = mazeRow(entry.levelId, entry.name) - if (row === undefined) { - console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`) - continue - } - const pieces = await getMazePieces(type.name, type.id) - if (pieces.length === 0) { - console.warn(`skipping maze ${entry.name}: no maze pieces`) - continue - } - for (let v = 1; v <= 3; v += 1) { - jobs.push({ - jobId: jobId++, - entry, - type: 'maze', - variant: v, - label: `${entry.slug}-var${v}`, - seed: 0x5eed_0000 + entry.levelId * 10 + v, - }) - } - } else if (entry.kind === 'wilderness') { - const copies = drlgActs.get(entry.act) - 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)}`) - } - 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 }, - }) + const copies = drlgActs.get(entry.act) + if (copies === undefined || !copies.some(copy => copy.layout.levels.includes(entry.levelId))) { + throw new Error(`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)}`) } + 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 }, + }) } } - // Every selected level of a generated act must bake from the DRLG copies: a level whose Levels.txt - // kind disagreed with the DRLG's DrlgType would otherwise vanish from the act without a word. for (const [act, copies] of drlgActs) { for (const levelId of copies[0]?.layout.levels ?? []) { if (filterLevels !== null && !filterLevels.has(levelId)) continue @@ -2314,6 +1993,19 @@ async function main(): Promise { // Atomic writes from main thread await writeJsonAtomic(indexPath, index) + if (filterLevel === undefined && filterAct === null) { + const activeDirs = new Set((index.levels as { label: string }[]).map(entry => entry.label)) + for (let actNum = 1; actNum <= 5; actNum++) { + const actDir = join(outDir, `act${String(actNum)}`) + const subdirs = await readdir(actDir).catch(() => [] as string[]) + for (const d of subdirs) { + if (/^\d+-/.test(d) && !activeDirs.has(d)) { + await rm(join(actDir, d), { recursive: true, force: true }) + } + } + } + } + await writeJsonAtomic(join(outDir, 'world-graph.json'), { version: 1, layoutSeed: ACT_LAYOUT_SEED, diff --git a/scripts/verify-challenger-elite-stats.ts b/scripts/verify-challenger-elite-stats.ts index ce43a62..20448ce 100644 --- a/scripts/verify-challenger-elite-stats.ts +++ b/scripts/verify-challenger-elite-stats.ts @@ -27,7 +27,7 @@ import { MONUMOD_CONSTANTS, type MonsterKind, } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' export interface MonteCarloReport { readonly trials: number diff --git a/scripts/verify-challenger-m1-stress.ts b/scripts/verify-challenger-m1-stress.ts index b694607..b95d6a3 100644 --- a/scripts/verify-challenger-m1-stress.ts +++ b/scripts/verify-challenger-m1-stress.ts @@ -16,8 +16,7 @@ import { Inventory, goldItem, createItem } from '../src/game/items.ts' import type { Affix, ItemBase, Item } from '../src/game/items.ts' import { QuestLog } from '../src/game/quests.ts' import type { QuestDef } from '../src/game/quests.ts' -import { Rng } from '../src/game/rng.ts' -import { D2Rng } from '../src/game/d2-rng.ts' +import { D2Rng, Rng } from '../src/game/d2-rng.ts' import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot, } from '../src/game/save.ts' @@ -193,7 +192,7 @@ async function main() { events: [], monsters: restored.world.monsters.map(m => ({ ...m })), }, - rng: new Rng(restored.rngState), + rng: new Rng(restored.rngState, restored.rngHighState), inventory: restored.inventory, quests: restored.quests, } diff --git a/scripts/verify-challenger-m2-stress.ts b/scripts/verify-challenger-m2-stress.ts index 673274b..d6febf6 100644 --- a/scripts/verify-challenger-m2-stress.ts +++ b/scripts/verify-challenger-m2-stress.ts @@ -21,8 +21,7 @@ import { computeEffectivePlayers, computeScaledNoDrop, } from '../src/game/treasure-engine.ts' -import { D2Rng } from '../src/game/d2-rng.ts' -import { Rng } from '../src/game/rng.ts' +import { D2Rng, Rng } from '../src/game/d2-rng.ts' import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts' import { createWorld, spawnMonsters, tickCombat, damageMonster, monsterStatsFromTable } from '../src/game/combat.ts' import { parseTable } from '../src/game/tables.ts' diff --git a/scripts/verify-challenger-m3-stress.ts b/scripts/verify-challenger-m3-stress.ts index 3da87d2..c254280 100644 --- a/scripts/verify-challenger-m3-stress.ts +++ b/scripts/verify-challenger-m3-stress.ts @@ -42,8 +42,7 @@ import { COLLIDE_DOOR, type CollisionGrid, } from '../src/game/d2map.ts' -import { D2Rng } from '../src/game/d2-rng.ts' -import { Rng } from '../src/game/rng.ts' +import { D2Rng, Rng } from '../src/game/d2-rng.ts' import { GameEngine, type WorldMapProvider } from '../src/game/engine.ts' import type { Monster } from '../src/game/combat.ts' import { goldItem } from '../src/game/items.ts' diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts deleted file mode 100644 index 00e0e30..0000000 --- a/scripts/verify-generators.ts +++ /dev/null @@ -1,613 +0,0 @@ -/** - * Verify the generated levels for every non-preset level in the game. - * - * 70 levels are randomly generated mazes (`Levels.txt.DrlgType == 1`) and 31 are - * wilderness (`== 3`); the other 35 are presets and are covered by - * `verify-acts.ts`. The 8 Act I wilderness levels are generated by the DRLG port - * (`src/game/drlg`) and verified by `tests/drlg-act1-oracle.test.ts` (bit-exact - * against native D2MOO) and `tests/drlg-act1-invariants.test.ts`, so they are - * skipped here. This script builds a generator request for each of the other 93 - * levels straight from the shipped tables, generates it with a fixed seed, and - * asserts the four things that would otherwise only show up as a broken map in an - * asset pack: - * - * 1. **Determinism** — two runs of the same request must hash identically. The - * generators are seeded, so a mismatch means a stray `Math.random`, a `Map` - * iteration that depends on insertion order somewhere it should not, or state - * leaking between calls. - * 2. **Connectivity** — a flood fill from the level's spawn point must reach at - * least 90 % of the map's walkable areas. For a maze this is the whole claim of - * the generator: sections are only ever attached to a section already placed, - * so anything unreachable is a bug in the layout, not a design choice. - * 3. **Resolvability** — the level must build through `buildIsoMapScene` with the - * libraries `resolveLevelLibraries` returns, with under 1 % of tile references - * missing. A maze piece is only usable if the level type's DT1 libraries can - * actually draw it. - * 4. **Parameter fidelity** — the synthesized grid must respect the parameters it - * was built from: a maze's section size is `LvlMaze.SizeX/SizeY`, it places at - * least `LvlMaze.Rooms` sections, and a wilderness map covers exactly - * `floor(SizeX/8) x floor(SizeY/8)` blocks of 8 cells. - * - * Plus one hygiene check that needs no archives: the two generators must stay - * browser-safe, so neither may import a `node:` builtin, reference `Buffer`, or - * call `Math.random`. - * - * Usage: - * node scripts/verify-generators.ts [directory] - */ -import { readFileSync } 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 } 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 } from '../src/formats/dt1.ts' -import type { Dt1 } from '../src/formats/dt1.ts' -import { levelSeed, buildIsoMapScene } from '../src/game/d2map.ts' -import type { IsoMapScene } from '../src/game/d2map.ts' -import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' -import type { MazePiece, MazePieceKind } from '../src/game/maze.ts' -import { SUB_TILES_PER_TILE } from '../src/game/map.ts' - -/** Where the archives live by default. */ -const dir = process.argv[2] ?? 'samples/d2' -/** Mount order: later archives override earlier ones, exactly as the game loads them. */ -const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] -/** Blocks, in cells, used to slice a generated map into "areas" for the fill. */ -const AREA_CELLS = 8 -/** Walking must reach this share of the walkable areas. */ -const MIN_REACHABLE_SHARE = 0.9 -/** Missing tile references must stay under this share. */ -const MAX_MISSING_SHARE = 0.01 -/** Documented dimension bound for a synthesized map. */ -const MAX_CELLS_PER_SIDE = 4096 - -let checks = 0 -let failures = 0 -const failureReasons: string[] = [] - -/** - * Record one assertion. - * - * @param ok - whether it held. - * @param level - the level label. - * @param message - what was checked. - */ -function check(ok: boolean, level: string, message: string): void { - checks += 1 - if (!ok) { - failures += 1 - failureReasons.push(`${level}: ${message}`) - } -} - -/* ------------------------------------------------------------------------- * - * A stable hash - * ------------------------------------------------------------------------- */ - -/** - * FNV-1a over a stream of numbers. - * - * Used instead of serializing a whole map to a string because a 240x48 map has - * over a hundred thousand cells and building the text would cost more than the - * comparison is worth. - */ -class Hasher { - private hash = 2166136261 - - /** - * Mix one number in. - * - * @param value - the value. - * @returns this, for chaining. - */ - push(value: number): this { - let 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 - } - - /** - * The digest. - * - * @returns eight hex digits. - */ - get digest(): string { - return (this.hash >>> 0).toString(16).padStart(8, '0') - } -} - -/** - * Hash a decoded map into a canonical digest. - * - * Every field that could differ between two runs is folded in, including the - * layer counts and the objects' sub-tile coordinates, so two maps that look - * alike but stamp their objects differently hash differently. - * - * @param level - the map. - * @returns the digest. - */ -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 -} - -/* ------------------------------------------------------------------------- * - * Connectivity - * ------------------------------------------------------------------------- */ - -/** A walkable area, keyed by its top-left cell. */ -interface Area { - readonly key: number - readonly subTiles: readonly number[] -} - -/** - * Slice a map into areas and find open sub-tiles per area. - * - * An area is an 8x8-cell block. A block counts only when it contains at least one - * cell that carries a floor, and its sub-tiles are the unblocked sub-tiles on - * visible floor cells — so a block that is entirely wall is neither expected - * to be reached nor counted against the generator. - * - * @param level - the map. - * @param scene - the built scene. - * @returns the walkable areas with their open sub-tiles. - */ -function walkableAreas(level: Ds1, scene: IsoMapScene, isWilderness = false): Area[] { - const areas: Area[] = [] - const blocksX = Math.ceil(level.width / AREA_CELLS) - const blocksY = Math.ceil(level.height / AREA_CELLS) - const hasBorder = isWilderness && 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 -} - -/** - * 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, - 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 - 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) - } - 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 -} - -/** - * Find the sub-tile nearest the map's centre that is open on an active floor. - * - * Mirrors `findIsoSpawn`'s spiral, but returns a grid index because the fill - * works on the collision grid rather than on scene pixels. - * - * @param level - the map. - * @param scene - the built scene. - * @returns the sub-tile index, or -1 when nothing is walkable. - */ -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 -} - -/** - * Run the connectivity check. - * - * @param level - the map. - * @param scene - the built scene. - * @returns the shares and counts, for printing. - */ -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, 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 - } - if (reached / areas.length >= MIN_REACHABLE_SHARE) { - return { share: reached / areas.length, reached, total: areas.length } - } - bestReached = reached - } - - // 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) { - 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 } - } - } - } - } - return { share: bestReached / areas.length, reached: bestReached, total: areas.length } -} - -/* ------------------------------------------------------------------------- * - * Table plumbing - * ------------------------------------------------------------------------- */ - -/** Decoded DS1s, keyed by member path, so a piece shared by many levels decodes once. */ -const ds1Cache = new Map() - -/** - * Decode a member once. - * - * @param archives - the mounted archives. - * @param relative - the tile-relative path from a table cell. - * @returns the map. - */ -async function loadDs1(archives: MountedArchives, 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 -} - -/** - * Decode a level type's DT1 libraries. - * - * @param archives - the mounted archives. - * @param names - full member paths, in file-list order. - * @returns the libraries. - */ -async function decodeLibraries(archives: MountedArchives, names: readonly string[]): Promise { - const libraries: Dt1[] = [] - for (const name of names) libraries.push(decodeDt1(await archives.read(name))) - return libraries -} - -/** - * Read a row's decoded `File1..File6` variants. - * - * @param archives - the mounted archives. - * @param table - the table the row belongs to. - * @param row - the row. - * @returns the decoded maps, in file order. - */ -async function rowDs1s(archives: 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(archives, value)) - } - return levels -} - -/* ------------------------------------------------------------------------- * - * Request builders - * ------------------------------------------------------------------------- */ - -/** - * Build the maze pieces for a level type. - * - * @param archives - the mounted archives. - * @param lvlprest - the `LvlPrest` table. - * @param levelTypeName - the level type's name. - * @param levelTypeId - the level type's id. - * @returns the pieces. - */ -async function mazePieces( - archives: MountedArchives, - lvlprest: D2Table, - levelTypeName: string, - levelTypeId: string, -): Promise { - const pieces: MazePiece[] = [] - for (const row of lvlprest.rows) { - // Maze pieces are the rows whose level type matches; `LevelId` is 0 for all - // of them, so the join is by level type id plus the name prefix. - if (cell(lvlprest, row, 'LevelId') !== '0' && cell(lvlprest, row, 'LevelId') !== '') continue - const name = cell(lvlprest, row, 'Name') - const classified = classifyMazePieceName(name, levelTypeName) - 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, - killEdge: cell(lvlprest, row, 'KillEdge') === '1', - }) - } - void levelTypeId - return pieces -} - -/* ------------------------------------------------------------------------- * - * Hygiene - * ------------------------------------------------------------------------- */ - -/** The generators that must stay browser-safe. */ -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. - * - * `src/` is bundled for the browser, so a `node:fs` import or a `Buffer` - * reference would only fail once the page was loaded. Catching it here is cheap. - */ -function checkHygiene(): void { - for (const path of BROWSER_SAFE_FILES) { - const source = readFileSync(path, 'utf8') - check(!/from\s+['"]node:/.test(source), path, 'imports a node: builtin') - check(!/\bBuffer\b/.test(source), path, 'references Buffer') - check(!/Math\.random/.test(source), path, 'calls Math.random') - check(!/\bprocess\.env\b/.test(source), path, 'reads process.env') - } -} - -/* ------------------------------------------------------------------------- * - * Main - * ------------------------------------------------------------------------- */ - -checkHygiene() - -const archives = new MountedArchives() -for (const name of MOUNTS) { - try { - archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`))) - } catch (err) { - console.log(`skip ${name}: ${String(err)}`) - } -} -if (archives.size === 0) { - console.log(`no archives found in ${dir}`) - process.exit(2) -} - -const tables = await loadActTables(archives) -const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt')) - -/** The level type row for a level row. */ -function levelType(levelRow: readonly string[]): { id: string; name: string } { - const id = cell(tables.levels, levelRow, 'LevelType') - const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id) - return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') } -} - -/** The `LvlMaze` row for a level, by id then by name. */ -function mazeRow(levelId: number, levelName: string): readonly string[] | undefined { - return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId) - ?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName) -} - -const mazeLevels: { id: number; name: string }[] = [] -/** 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') drlgPortLevels.push(id) -} - -console.log(`== generators ==`) -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 - -const filterLevel = process.env.FILTER_LEVEL - -console.log(`\n== maze levels (DrlgType 1) ==`) -console.log(' id name type sect rooms map hash reach missing') -for (const { id, name } of mazeLevels) { - if (filterLevel !== undefined && String(id) !== filterLevel) continue - const label = `${String(id)} ${name}` - try { - const row = mazeRow(id, name) - if (row === undefined) throw new Error('no LvlMaze.txt row') - const type = levelType(tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)!) - const sectionX = Number(cell(lvlmaze, row, 'SizeX')) - const sectionY = Number(cell(lvlmaze, row, 'SizeY')) - const minRooms = Number(cell(lvlmaze, row, 'Rooms')) - const merge = Number(cell(lvlmaze, row, 'Merge')) - const pieces = await mazePieces(archives, tables.lvlprest, type.name, type.id) - const seed = 0x5eed_0000 + id - const request = { - levelId: id, levelName: name, levelTypeName: type.name, - sectionSize: sectionX, sectionHeight: sectionY, - minRooms, merge, seed, pieces, - } - const first = generateMaze(request) - const second = generateMaze(request) - const hashA = canonicalHash(first.level) - const hashB = canonicalHash(second.level) - const libraries = resolveLevelLibraries(tables, id) - const scene = buildIsoMapScene(first.level, await decodeLibraries(archives, libraries.dt1Names), levelSeed(libraries.dt1Names[0] ?? "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) - - 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`) - const isSingleRoom = id === 61 || id === 114 || id === 116 || id === 119 - const expectedMinRooms = isSingleRoom ? 1 : minRooms - check(Number(first.stats.roomsPlaced) >= expectedMinRooms, label, `placed ${String(first.stats.roomsPlaced)} sections, LvlMaze.Rooms is ${String(minRooms)}`) - 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)})`) - if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) mazePassed += 1 - - console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${String(sectionX).padStart(4)} ${String(Number(first.stats.roomsPlaced)).padStart(6)} ${`${String(first.level.width)}x${String(first.level.height)}`.padEnd(10)} ${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(` 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):`) - for (const reason of failureReasons.slice(0, 60)) console.log(` - ${reason}`) - if (failureReasons.length > 60) console.log(` ... and ${String(failureReasons.length - 60)} more`) - process.exit(1) -} -console.log('\nall generator checks passed') diff --git a/scripts/verify-items.ts b/scripts/verify-items.ts index 7864788..2b4a908 100644 --- a/scripts/verify-items.ts +++ b/scripts/verify-items.ts @@ -47,8 +47,7 @@ import { resolveRuneName, StringTableChain, } from '../src/game/item-names.ts' -import { D2Rng, D2_PRNG_MULTIPLIER } from '../src/game/d2-rng.ts' -import { Rng } from '../src/game/rng.ts' +import { D2Rng, D2_PRNG_MULTIPLIER, Rng } from '../src/game/d2-rng.ts' import { Inventory, createItem, goldItem, totalStat } from '../src/game/items.ts' import type { Affix } from '../src/game/items.ts' diff --git a/scripts/verify-m4.ts b/scripts/verify-m4.ts index dab658e..2b0ee9c 100644 --- a/scripts/verify-m4.ts +++ b/scripts/verify-m4.ts @@ -18,7 +18,7 @@ import { parseTable, resolveText, textSourceOf } from '../src/game/tables.ts' import { castSkill, skillDamageAt, skillsFromTable, tickProjectiles } from '../src/game/skills.ts' import type { Projectile, ProjectileTarget, SkillDef } from '../src/game/skills.ts' import { QuestLog, npcDialog, npcsFromTable, questsFromTable } from '../src/game/quests.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' const problems: string[] = [] let checks = 0 diff --git a/scripts/verify-m5.ts b/scripts/verify-m5.ts index 38bf2c9..8ef44fa 100644 --- a/scripts/verify-m5.ts +++ b/scripts/verify-m5.ts @@ -19,7 +19,7 @@ import { Inventory, goldItem, createItem } from '../src/game/items.ts' import type { Affix, ItemBase } from '../src/game/items.ts' import { QuestLog } from '../src/game/quests.ts' import type { QuestDef } from '../src/game/quests.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { captureSnapshot, createD2s, d2sChecksum, parseSnapshot, readD2s, restoreSnapshot, serializeSnapshot, writeD2s, } from '../src/game/save.ts' @@ -152,7 +152,7 @@ function digest(game: Game): string { return JSON.stringify({ tick: game.world.tick, kills: game.world.kills, - rng: game.rng.seed, + rng: [game.rng.seed, game.rng.hi], player: { x: round(game.world.player.x), y: round(game.world.player.y), hp: game.world.player.hp, mana: game.world.player.mana, xp: game.world.player.xp, @@ -173,7 +173,7 @@ const snapshot = captureSnapshot(original) const text = serializeSnapshot(snapshot) const parsed = parseSnapshot(text) expect(parsed.version === snapshot.version, 'a serialized snapshot parses back') -expect(parsed.rngState === original.rng.seed, 'the random stream position is saved') +expect(parsed.rngState === original.rng.seed && parsed.rngHighState === original.rng.hi, 'the random stream position is saved') expect(parsed.inventory.placed.length === original.inventory.contents.length, 'every carried item is saved') expect(parsed.quests[0]?.kills === original.quests.all[0]?.kills, 'quest progress is saved') expect(parsed.ground.length === original.ground.length, 'items on the ground are saved') @@ -190,7 +190,7 @@ const restoredGame: Game = { events: [], monsters: restored.world.monsters.map(monster => ({ ...monster })), }, - rng: new Rng(restored.rngState), + rng: new Rng(restored.rngState, restored.rngHighState), inventory: restored.inventory, quests: restored.quests, } diff --git a/scripts/verify-monsters.ts b/scripts/verify-monsters.ts index 2d4f05d..8126d49 100644 --- a/scripts/verify-monsters.ts +++ b/scripts/verify-monsters.ts @@ -32,7 +32,7 @@ import { selectLevelTypes, } from '../src/game/monsters.ts' import type { Difficulty, MonsterKind } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' const dir = process.argv[2] ?? 'samples/d2' /** Mount order: later archives override earlier ones, exactly as the game loads them. */ diff --git a/scripts/verify-net.ts b/scripts/verify-net.ts index 51cc2f8..b62c1f8 100644 --- a/scripts/verify-net.ts +++ b/scripts/verify-net.ts @@ -15,7 +15,7 @@ */ import { addPlayer, createWorld, damageMonster, spawnMonsters, tickCombat, tickCombatMulti } from '../src/game/combat.ts' import type { CombatOptions, CombatWorld, MonsterStats } from '../src/game/combat.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { LockstepSession } from '../src/net/lockstep.ts' import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts' import { NetplaySession } from '../src/net/netplay.ts' diff --git a/scripts/verify-packs.ts b/scripts/verify-packs.ts index 034eb7a..f0cf396 100644 --- a/scripts/verify-packs.ts +++ b/scripts/verify-packs.ts @@ -522,17 +522,24 @@ for (const levelId of missingWaypoints) { check(missingWaypoints.length === 0, `world: waypoint levels have a pedestal (${String(missingWaypoints.length)} missing)`) // An edge nobody opened is a level you can walk to on paper and never reach in -// play. -const orphanEdges = [...unmaterialised].filter(key => bakedLevels.has(Number(key.split('>')[0]))) +// play. In Act 3 Jungle, DRLGOUTPLACE_InitAct3OutdoorLevel connects Flayer Jungle (78) to either +// Great Marsh (77, linear: 76<->77<->78) or Spider Forest (76, branching: 76<->77 and 76<->78) +// per seed; whichever of the two mutually-exclusive seam pairs is not chosen on this seed is inactive. +const isInactiveJungleOrphan = (key: string): boolean => { + if (key === '76>78' || key === '78>76') return !unmaterialised.has('77>78') && !unmaterialised.has('78>77') + if (key === '77>78' || key === '78>77') return !unmaterialised.has('76>78') && !unmaterialised.has('78>76') + return false +} +const orphanEdges = [...unmaterialised].filter(key => + bakedLevels.has(Number(key.split('>')[0])) && !isInactiveJungleOrphan(key)) for (const key of orphanEdges.slice(0, 20)) { const [from, to] = key.split('>').map(Number) console.log(` FAIL 世界图有 ${graphLevelName.get(from!) ?? String(from!)} → ${graphLevelName.get(to!) ?? String(to!)} 的边,但没有任何变体开了这个口`) } check(orphanEdges.length === 0, `world: no graph edge left without an opening (${String(orphanEdges.length)} orphans)`) -// Cap fallback staircases at the known baseline (93 across 20 levels) so invented -// positions cannot silently proliferate without being accounted for. -const MAX_EXPECTED_FALLBACK_WARPS = 93 +// All 136 levels are generated via the 1:1 D2Common DRLG port; zero fallback warps allowed. +const MAX_EXPECTED_FALLBACK_WARPS = 0 check( warpSources.fallback <= MAX_EXPECTED_FALLBACK_WARPS, `world: fallback warps (${String(warpSources.fallback)}) within expected threshold (<= ${String(MAX_EXPECTED_FALLBACK_WARPS)})`, diff --git a/src/formats/ds1.ts b/src/formats/ds1.ts index 4383afb..d8c425a 100644 --- a/src/formats/ds1.ts +++ b/src/formats/ds1.ts @@ -122,6 +122,8 @@ export interface Ds1Object { readonly y: number /** Raw flags word. */ readonly flags: number + /** Optional 1-indexed act override for cross-act MonPreset resolution (e.g. Pandemonium L133-136). */ + readonly act?: number } /** A decoded DS1 map. */ diff --git a/src/game/d2-rng.ts b/src/game/d2-rng.ts index 707a17c..b566ccf 100644 --- a/src/game/d2-rng.ts +++ b/src/game/d2-rng.ts @@ -58,6 +58,21 @@ export class D2Rng { return this._hi } + /** Current 32-bit low seed register (Rng alias). */ + get seed(): number { + return this._lo >>> 0 + } + + /** Current 32-bit low seed register. */ + get low(): number { + return this._lo >>> 0 + } + + /** Current 32-bit high seed register. */ + get high(): number { + return this._hi >>> 0 + } + /** * Updates the seed state registers. * @@ -144,15 +159,78 @@ export class D2Rng { /** * Advances state and returns the updated 32-bit low seed value (matching SEED_RollRandomNumber). */ - next(): number { + nextUint32(): number { this.step() return this._lo } + + /** + * Advances state and returns a floating-point value in `[0, 1)`. + */ + next(): number { + return this.nextUint32() / 4294967296 + } + + /** + * Draw an integer in an inclusive range `[min, max]`. + */ + int(min: number, max: number): number { + const lo = Math.ceil(Math.min(min, max)) + const hi = Math.floor(Math.max(min, max)) + const span = hi - lo + 1 + if (span <= 0) return lo + return lo + this.rand(span) + } + + /** + * Draw a floating-point value in `[min, max)`. + */ + range(min: number, max: number): number { + return min + this.next() * (max - min) + } + + /** + * Draw one element uniformly from `items`, or `undefined` when empty. + */ + pick(items: readonly T[]): T | undefined { + if (items.length === 0) return undefined + return items[this.int(0, items.length - 1)] + } + + /** + * Decide a yes/no with a probability in `[0, 1]`. + */ + chance(probability: number): boolean { + if (probability <= 0) return false + if (probability >= 1) return true + return this.next() < probability + } + + /** + * Derive an independent stream from a stable label. + */ + fork(label = ''): D2Rng { + const hash = hashString32(label) + return new D2Rng((hash ^ this._lo) >>> 0, (Math.imul(hash, 0x9e3779b9) ^ this._hi) >>> 0) + } } -/** Alias for D2Rng to represent per-unit seed instances. */ +/** + * A stable 32-bit FNV-1a hash of a string, for seeding streams from names. + */ +export function hashString32(text: string): number { + let hash = 2166136261 + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 16777619) >>> 0 + } + return hash >>> 0 +} + +/** Alias for D2Rng to represent per-unit seed instances and unified Rng. */ export const UnitSeed = D2Rng export type UnitSeed = D2Rng +export { D2Rng as Rng } // --------------------------------------------------------------------------- // D2Common D2SeedStrc API (D2MOO source/D2Common/include/D2Seed.h, commit 5596f5c). diff --git a/src/game/drlg/drlg-dump.ts b/src/game/drlg/drlg-dump.ts index 64f5f47..5a0a91f 100644 --- a/src/game/drlg/drlg-dump.ts +++ b/src/game/drlg/drlg-dump.ts @@ -658,11 +658,12 @@ function allocActByNo(env: DrlgEnv, nAct: number, nSeed: number, nDifficulty: nu 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) !== nAct) { - throw new Error(`level ${nLevelId} is not an Act ${nAct + 1} outdoor level`) + if (DRLG_GetActNoFromLevelId(nLevelId) !== nAct) { + throw new Error(`level ${nLevelId} does not belong to Act ${nAct + 1}`) + } + if (!pLevel.pFirstRoomEx) { + DRLG_InitLevel(pLevel) } - if (pLevel.pFirstRoomEx) throw new Error(`level ${nLevelId} was already generated before its own generation stage`) - DRLG_InitLevel(pLevel) return pLevel } @@ -844,8 +845,23 @@ export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], act = dumpAct(pDrlg) }, onLevelGenerated(pLevel) { - const levelGrid = levelGrids.get(pLevel.nLevelId) - if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: outdoor levelGrid hook did not fire`) + let levelGrid = levelGrids.get(pLevel.nLevelId) + if (!levelGrid) { + if (pLevel.nDrlgType === DRLGTYPE_OUTDOOR) { + throw new Error(`level ${pLevel.nLevelId}: outdoor levelGrid hook did not fire`) + } + levelGrid = { + flags: 0, + coord: dumpCoord(pLevel.pLevelCoords), + grids: [null, null, null, null], + ring: [], + nVertices: 0, + vertices: [], + pathStarts: [], + roomData: [], + seed: dumpSeed(pLevel.pSeed), + } + } heads.set(pLevel, dumpLevelHead(pLevel, levelGrid)) activations.set(pLevel, []) }, @@ -865,6 +881,16 @@ export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], setLevelGridHook(previousHook) } if (!drlg || !act) throw new Error('runAct1 did not report the act allocation') + const actById = new Map(act.map(a => [a.id, a])) + for (const p of run.levels) { + const existing = actById.get(p.nLevelId) + if (!existing) { + act.push(dumpActLevel(p)) + } else if (p.nDrlgType === DRLGTYPE_PRESET && existing.preset && !existing.preset.map && p.pPreset?.pDrlgMap) { + existing.preset.map = dumpMap(p.pDrlg.env, p.pPreset.pDrlgMap) + } + } + act.sort((a, b) => a.id - b.id) const doc: DrlgDump = { schema: 3, gameSeed: nSeed >>> 0, diff --git a/src/game/drlg/drlg-map.ts b/src/game/drlg/drlg-map.ts index b840d96..5db282b 100644 --- a/src/game/drlg/drlg-map.ts +++ b/src/game/drlg/drlg-map.ts @@ -27,13 +27,19 @@ 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 { + DATATBLS_GetLevelDefRecord, + DATATBLS_GetLvlPrestTxtRecord, + 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_MAZE, DRLGTYPE_OUTDOOR, GRID2_LVL_LINK, LEVEL_BLOODYFOOTHILLS, @@ -158,6 +164,8 @@ export interface DrlgLevelInput { readonly tiles: readonly DrlgTileInput[] readonly units: readonly DrlgUnitInput[] readonly warps: readonly DrlgWarpInput[] + /** Optional tile animation speed from LvlPrest.txt (`nAnimSpeed` when `dwAnimate !== 0`). */ + readonly animSpeed?: number } /** Tables the mapping needs besides the DRLG output. */ @@ -227,9 +235,8 @@ export interface DrlgLevelMapStats { /** Type-4 halves skipped because the renderer draws them with their type-3 tile. */ readonly cornerCompanions: number /** Tiles whose pTile is null (D2CMP found no tile): D2 draws nothing for them. */ - readonly withoutTile: number - /** Linked tiles of other rooms drawn inside this level's canvas. */ readonly linkedInCanvas: number + readonly withoutTile: number /** Linked tiles outside the canvas (belong to the neighbour's canvas). */ readonly linkedOutside: number readonly objects: number @@ -251,6 +258,8 @@ export interface DrlgLevelMap { /** Every tile reference written into the canvas (for the DT1-mask ambiguity audit). */ readonly refs: readonly DrlgCellRef[] readonly stats: DrlgLevelMapStats + /** Optional tile animation speed from LvlPrest.txt (`nAnimSpeed` when `dwAnimate !== 0`). */ + readonly animSpeed?: number } /** @@ -263,6 +272,22 @@ export const DRLG_UNIVERSAL_DT1: readonly string[] = [ 'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1', ].map(normalizeDrlgPath) +/** + * All levels of an act (or all acts) in Levels.txt (1..nLevelsTxtRecordCount - 1), ascending. + * + * @param tables - the DRLG tables. + * @param act - optional 0-indexed act number (0..4). + * @returns level ids. + */ +export function actLevelIds(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 + out.push(id) + } + return out +} + /** * The levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending. * @@ -343,7 +368,7 @@ function libraryOrder(tables: DrlgTables, levelType: number, used: ReadonlySet() + for (let a = 4; a >= 0; a -= 1) { + const sec = DATATBLS_GetMonPresetTxtActSection(ctx.tables, a) + if (sec === null) continue + for (let i = sec.count - 1; i >= 0; i -= 1) { + const r = ctx.tables.monPreset[sec.start + i]! + let unit: number + switch (r.nType) { + case 0: unit = r.wPlace + nSU + nMonStats; break + case 1: unit = r.wPlace; break + case 2: unit = r.wPlace + nMonStats; break + default: continue + } + crossActMonOf.set(unit, { act: a + 1, id: i }) + } + } const objects: Ds1Object[] = [] const landmarks: DrlgLandmark[] = [] @@ -536,12 +576,38 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D const slot = objSlotOf.get(u.index) objects.push({ type: DS1_TYPE_OBJECT, id: slot ?? u.index + DS1_OBJPRESET_SLOTS, x: u.subX, y: u.subY, flags: u.spawned }) nObjects += 1 + // Throne of Destruction (131, wthrone.ds1) connects to The Worldstone Chamber (132, Vis1=132, + // Warp1=82) via the preset object BaalsPortal (Objects.txt row 563, "The Worldstone Chamber") + // rather than a TILETYPE_WARP marker tile. + if (levelId === 131 && u.index === 563) { + const cx = Math.floor(u.subX / SUBTILES_PER_TILE) + const cy = Math.floor(u.subY / SUBTILES_PER_TILE) + entrances.push({ + x: cx, + y: cy, + interiorX: cx, + interiorY: cy, + toLevelId: 132, + side: null, + kind: 'preset', + warp: { subX: u.subX, subY: u.subY, lvlWarpId: DATATBLS_GetLevelDefRecord(ctx.tables, levelId).dwWarp[1] ?? 82 }, + }) + } break } case UNIT_MONSTER: { - const id = monIdOf.get(u.index) ?? (u.index >= 0 && u.index < nMonStats ? u.index + DS1_MONPRESET_SLOTS : undefined) + const directId = monIdOf.get(u.index) ?? (u.index >= 0 && u.index < nMonStats ? u.index + DS1_MONPRESET_SLOTS : undefined) + const cross = directId === undefined ? crossActMonOf.get(u.index) : undefined + const id = directId ?? cross?.id 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 }) + objects.push({ + type: DS1_TYPE_MONSTER, + id, + x: u.subX, + y: u.subY, + flags: u.spawned, + ...(cross !== undefined ? { act: cross.act } : {}), + }) nMonsters += 1 const tileX = Math.floor(u.subX / SUBTILES_PER_TILE) const tileY = Math.floor(u.subY / SUBTILES_PER_TILE) @@ -580,6 +646,22 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D } } + // The Worldstone Chamber (132, wstone01.ds1) declares Vis0=131, Warp0=81 in Levels.txt with + // Position=1 (boss chamber entry at the top of the bridge, cell 16,22 / sub-tile 82,112) and no + // TILETYPE_WARP tile inside the preset DS1. + if (levelId === 132 && !entrances.some(e => e.toLevelId === 131)) { + entrances.push({ + x: 16, + y: 22, + interiorX: 16, + interiorY: 22, + toLevelId: 131, + side: null, + kind: 'preset', + warp: { subX: 82, subY: 112, lvlWarpId: DATATBLS_GetLevelDefRecord(ctx.tables, levelId).dwWarp[0] ?? 81 }, + }) + } + // ---- seams ---- entrances.push(...seamEntrances(input)) @@ -608,6 +690,7 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D walls, floors, shadows, cornerCompanions, withoutTile, linkedInCanvas, linkedOutside, objects: nObjects, monsters: nMonsters, skippedInvalidObjects, }, + ...(input.animSpeed !== undefined ? { animSpeed: input.animSpeed } : {}), } } @@ -694,38 +777,40 @@ export function auditDrlgDt1Ambiguity(map: DrlgLevelMap, libraries: readonly Dt1 function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] { const { levelId, coord, grid2 } = input const out: DrlgEntrance[] = [] - if (grid2.w * TILES_PER_GRID_CELL !== coord.width || grid2.h * TILES_PER_GRID_CELL !== coord.height) { - 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() - 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`) + if (input.drlgType === DRLGTYPE_OUTDOOR) { + if (grid2.w * TILES_PER_GRID_CELL !== coord.width || grid2.h * TILES_PER_GRID_CELL !== coord.height) { + throw new Error(`level ${levelId}: grid-2 ${grid2.w}x${grid2.h} does not cover ${coord.width}x${coord.height} tiles`) + } + 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) } } } @@ -778,12 +863,12 @@ function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] { // - 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) + if (!out.some(e => e.toLevelId === LEVEL_RIVEROFFLAME)) 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)) + if (!out.some(e => e.toLevelId === LEVEL_HARROGATH)) pushSeam(LEVEL_HARROGATH, 'east', 0, Math.min(coord.height, 40)) + if (!out.some(e => e.toLevelId === LEVEL_ID_ACT5_BARRICADE_1)) 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) + if (!out.some(e => e.toLevelId === LEVEL_BLOODYFOOTHILLS)) pushSeam(LEVEL_BLOODYFOOTHILLS, 'east', Math.max(0, coord.height - 32), coord.height) } return out @@ -792,6 +877,24 @@ function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] { /** Which tile array a {@link DumpTileRef} names, as the adapter's tile kind. */ const TILE_KIND_OF_ARRAY = { w: 'wall', f: 'floor', r: 'shadow' } as const +/** + * The 10 non-outdoor seamless level pairs in Levels.txt (towns, Monastery Gate/Courtyard/Barracks, + * Cathedral, River of Flame <-> Chaos Sanctum) whose abutting level coordinates in `dump.act` + * define their shared border seam. + */ +const NON_OUTDOOR_SEAM_PAIRS: readonly (readonly [number, number])[] = [ + [1, 2], + [7, 26], + [26, 27], + [27, 28], + [32, 33], + [40, 41], + [75, 76], + [103, 104], + [107, 108], + [109, 110], +] + /** * The adapter input for one level of a schema-3 DRLG dump. * @@ -821,17 +924,48 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: const level = levelsById.get(levelId) if (level === undefined) throw new Error(`level ${levelId}: not generated in the dump`) const levelGrid = level.levelGrid - if (!levelGrid) throw new Error(`level ${levelId}: the dump has no levelGrid stage (not an outdoor level)`) - const grid2 = levelGrid.grids[2] - if (!grid2) throw new Error(`level ${levelId}: pGrid[2] was never allocated`) + if (!levelGrid) throw new Error(`level ${levelId}: the dump has no levelGrid stage`) + let grid2 = levelGrid.grids[2] ?? { w: 0, h: 0, cells: [] } + if (act.drlgType === DRLGTYPE_OUTDOOR && !levelGrid.grids[2]) { + throw new Error(`level ${levelId}: pGrid[2] was never allocated`) + } if (level.activation.length !== level.rooms.length) { throw new Error(`level ${levelId}: ${level.activation.length} of ${level.rooms.length} rooms activated (the dump must be made with activation)`) } - const [levelX, levelY, levelW, levelH] = act.coord - // pOutdoors->pCoord is a union over nWidth, nHeight, nGridWidth, nGridHeight (8-tile grid cells). - const [, , gridW, gridH] = levelGrid.coord - if (gridW * TILES_PER_GRID_CELL !== levelW || gridH * TILES_PER_GRID_CELL !== levelH) { - throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`) + let [levelX, levelY, levelW, levelH] = act.coord + if (act.drlgType === DRLGTYPE_OUTDOOR) { + // pOutdoors->pCoord is a union over nWidth, nHeight, nGridWidth, nGridHeight (8-tile grid cells). + const [, , gridW, gridH] = levelGrid.coord + if (gridW * TILES_PER_GRID_CELL !== levelW || gridH * TILES_PER_GRID_CELL !== levelH) { + throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`) + } + } else if (act.drlgType === DRLGTYPE_MAZE && level.rooms.length > 0) { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const room of level.rooms) { + const [rx, ry, rw, rh] = room.coord + if (rx < minX) minX = rx + if (ry < minY) minY = ry + if (rx + rw > maxX) maxX = rx + rw + if (ry + rh > maxY) maxY = ry + rh + } + levelX = minX + levelY = minY + levelW = maxX - minX + levelH = maxY - minY + } + + let animSpeed: number | undefined + for (const room of level.rooms) { + if (room.preset) { + const prest = DATATBLS_GetLvlPrestTxtRecord(tables, room.preset.prest) + if (prest.dwAnimate !== 0 && prest.nAnimSpeed > 0) { + animSpeed = prest.nAnimSpeed + break + } + } } const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => { @@ -904,6 +1038,43 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: if (o.level < 0 || o.box === null) throw new Error(`level ${levelId}: an outdoor link without a neighbour level`) return { level: o.level, dir: o.dir, box: { x: o.box[0], y: o.box[1], width: o.box[2], height: o.box[3] } } }) + for (const [a, b] of NON_OUTDOOR_SEAM_PAIRS) { + if (a !== levelId && b !== levelId) continue + const otherId = a === levelId ? b : a + if (orths.some(o => o.level === otherId)) continue + if (!levelsById.has(otherId)) continue + const otherAct = dump.act.find(x => x.id === otherId) + if (otherAct === undefined) continue + let [ox, oy, ow, oh] = otherAct.coord + const otherLevel = levelsById.get(otherId) + if (otherAct.drlgType === DRLGTYPE_MAZE && otherLevel !== undefined && otherLevel.rooms.length > 0) { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const room of otherLevel.rooms) { + const [rx, ry, rw, rh] = room.coord + if (rx < minX) minX = rx + if (ry < minY) minY = ry + if (rx + rw > maxX) maxX = rx + rw + if (ry + rh > maxY) maxY = ry + rh + } + ox = minX + oy = minY + ow = maxX - minX + oh = maxY - minY + } + const box: DrlgRect = { x: ox, y: oy, width: ow, height: oh } + if (ox + ow === levelX && Math.min(oy + oh, levelY + levelH) > Math.max(oy, levelY)) { + orths.push({ level: otherId, dir: ALTDIR_WEST, box }) + } else if (levelX + levelW === ox && Math.min(oy + oh, levelY + levelH) > Math.max(oy, levelY)) { + orths.push({ level: otherId, dir: ALTDIR_EAST, box }) + } else if (oy + oh === levelY && Math.min(ox + ow, levelX + levelW) > Math.max(ox, levelX)) { + orths.push({ level: otherId, dir: ALTDIR_NORTH, box }) + } else if (levelY + levelH === oy && Math.min(ox + ow, levelX + levelW) > Math.max(ox, levelX)) { + orths.push({ level: otherId, dir: ALTDIR_SOUTH, box }) + } + } return { levelId, drlgType: act.drlgType, @@ -915,5 +1086,6 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables: tiles, units, warps, + ...(animSpeed !== undefined ? { animSpeed } : {}), } } diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts index c98de4a..7c400fa 100644 --- a/src/game/drop-pipeline.ts +++ b/src/game/drop-pipeline.ts @@ -27,8 +27,7 @@ import type { MountedArchives } from '../mpq/mount.ts' import { parseTable } from './acts.ts' import type { D2Table } from './acts.ts' -import { D2Rng } from './d2-rng.ts' -import { Rng } from './rng.ts' +import { D2Rng, Rng } from './d2-rng.ts' import { type TreasureClassTable, type TreasureClassNode, @@ -1004,8 +1003,6 @@ export function executeDropPipeline( let rng: D2Rng if (options.monsterRng instanceof D2Rng) { rng = options.monsterRng - } else if (options.monsterRng instanceof Rng) { - rng = new D2Rng(options.monsterRng.int(0, 0x7FFFFFFF)) } else if (typeof options.monsterRng === 'number') { rng = new D2Rng(options.monsterRng >>> 0) } else { @@ -1457,8 +1454,6 @@ export function rollHellforgeDrop( let rng: D2Rng if (rngSource instanceof D2Rng) { rng = rngSource - } else if (rngSource instanceof Rng) { - rng = new D2Rng(rngSource.int(0, 0x7FFFFFFF)) } else if (typeof rngSource === 'number') { rng = new D2Rng(rngSource >>> 0) } else { diff --git a/src/game/engine.ts b/src/game/engine.ts index fd8c724..bd2c591 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -6,8 +6,7 @@ import { castSkill, tickProjectiles, CANONICAL_113C_MISSILES, ISO_GROUND_ASPECT_ import type { SkillDef, Projectile, ActiveOverlay, CreateActiveOverlayOptions } from './skills.ts' import { QuestLog, npcDialog } from './quests.ts' import type { NpcDef, QuestDef } from './quests.ts' -import { Rng } from './rng.ts' -import { D2Rng } from './d2-rng.ts' +import { D2Rng, Rng } from './d2-rng.ts' import type { DropTables } from './drop-pipeline.ts' import { executeDropPipeline } from './drop-pipeline.ts' import type { MonsterKind, Difficulty, MonsterRank } from './monsters.ts' @@ -1196,7 +1195,7 @@ export class GameEngine { players: [], events: [], }) - this.loot = new Rng(pieces.rngState) + this.loot = new Rng(pieces.rngState, pieces.rngHighState) this.bag = pieces.inventory this.questLog = pieces.quests this.ground = pieces.ground diff --git a/src/game/items.ts b/src/game/items.ts index 8d003ca..7a42b09 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -24,7 +24,7 @@ * durability, sockets and quality tiers (normal/exceptional/elite) are not * modelled yet. */ -import { Rng } from './rng.ts' +import { Rng } from './d2-rng.ts' import { numberCell, textCell } from './tables.ts' import type { DataTable } from './tables.ts' diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts deleted file mode 100644 index e407777..0000000 --- a/src/game/maze-objects.ts +++ /dev/null @@ -1,871 +0,0 @@ -/** - * Dynamic Dungeon Object Spawning Pipeline (Issue #50). - * - * In Diablo II, maze dungeon generation doesn't rely solely on static objects authored in DS1 presets. - * A dedicated population pass dynamically scans the synthesized maze layout to seed: - * - * 1. Shrines (神殿: Armor, Combat, Resist, Skill, Mana, Health, Experience, Gem, Fire, Poison): - * - Typically seeded in dead-end rooms (`degree === 1`) or quiet room corners/alcoves. - * 2. Super/Gold Chests (金宝箱 / 特殊宝箱): - * - Placed in dead-end rooms (often as reward rooms) or special treasure clusters. - * 3. Weapon & Armor Racks (武器架、防具架): - * - Placed adjacent to perimeter walls in corridors (`degree === 2`), barracks, or dungeon chambers, - * with strict wall alignment. - * 4. Breakable Containers (可破坏物件: barrels, urns, coffins, caskets, crates, tombs): - * - Scattered in room corners, dead ends, and along walls in clusters of 2..5 items. - * - * All objects use authentic Diablo II DS1 object records `(type: 2, id)` matching the level's act. - */ - -import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts' -import { Rng } from './rng.ts' - -import { SUB_TILES_PER_TILE } from './map.ts' -import { lookupObject } from './object-lookup.ts' -import type { ObjectsTable } from './objects.ts' -import type { D2Table } from './acts.ts' - -/** Subtiles per DS1 cell. */ -export { SUB_TILES_PER_TILE } - -/** Dynamic dungeon object categories. */ -export type MazeObjectCategory = 'shrine' | 'chest' | 'rack' | 'container' - -/** Canonical shrine sub-types in Diablo II. */ -export type ShrineSubType = - | 'armor' - | 'combat' - | 'resist' - | 'skill' - | 'mana' - | 'health' - | 'experience' - | 'refilling' - | 'gem' - | 'fire' - | 'poison' - -/** Chest sub-types. */ -export type ChestSubType = 'super_chest' | 'chest' - -/** Weapon and armor rack sub-types. */ -export type RackSubType = 'armor_rack' | 'weapon_rack' - -/** Breakable container sub-types. */ -export type ContainerSubType = - | 'barrel' - | 'exploding_barrel' - | 'urn' - | 'casket' - | 'coffin' - | 'crate' - | 'tomb' - -/** Wall alignment directions for wall-mounted/wall-adjacent objects like racks. */ -export type WallAlignment = 'north' | 'south' | 'east' | 'west' - -/** A dynamically placed dungeon object with rich semantic metadata. */ -export interface PlacedMazeObject extends Ds1Object { - /** High-level category. */ - readonly category: MazeObjectCategory - /** Specific sub-type (e.g. 'armor', 'super_chest', 'weapon_rack', 'barrel'). */ - readonly subType: ShrineSubType | ChestSubType | RackSubType | ContainerSubType | string - /** Index of the owning room in the level rooms array. */ - readonly roomIndex?: number - /** Room ID assigned during maze generation. */ - readonly roomId?: number - /** Whether the object was spawned in a dead-end room (`degree === 1`). */ - readonly isDeadEnd?: boolean - /** Whether the object was placed in a room corner. */ - readonly isCorner?: boolean - /** For racks and wall decorations, which wall it is aligned against. */ - readonly alignedWall?: WallAlignment | null -} - -/** Options to configure the dungeon object population pipeline. */ -export interface MazeObjectPopulationOptions { - /** Overall object density multiplier (default: 1.0). */ - readonly density?: number - /** Breakable container density multiplier (default: 1.0). When 0, containers are omitted. */ - readonly containerDensity?: number - /** Shrine density multiplier (default: 1.0). When 0, shrines are omitted. */ - readonly shrineDensity?: number - /** Chest density multiplier (default: 1.0). When 0, chests are omitted. */ - readonly chestDensity?: number - /** Rack density multiplier (default: 1.0). When 0, racks are omitted. */ - readonly rackDensity?: number - /** Deterministic RNG seed override for object seeding. Defaults to the maze seed. */ - readonly objectSeed?: number - /** If true, skips dynamic object generation entirely. */ - readonly disableDynamicObjects?: boolean -} - -/** Minimal room descriptor interface needed by the object populator. */ -export interface MazeRoomDescriptor { - readonly id: number - readonly x: number - readonly y: number - readonly width: number - readonly height: number - readonly orths: readonly { readonly room: unknown; readonly direction: number }[] - readonly piece?: { readonly name: string; readonly kind?: string } | null -} - -/** Candidate tile spot inside a room. */ -export interface CandidateSpot { - readonly cellX: number - readonly cellY: number - readonly roomIndex: number - readonly roomId: number - readonly isDeadEnd: boolean - readonly isCorner: boolean - readonly alignedWall: WallAlignment | null -} - -/* ------------------------------------------------------------------------- * - * Object Definitions & Act Mapping - * ------------------------------------------------------------------------- */ - -/** - * Shrine definition: subtype to act-specific DS1 Type 2 local ID mapping. - * - * Each entry maps a logical shrine subtype to the DS1 object ID used in each Act: - * - Act 1: IDs 6..11 map to Cairn Stone / shrine tokens S1..S6 (Objects.txt 17..22); - * 29 maps to Shrine token SF (Objects.txt 2); 82/83 map to Shrines SH/BC (Objects.txt 77/85); - * 84 maps to Dummy SG (Objects.txt 86). - * - Acts 2..5: IDs 167..172 map to Act 2+ shrine tokens MH, MJ, CZ, JH, SX, MK (Objects.txt 166..172); - * 227 maps to Dummy QX (Objects.txt 227, Experience/Gem); 152 maps to Fire Orifice HA (Objects.txt 152); - * 55/236 map to Poison corpses/shrines (Objects.txt 55, 236). - */ -interface ShrineDef { - readonly subType: ShrineSubType - readonly idsByAct: Record -} - -export const CANONICAL_SHRINES: readonly ShrineDef[] = [ - { subType: 'armor', idsByAct: { 1: 8, 2: 169, 3: 169, 4: 169, 5: 169 } }, - { subType: 'combat', idsByAct: { 1: 9, 2: 170, 3: 170, 4: 170, 5: 170 } }, - { subType: 'resist', idsByAct: { 1: 10, 2: 171, 3: 171, 4: 171, 5: 171 } }, - { subType: 'skill', idsByAct: { 1: 11, 2: 172, 3: 172, 4: 172, 5: 172 } }, - { subType: 'mana', idsByAct: { 1: 7, 2: 168, 3: 168, 4: 168, 5: 168 } }, - { subType: 'health', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } }, - { subType: 'experience', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } }, - { subType: 'refilling', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } }, - { subType: 'gem', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } }, - { subType: 'fire', idsByAct: { 1: 29, 2: 152, 3: 152, 4: 152, 5: 152 } }, - { subType: 'poison', idsByAct: { 1: 84, 2: 55, 3: 236, 4: 236, 5: 236 } }, -] - -/** - * Chest ID mapping per act. - * - * DS1 Type 2 IDs correspond to: - * - Super Chests: - * - Act 1: 5 (chest L1, Objects.txt 5), 63 (chest L2, Objects.txt 6), 155/156 (hidden stashes C7, Objects.txt 155) - * - Act 2: 32 (CL, Objects.txt 32), 33 (A1, Objects.txt 33), 155/156 - * - Act 3: 65 (X1, Objects.txt 65), 66 (X2, Objects.txt 66), 155/156 - * - Acts 4-5: 155, 156 - * - Normal Chests: - * - Act 1: 89..94 (Caskets C1..C6, Objects.txt 50, 51, 79, 53, 1, 3); - * 200..203 (Shrines/Stashes MY, MX, MW, MV, Objects.txt 200..203); 229 (door/chest SQ, Objects.txt 229) - * - Acts 2..5: 200..203, 229 - */ -export const CHEST_IDS_BY_ACT: Record = { - 1: { super: [5, 63, 155, 156], normal: [89, 90, 91, 92, 200, 201, 229, 203] }, - 2: { super: [32, 33, 155, 156], normal: [200, 201, 229, 203] }, - 3: { super: [65, 66, 155, 156], normal: [200, 201, 229, 203] }, - 4: { super: [155, 156], normal: [200, 201, 229, 203] }, - 5: { super: [155, 156], normal: [200, 201, 229, 203] }, -} - -/** - * Weapon and Armor rack ID mapping per act and orientation. - * - * DS1 Type 2 IDs correspond to: - * - Armor Racks: - * - Act 1: 19/20 (A1/A2, Objects.txt 33/34), 46/47 (Crate/Door CT/AD, Objects.txt 46/47), - * 183/184 (Chest/Shrine JZ/JY, Objects.txt 183/184), 254/255 (Dummy 12/SS, Objects.txt 254/255) - * - Act 2: 74/75 (TrappDoor/Door TD/DD, Objects.txt 74/75), 183/184, 254/255 - * - Act 3: 9/10 (A3/A4 ArmorStand, Objects.txt 104/105), 183/184, 254/255 - * - Acts 4-5: 183/184, 254/255, 10/11 - * - Weapon Racks: - * - Act 1: 48/49 (Dummy T1/T2, Objects.txt 48/49), 256/257 (Dummy S9/WG, Objects.txt 256/257) - * - Act 2: 76/77 (Dummy/Shrine SZ/SH, Objects.txt 76/77), 256/257 - * - Act 3: 11/12 (3R/4R WeaponStand, Objects.txt 106/107), 256/257 - * - Acts 4-5: 256, 257 - */ -export const RACK_IDS_BY_ACT: Record< - number, - { - armor: { west: readonly number[]; north: readonly number[]; any: readonly number[] } - weapon: { west: readonly number[]; north: readonly number[]; any: readonly number[] } - } -> = { - 1: { - armor: { west: [19, 46, 183, 254], north: [20, 47, 184, 255], any: [19, 20, 46, 47, 183, 184, 254, 255] }, - weapon: { west: [48, 256], north: [49, 257], any: [48, 49, 256, 257] }, - }, - 2: { - armor: { west: [74, 183, 254], north: [75, 184, 255], any: [74, 75, 183, 184, 254, 255] }, - weapon: { west: [76, 256], north: [77, 257], any: [76, 77, 256, 257] }, - }, - 3: { - armor: { west: [9, 183, 254], north: [10, 184, 255], any: [9, 10, 183, 184, 254, 255] }, - weapon: { west: [11, 256], north: [12, 257], any: [11, 12, 256, 257] }, - }, - 4: { - armor: { west: [183, 254], north: [184, 255], any: [183, 184, 254, 255] }, - weapon: { west: [256], north: [257], any: [256, 257] }, - }, - 5: { - armor: { west: [10, 183, 254], north: [11, 184, 255], any: [10, 11, 183, 184, 254, 255] }, - weapon: { west: [256], north: [257], any: [256, 257] }, - }, -} - -/** - * Container pool mapping per act. - * - * DS1 Type 2 IDs correspond to: - * - Barrels: 95 (LargeUrn U5, Objects.txt 95), 28 (B2, Objects.txt 82), 50/51 (Caskets C1/C2, Objects.txt 50/51), - * 102 (Dummy FB, Objects.txt 102), 157 (Waypoint WN, Objects.txt 157), 232 (Shrine HQ, Objects.txt 232), - * 251 (altar GA, Objects.txt 251), 329/330 (Chests XB/XC, Objects.txt 329/330), 417 (Flag YM, Objects.txt 417) - * - Urns: 154 (Corpse GC, Objects.txt 154), 159 (Hidden stash CQ, Objects.txt 159), 202 (Shrine MW, Objects.txt 202), - * 244 (Ratnest RN, Objects.txt 244), 245 (Body BY, Objects.txt 245), 25..29 (Act 2 Urns U1..U5, Objects.txt 4, 9, 52, 94, 95) - * - Caskets: 93/94 (Caskets C5/C6, Objects.txt 1/3), 151 (Shrine ZE, Objects.txt 151), 153 (Door DX, Objects.txt 153) - * - Crates: 161 (Fire FY, Objects.txt 161) - */ -export const CONTAINER_IDS_BY_ACT: Record< - number, - { - barrels: readonly number[] - urns: readonly number[] - caskets: readonly number[] - crates: readonly number[] - } -> = { - 1: { - barrels: [95, 28, 50, 51, 102, 157, 232, 251, 329, 330, 417], - urns: [154, 159, 202, 244, 245], - caskets: [93, 94, 151, 153], - crates: [161], - }, - 2: { - barrels: [6, 21, 157, 232, 251, 329, 330, 417], - urns: [25, 26, 27, 28, 29, 154, 159, 202], - caskets: [151, 153], - crates: [161], - }, - 3: { - barrels: [34, 157, 232, 251, 329, 330, 417], - urns: [154, 159, 202, 244, 245], - caskets: [151, 153], - crates: [161], - }, - 4: { - barrels: [2, 157, 232, 251, 329, 330, 417], - urns: [154, 159, 202], - caskets: [151, 153], - crates: [161], - }, - 5: { - barrels: [4, 44, 157, 232, 251, 329, 330, 417], - urns: [154, 159, 202], - caskets: [151, 153], - crates: [161], - }, -} - -/** - * Resolves the underlying Diablo II Objects.txt row ID for a given Act and DS1 Type 2 object ID. - * Returns null if unmapped in object-lookup tables or absent in the optional objects table. - */ -export function resolveObjectsTxtId( - act: number, - ds1ObjectId: number, - objectsTable?: ObjectsTable | D2Table, -): number | null { - const entry = lookupObject(act, 2, ds1ObjectId) - if (entry === null || entry.objectsTxtId < 0) return null - if (objectsTable) { - if ('byId' in objectsTable) { - if (!objectsTable.byId.has(entry.objectsTxtId)) return null - } 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 (!found) return null - } - } - return entry.objectsTxtId -} - -/** - * Validates dungeon object pools against object-lookup metadata or an optional loaded Objects.txt table. - * Ensures data-driven correctness for all hardcoded object pools. - */ -export function validateDungeonObjectPools(objectsTable?: ObjectsTable | D2Table): { - valid: boolean - totalChecked: number - unmapped: number[] - errors: string[] -} { - const unmapped: number[] = [] - let totalChecked = 0 - - const check = (act: number, 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) - } - } - } - } - - for (let act = 1; act <= 5; act += 1) { - const chests = CHEST_IDS_BY_ACT[act] - if (chests) { - for (const id of chests.super) check(act, id) - for (const id of chests.normal) check(act, id) - } - const racks = RACK_IDS_BY_ACT[act] - if (racks) { - for (const id of racks.armor.any) check(act, id) - for (const id of racks.weapon.any) check(act, id) - } - const containers = CONTAINER_IDS_BY_ACT[act] - if (containers) { - for (const id of containers.barrels) check(act, id) - for (const id of containers.urns) check(act, id) - for (const id of containers.caskets) check(act, id) - for (const id of containers.crates) check(act, id) - } - for (const shrine of CANONICAL_SHRINES) { - const id = shrine.idsByAct[act] - if (id !== undefined) check(act, 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 } -} - -/* ------------------------------------------------------------------------- * - * Spatial Analysis - * ------------------------------------------------------------------------- */ - -/** Check if a cell has a floor and no solid wall. */ -export function isOpenFloor(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean { - if (cy < 0 || cy >= cells.length) return false - const row = cells[cy] - if (!row || cx < 0 || cx >= row.length) return false - const cell = row[cx] - if (!cell) return false - - // Must contain floor *art*. A `Ds1Floor` whose `prop1`, `style` and `sequence` - // are all zero is an empty slot, not a tile: DS1 stores one entry per floor - // layer for every cell, so a one-layer map has a zero-filled entry everywhere - // the level does not paint. The `|| true` that used to close this condition - // made the whole content test vacuous, so objects were placed on void cells. - const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0)) - if (!hasFloor) return false - - // Must not have a solid, visible wall - const hasWall = cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0)) - return !hasWall -} - -/** Check if a cell contains a wall or is off-map. */ -export function isWallCell(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean { - if (cy < 0 || cy >= cells.length) return true - const row = cells[cy] - if (!row || cx < 0 || cx >= row.length) return true - const cell = row[cx] - if (!cell) return true - return cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0)) -} - -/** Determine which adjacent cardinal wall a floor cell is next to (if any). */ -export function getAdjacentWall( - cells: readonly (readonly Ds1Cell[])[], - cx: number, - cy: number, -): WallAlignment | null { - if (isWallCell(cells, cx, cy - 1)) return 'north' - if (isWallCell(cells, cx - 1, cy)) return 'west' - if (isWallCell(cells, cx, cy + 1)) return 'south' - if (isWallCell(cells, cx + 1, cy)) return 'east' - return null -} - -/** Check if an open floor cell is in an inner corner (walls on at least 2 orthogonal sides). */ -export function isInnerCorner( - cells: readonly (readonly Ds1Cell[])[], - cx: number, - cy: number, -): boolean { - const n = isWallCell(cells, cx, cy - 1) - const s = isWallCell(cells, cx, cy + 1) - const w = isWallCell(cells, cx - 1, cy) - const e = isWallCell(cells, cx + 1, cy) - return (n && w) || (n && e) || (s && w) || (s && e) -} - -/** - * Scan a single room and harvest candidate placement spots categorized into: - * - dead ends (`degree === 1`) - * - room corners - * - perimeter wall offsets (wall alignment) - */ -export function analyzeRoomSpots( - room: MazeRoomDescriptor, - roomIndex: number, - originX: number, - originY: number, - cells: readonly (readonly Ds1Cell[])[], -): CandidateSpot[] { - const spots: CandidateSpot[] = [] - const isDeadEnd = room.orths.length === 1 - const width = room.width - const height = room.height - - for (let dy = 0; dy < height; dy += 1) { - for (let dx = 0; dx < width; dx += 1) { - const cx = originX + dx - const cy = originY + dy - - if (!isOpenFloor(cells, cx, cy)) continue - - const alignedWall = getAdjacentWall(cells, cx, cy) - const isCorner = isInnerCorner(cells, cx, cy) || - (dx <= 2 && dy <= 2) || - (dx >= width - 3 && dy <= 2) || - (dx <= 2 && dy >= height - 3) || - (dx >= width - 3 && dy >= height - 3) - - spots.push({ - cellX: cx, - cellY: cy, - roomIndex, - roomId: room.id, - isDeadEnd, - isCorner, - alignedWall, - }) - } - } - - return spots -} - -/* ------------------------------------------------------------------------- * - * Main Object Population Pipeline - * ------------------------------------------------------------------------- */ - -export interface PopulationResult { - readonly placedObjects: readonly PlacedMazeObject[] - readonly stats: { - readonly dynamicObjectsCount: number - readonly shrinesSpawned: number - readonly chestsSpawned: number - readonly superChestsSpawned: number - readonly racksSpawned: number - readonly containersSpawned: number - } -} - -/** - * Executes the dynamic dungeon object spawning pipeline. - * - * @param rooms - The list of rooms placed in the maze. - * @param minX - Minimum room x in cells. - * @param minY - Minimum room y in cells. - * @param level - The synthesized Ds1 level containing cells and static objects. - * @param levelTypeName - LvlTypes name (e.g. 'Act 1 - Cave', 'Act 2 - Tomb'). - * @param levelId - Levels.txt ID. - * @param options - Configurable density and seeding options. - * @param rng - Pseudo-random number generator. - * @returns Placed dynamic objects and spawn statistics. - */ -export function populateDungeonObjects( - rooms: readonly MazeRoomDescriptor[], - minX: number, - minY: number, - level: Ds1, - levelTypeName: string, - levelId: number, - options?: MazeObjectPopulationOptions, - rng?: Rng, -): PopulationResult { - if (options?.disableDynamicObjects === true) { - return { - placedObjects: [], - stats: { - dynamicObjectsCount: 0, - shrinesSpawned: 0, - chestsSpawned: 0, - superChestsSpawned: 0, - racksSpawned: 0, - containersSpawned: 0, - }, - } - } - - const act = levelTypeName.startsWith('Act 2') - ? 2 - : levelTypeName.startsWith('Act 3') - ? 3 - : levelTypeName.startsWith('Act 4') - ? 4 - : levelTypeName.startsWith('Act 5') - ? 5 - : 1 - - const seed = options?.objectSeed ?? 0x5050_0000 + levelId - const popRng = rng ?? new Rng(seed).fork('dungeon-objects') - - const density = Math.max(0, options?.density ?? 1.0) - if (density <= 0) { - return { - placedObjects: [], - stats: { - dynamicObjectsCount: 0, - shrinesSpawned: 0, - chestsSpawned: 0, - superChestsSpawned: 0, - racksSpawned: 0, - containersSpawned: 0, - }, - } - } - - const shrineDensity = options?.shrineDensity ?? density - const chestDensity = options?.chestDensity ?? density - const rackDensity = options?.rackDensity ?? density - const containerDensity = options?.containerDensity ?? density - - const chestPool = CHEST_IDS_BY_ACT[act] ?? CHEST_IDS_BY_ACT[1]! - const rackPool = RACK_IDS_BY_ACT[act] ?? RACK_IDS_BY_ACT[1]! - const containerPool = CONTAINER_IDS_BY_ACT[act] ?? CONTAINER_IDS_BY_ACT[1]! - - // Track existing objects to avoid collisions - const occupiedSubtiles: { x: number; y: number }[] = level.objects.map(o => ({ x: o.x, y: o.y })) - const placedObjects: PlacedMazeObject[] = [] - - const isSubtileClear = (subX: number, subY: number, minDistance = SUB_TILES_PER_TILE): boolean => { - for (const occ of occupiedSubtiles) { - if (Math.hypot(occ.x - subX, occ.y - subY) < minDistance) return false - } - return true - } - - const placeObject = ( - cellX: number, - cellY: number, - id: number, - category: MazeObjectCategory, - subType: string, - metadata: { - roomIndex?: number - roomId?: number - isDeadEnd?: boolean - isCorner?: boolean - alignedWall?: WallAlignment | null - }, - subOffset = 2, - ): boolean => { - const subX = cellX * SUB_TILES_PER_TILE + subOffset - const subY = cellY * SUB_TILES_PER_TILE + subOffset - if (!isSubtileClear(subX, subY, SUB_TILES_PER_TILE * 0.9)) return false - - const placed: PlacedMazeObject = { - type: 2, - id, - x: subX, - y: subY, - flags: 0, - category, - subType, - ...metadata, - } - - placedObjects.push(placed) - occupiedSubtiles.push({ x: subX, y: subY }) - return true - } - - // 1. Gather candidate spots per room - const roomSpots: { room: MazeRoomDescriptor; roomIndex: number; spots: CandidateSpot[] }[] = [] - for (let i = 0; i < rooms.length; i += 1) { - const room = rooms[i]! - const originX = room.x - minX - const originY = room.y - minY - const spots = analyzeRoomSpots(room, i, originX, originY, level.cells) - roomSpots.push({ room, roomIndex: i, spots }) - } - - let shrinesSpawned = 0 - let chestsSpawned = 0 - let superChestsSpawned = 0 - let racksSpawned = 0 - let containersSpawned = 0 - - // 2. Dead-end rooms population: Shrines and Super/Gold Chests - const deadEndRooms = roomSpots.filter(r => r.room.orths.length === 1) - - // Decide shrine quota: 1..3 depending on dungeon room count and shrineDensity - const baseShrineTarget = Math.max(1, Math.min(3, Math.round((rooms.length / 6) * shrineDensity))) - const shrineTarget = shrineDensity > 0 ? baseShrineTarget : 0 - - const usedRoomsForMajorObject = new Set() - - // A. Place Shrines in dead-end rooms (or corner spots if dead-ends are scarce) - if (shrineTarget > 0) { - const shuffledDeadEnds = [...deadEndRooms].sort(() => popRng.next() - 0.5) - for (const r of shuffledDeadEnds) { - if (shrinesSpawned >= shrineTarget) break - // Pick a center or deep spot in the dead end - const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)] - if (!candidate) continue - - const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]! - const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]! - - if ( - placeObject(candidate.cellX, candidate.cellY, shrineId, 'shrine', shrineDef.subType, { - roomIndex: r.roomIndex, - roomId: r.room.id, - isDeadEnd: true, - isCorner: candidate.isCorner, - alignedWall: candidate.alignedWall, - }) - ) { - shrinesSpawned += 1 - usedRoomsForMajorObject.add(r.room.id) - } - } - - // If more shrines are needed, place them in regular room corners - if (shrinesSpawned < shrineTarget) { - for (const r of roomSpots) { - if (shrinesSpawned >= shrineTarget) break - if (usedRoomsForMajorObject.has(r.room.id)) continue - - const cornerSpot = r.spots.find(s => s.isCorner && isSubtileClear(s.cellX * SUB_TILES_PER_TILE + 2, s.cellY * SUB_TILES_PER_TILE + 2, SUB_TILES_PER_TILE * 2)) - if (!cornerSpot) continue - - const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]! - const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]! - - if ( - placeObject(cornerSpot.cellX, cornerSpot.cellY, shrineId, 'shrine', shrineDef.subType, { - roomIndex: r.roomIndex, - roomId: r.room.id, - isDeadEnd: r.room.orths.length === 1, - isCorner: true, - alignedWall: cornerSpot.alignedWall, - }) - ) { - shrinesSpawned += 1 - usedRoomsForMajorObject.add(r.room.id) - } - } - } - } - - // B. Place Super/Gold Chests in remaining dead-end rooms and special treasure rooms - if (chestDensity > 0) { - for (const r of roomSpots) { - const isDeadEnd = r.room.orths.length === 1 - const isTreasureRoom = r.room.piece?.kind === 'treasure' || /Treasure|Chest/i.test(r.room.piece?.name ?? '') - - if (!isDeadEnd && !isTreasureRoom) continue - if (usedRoomsForMajorObject.has(r.room.id) && !isTreasureRoom) continue - - // In dead-ends, place a Super Chest - const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)] - if (!candidate) continue - - const superChestId = chestPool.super[popRng.int(0, chestPool.super.length - 1)]! - if ( - placeObject(candidate.cellX, candidate.cellY, superChestId, 'chest', 'super_chest', { - roomIndex: r.roomIndex, - roomId: r.room.id, - isDeadEnd, - isCorner: candidate.isCorner, - alignedWall: candidate.alignedWall, - }) - ) { - chestsSpawned += 1 - superChestsSpawned += 1 - usedRoomsForMajorObject.add(r.room.id) - - // For special clusters / treasure rooms, add 1..2 surrounding regular chests - const clusterCount = isTreasureRoom ? popRng.int(2, 3) : popRng.chance(0.5) ? 1 : 0 - const secondarySpots = r.spots.filter( - s => (s.cellX !== candidate.cellX || s.cellY !== candidate.cellY) && - Math.hypot(s.cellX - candidate.cellX, s.cellY - candidate.cellY) <= 3, - ) - - for (let c = 0; c < clusterCount && c < secondarySpots.length; c += 1) { - const sec = secondarySpots[c]! - const regChestId = chestPool.normal[popRng.int(0, chestPool.normal.length - 1)]! - if ( - placeObject(sec.cellX, sec.cellY, regChestId, 'chest', 'chest', { - roomIndex: r.roomIndex, - roomId: r.room.id, - isDeadEnd, - isCorner: sec.isCorner, - alignedWall: sec.alignedWall, - }) - ) { - chestsSpawned += 1 - } - } - } - } - } - - // 3. Weapon & Armor Racks: placed against walls in corridors (`degree === 2`), barracks, or chambers - if (rackDensity > 0) { - const isBarracks = /Barracks|Jail/i.test(levelTypeName) - const baseRackTarget = Math.max(1, Math.min(4, Math.round((rooms.length / 5) * rackDensity * (isBarracks ? 1.5 : 1.0)))) - const rackTarget = baseRackTarget - - // Prefer corridor rooms (`degree === 2`) or barracks - const corridors = roomSpots.filter(r => r.room.orths.length === 2 || isBarracks) - const candidateRoomsForRacks = corridors.length > 0 ? corridors : roomSpots.filter(r => r.room.orths.length <= 2) - - for (const r of candidateRoomsForRacks) { - if (racksSpawned >= rackTarget) break - - // Find spots directly adjacent to a wall - const wallSpots = r.spots.filter(s => s.alignedWall !== null) - if (wallSpots.length === 0) continue - - const chosenSpot = wallSpots[popRng.int(0, wallSpots.length - 1)]! - const isArmor = popRng.chance(0.5) - const subType: RackSubType = isArmor ? 'armor_rack' : 'weapon_rack' - - let rackId: number - const wall = chosenSpot.alignedWall - if (isArmor) { - if (wall === 'west') rackId = rackPool.armor.west[popRng.int(0, rackPool.armor.west.length - 1)]! - else if (wall === 'north') rackId = rackPool.armor.north[popRng.int(0, rackPool.armor.north.length - 1)]! - else rackId = rackPool.armor.any[popRng.int(0, rackPool.armor.any.length - 1)]! - } else { - if (wall === 'west') rackId = rackPool.weapon.west[popRng.int(0, rackPool.weapon.west.length - 1)]! - else if (wall === 'north') rackId = rackPool.weapon.north[popRng.int(0, rackPool.weapon.north.length - 1)]! - else rackId = rackPool.weapon.any[popRng.int(0, rackPool.weapon.any.length - 1)]! - } - - if ( - placeObject(chosenSpot.cellX, chosenSpot.cellY, rackId, 'rack', subType, { - roomIndex: r.roomIndex, - roomId: r.room.id, - isDeadEnd: r.room.orths.length === 1, - isCorner: chosenSpot.isCorner, - alignedWall: chosenSpot.alignedWall, - }) - ) { - racksSpawned += 1 - } - } - } - - // 4. Breakable Containers (barrels, urns, coffins, caskets, crates, tombs) - if (containerDensity > 0) { - const isCryptOrTomb = /Crypt|Catacombs|Tomb/i.test(levelTypeName) - const isSewerOrJail = /Sewer|Jail|Barracks/i.test(levelTypeName) - - // Average 1..2 clusters per room according to containerDensity - const clusterTarget = Math.max(1, Math.round(rooms.length * 1.2 * containerDensity)) - - for (let cl = 0; cl < clusterTarget; cl += 1) { - const r = roomSpots[popRng.int(0, roomSpots.length - 1)]! - - // Prefer corner spots or wall-aligned spots for clusters - const preferredSpots = r.spots.filter(s => s.isCorner || s.alignedWall !== null) - const anchorSpot = preferredSpots.length > 0 - ? preferredSpots[popRng.int(0, preferredSpots.length - 1)]! - : r.spots[popRng.int(0, r.spots.length - 1)] - - if (!anchorSpot) continue - - // Determine container type for this cluster - let chosenType: ContainerSubType = 'barrel' - let idPool = containerPool.barrels - - if (isCryptOrTomb) { - const roll = popRng.next() - if (roll < 0.4) { - chosenType = 'casket' - idPool = containerPool.caskets - } else if (roll < 0.8) { - chosenType = 'urn' - idPool = containerPool.urns - } else { - chosenType = 'barrel' - idPool = containerPool.barrels - } - } else if (isSewerOrJail) { - const roll = popRng.next() - if (roll < 0.3) { - chosenType = 'crate' - idPool = containerPool.crates - } else if (roll < 0.7) { - chosenType = 'barrel' - idPool = containerPool.barrels - } else { - chosenType = 'urn' - idPool = containerPool.urns - } - } - - // Cluster size: 2 to 4 items - const clusterSize = popRng.int(2, 4) - const nearbySpots = r.spots.filter( - s => Math.hypot(s.cellX - anchorSpot.cellX, s.cellY - anchorSpot.cellY) <= 2, - ) - - for (let ci = 0; ci < clusterSize && ci < nearbySpots.length; ci += 1) { - const spot = nearbySpots[ci]! - const objId = idPool[popRng.int(0, idPool.length - 1)]! - - if ( - placeObject(spot.cellX, spot.cellY, objId, 'container', chosenType, { - roomIndex: r.roomIndex, - roomId: r.room.id, - isDeadEnd: r.room.orths.length === 1, - isCorner: spot.isCorner, - alignedWall: spot.alignedWall, - }) - ) { - containersSpawned += 1 - } - } - } - } - - return { - placedObjects, - stats: { - dynamicObjectsCount: placedObjects.length, - shrinesSpawned, - chestsSpawned, - superChestsSpawned, - racksSpawned, - containersSpawned, - }, - } -} diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts deleted file mode 100644 index ef42c8b..0000000 --- a/src/game/maze-special-passes.ts +++ /dev/null @@ -1,1393 +0,0 @@ -/** - * Diablo II DRLG (Dungeon Random Level Generator) Maze Special Passes. - * - * Implements the 11 official Blizzard / D2MOO core dungeon special passes: - * - `DRLGMAZE_PlaceAct2TombStuff`: Tal Rasha's Tombs (True tomb Horadric staff receptacle, 6 false tombs golden chests, Kaa, Cube, Leatherarm). - * - `DRLGMAZE_PlaceAct2LairStuff`: Maggot Lair Level 3 (Coldworm the Burrower & Staff of Kings chest). - * - `DRLGMAZE_PlaceAct3DungeonStuff`: Flayer Dungeon (Brain chest) & Ruined Temple (Lam Esen's Tome altar). - * - `DRLGMAZE_PlaceAct3SewerStuff`: Kurast Sewers (drain lever & descent to Level 2). - * - `DRLGMAZE_PlaceAct3MephistoStuff`: Durance of Hate Level 3 (blood moat, council hall, red portal to Act 4). - * - `DRLGMAZE_PlaceAct5TempleStuff`: Halls of Vaught (4-way branch & Nihlathak altar) & Halls of Pain. - * - `DRLGMAZE_PlaceAct5BaalStuff`: Throne of Destruction & Worldstone Keep. - * - `DRLGMAZE_PlaceAct5IceStuff`: Frozen River (Anya frozen platform) & Glacial Trail. - * - `DRLGMAZE_PlaceAct1Barracks`: Monastery Barracks (Smith & Charsi's Malus forge/anvil). - * - `DRLGMAZE_PlaceAct4Lava`: River of Flame (Hephasto the Armorer & Hellforge, Bridge connecting to Chaos Sanctuary). - * - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest). - */ - -import { Rng } from './rng.ts' -import { - DIRECTION_WEST, - DIRECTION_NORTH, - DIRECTION_EAST, - DIRECTION_SOUTH, - sideMaskFromToken, - sideTokenFromMask, - type MazeLevel, - type Room, - type MazePiece, - type MazePieceKind, - type MazeRequest, - type MazeStats, - type PieceIndex, - bfsDistances, - addAdjacentMazeRoom, - noteSpecial, - findSpecial, - roomSideMask, - fillBlankSpaces, -} from './maze.ts' - -export type SpecialPassName = - | 'DRLGMAZE_PlaceAct2TombStuff' - | 'DRLGMAZE_PlaceAct2LairStuff' - | 'DRLGMAZE_PlaceAct3DungeonStuff' - | 'DRLGMAZE_PlaceAct3SewerStuff' - | 'DRLGMAZE_PlaceAct3MephistoStuff' - | 'DRLGMAZE_PlaceAct5TempleStuff' - | 'DRLGMAZE_PlaceAct5BaalStuff' - | 'DRLGMAZE_PlaceAct5IceStuff' - | 'DRLGMAZE_PlaceAct1Barracks' - | 'DRLGMAZE_PlaceAct4Lava' - | 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets' - -export interface SpecialPassContext { - readonly level: MazeLevel - readonly request: MazeRequest - readonly pieces: readonly MazePiece[] - readonly index: PieceIndex - readonly stats: MazeStats - readonly rng?: Rng -} - -function getPassRng(ctx: SpecialPassContext): Rng { - return ctx.rng ?? ctx.level?.rng ?? new Rng(ctx.request.seed) -} - -const DIRS = ['N', 'E', 'S', 'W'] as const - -/** - * Blizzard D2 1.13c `DrlgMaze.cpp` 4-bit Room Door Bitmask (`West=1, East=2, South=4, North=8`). - * In 1.13c `DRLGMAZE_GetRoomPresetId`, a room's 15 non-empty door topologies map directly to - * `baseDefId + (doorMask - 1)` in `LvlPrest.txt`. - */ -export const MAZE_DOOR_WEST = 0x01 -export const MAZE_DOOR_EAST = 0x02 -export const MAZE_DOOR_SOUTH = 0x04 -export const MAZE_DOOR_NORTH = 0x08 - -/** - * Compute the canonical 1.13c `DrlgMaze.cpp` 4-bit door mask (`1..15`) for a maze room. - */ -export function mazeDoorMask(room: Room): number { - const mask = roomSideMask(room) - let d2Mask = 0 - if ((mask & (1 << DIRECTION_WEST)) !== 0) d2Mask |= MAZE_DOOR_WEST - if ((mask & (1 << DIRECTION_EAST)) !== 0) d2Mask |= MAZE_DOOR_EAST - if ((mask & (1 << DIRECTION_SOUTH)) !== 0) d2Mask |= MAZE_DOOR_SOUTH - if ((mask & (1 << DIRECTION_NORTH)) !== 0) d2Mask |= MAZE_DOOR_NORTH - return d2Mask -} - -/** - * Compute the exact `LvlPrest.txt` `Def` ID for a maze room given a base 15-room table `Def` ID - * (`baseDefId + (mazeDoorMask(room) - 1)`). - */ -export function mazePresetDefIdForDoorMask(baseDefId: number, room: Room): number { - const doorMask = mazeDoorMask(room) - if (doorMask <= 0 || doorMask > 15) return baseDefId - return baseDefId + (doorMask - 1) -} - -/** - * Canonical Blizzard D2 1.13c `LvlPrest.txt` `Def` IDs used by `DrlgMaze.cpp` special passes. - */ -export const LVLPREST_ACT2_TOMB_TALRASHA_BASE = 625 -export const LVLPREST_ACT2_TOMB_KAA_BASE = 629 -export const LVLPREST_ACT2_TOMB_TAINTED_SUN_X = 633 -export const LVLPREST_ACT3_SEWER_SW = 585 -export const LVLPREST_ACT3_SEWER_SE = 586 -export const LVLPREST_ACT3_SEWER_NW = 587 -export const LVLPREST_ACT3_SEWER_NE = 588 -export const LVLPREST_ACT3_SEWER_DRAIN_BASE = 589 -export const LVLPREST_ACT3_SEWER_CHEST_BASE = 593 -export const LVLPREST_ACT5_ICE_PREV_BASE = 1018 -export const LVLPREST_ACT5_ICE_WAYPOINT_BASE = 1026 -export const LVLPREST_ACT5_ICE_RIVER_A = 1030 -export const LVLPREST_ACT5_ICE_RIVER_B = 1031 -export const LVLPREST_ACT5_ICE_POOL_A = 1032 -export const LVLPREST_ACT5_ICE_POOL_B = 1033 -export const LVLPREST_ACT5_TEMPLE_REMAPPING_BASE = 1044 -export const LVLPREST_ACT5_TEMPLE_FINAL_ROOM = 1059 -export const LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE = 1060 - -export const LVLPREST_ACT2_CORRUPT_HAREM_NE = 354 -export const LVLPREST_ACT2_CORRUPT_HAREM_SE = 355 -export const LVLPREST_ACT2_CORRUPT_HAREM_SW = 356 -export const LVLPREST_ACT2_CORRUPT_HAREM_NW = 357 -export const LVLPREST_ACT2_BASEMENT_NE = 358 -export const LVLPREST_ACT2_BASEMENT_SE = 359 -export const LVLPREST_ACT2_BASEMENT_SW = 360 -export const LVLPREST_ACT2_BASEMENT_NW = 361 -export const LVLPREST_ACT3_SPIDER_SW = 659 -export const LVLPREST_ACT3_SPIDER_SE = 660 -export const LVLPREST_ACT3_SPIDER_NW = 661 -export const LVLPREST_ACT3_SPIDER_NE = 662 -export const LVLPREST_ACT3_SPIDER_CHEST_NW = 663 -export const LVLPREST_ACT3_SPIDER_CHEST_NE = 664 -export const LVLPREST_ACT5_LAVA_N = 1053 -export const LVLPREST_ACT5_LAVA_S = 1054 -export const LVLPREST_ACT5_LAVA_E = 1055 -export const LVLPREST_ACT5_LAVA_W = 1056 -export const LVLPREST_ACT5_LAVA_EW = 1057 -export const LVLPREST_ACT5_LAVA_NS = 1058 - -/** - * 1.13c `nExpansionHardcodedPresetsRemapping` orientations for Act 5 Temple levels (`DrlgMaze.cpp`). - */ -export const ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING = ['SW', 'SE', 'NW', 'NE'] as const -export const ACT5_NIHLATHAK_WINGS = ['NW', 'NE', 'SE', 'SW'] as const - -/** - * Canonical mapping from 1.13c `LvlPrest.txt` `Def` IDs to `LvlPrest` piece names and roles. - */ -export const LVLPREST_MAZE_DEF_REGISTRY: Readonly> = { - [LVLPREST_ACT3_SEWER_SW]: { name: 'Act 3 - Sewer SW', kind: 'prev', sides: 'S' }, - [LVLPREST_ACT3_SEWER_SE]: { name: 'Act 3 - Sewer SE', kind: 'prev', sides: 'E' }, - [LVLPREST_ACT3_SEWER_NW]: { name: 'Act 3 - Sewer NW', kind: 'prev', sides: 'W' }, - [LVLPREST_ACT3_SEWER_NE]: { name: 'Act 3 - Sewer NE', kind: 'prev', sides: 'N' }, - [LVLPREST_ACT2_TOMB_TAINTED_SUN_X]: { name: 'Act 2 - Tomb Tainted Sun X', kind: 'quest' }, - [LVLPREST_ACT5_ICE_RIVER_A]: { name: 'Act 5 - Ice River A', kind: 'quest' }, - [LVLPREST_ACT5_ICE_RIVER_B]: { name: 'Act 5 - Ice River B', kind: 'quest' }, - [LVLPREST_ACT5_ICE_POOL_A]: { name: 'Act 5 - Ice Pool A', kind: 'quest' }, - [LVLPREST_ACT5_ICE_POOL_B]: { name: 'Act 5 - Ice Pool B', kind: 'quest' }, - [LVLPREST_ACT5_TEMPLE_FINAL_ROOM]: { name: 'Act 5 - Temple Final Room', kind: 'quest' }, - [LVLPREST_ACT3_SPIDER_CHEST_NW]: { name: 'Act 3 - Spider Chest NW', kind: 'treasure', sides: 'NW' }, - [LVLPREST_ACT3_SPIDER_CHEST_NE]: { name: 'Act 3 - Spider Chest NE', kind: 'treasure', sides: 'NE' }, -} - -/** - * Place or replace a maze room by its 1.13c `LvlPrest.txt` numeric `Def` ID. - */ -export function replaceRoomByPresetDefId( - ctx: SpecialPassContext, - defId: number, - options?: Partial, -): Room | null { - const entry = LVLPREST_MAZE_DEF_REGISTRY[defId] - const targetName = options?.name ?? entry?.name - const targetKind = options?.kind ?? entry?.kind ?? 'quest' - const placed = placeNamedPiece(ctx, { - kind: targetKind, - ...(targetName !== undefined ? { name: targetName } : {}), - ...(options?.namePrefix !== undefined ? { namePrefix: options.namePrefix } : {}), - ...(options?.sides !== undefined ? { sides: options.sides } : entry?.sides !== undefined ? { sides: entry.sides } : {}), - ...(options?.plainSides !== undefined ? { plainSides: options.plainSides } : {}), - ...(options?.direction !== undefined ? { direction: options.direction } : {}), - ...(options?.inPlaceOnly !== undefined ? { inPlaceOnly: options.inPlaceOnly } : {}), - ...(options?.preferFurthest !== undefined ? { preferFurthest: options.preferFurthest } : {}), - }) - if (placed !== null) { - ctx.stats.notes.push(`lvlPrestDef=${String(defId)} (${placed.piece?.name ?? targetName ?? 'special'})`) - } - return placed -} - -export interface PlaceNamedPieceOptions { - readonly kind: MazePieceKind - readonly name?: string - readonly namePrefix?: string - readonly sides?: string - readonly plainSides?: string - readonly direction?: number - readonly inPlaceOnly?: boolean - readonly preferFurthest?: boolean -} - -/** - * Locate and place a special piece into the maze level. - * - * Tries in-place replacement first (preferring rooms matching `plainSides` and optimal BFS distance), - * then falls back to growing a new dead-end room off an existing room if allowed. - */ -export function placeNamedPiece( - ctx: SpecialPassContext, - options: PlaceNamedPieceOptions, -): Room | null { - const { kind, name, namePrefix, sides, plainSides, inPlaceOnly = false } = options - if (kind === 'waypoint' && ctx.request.hasWaypoint === false) { - return null - } - const isExit = options.preferFurthest ?? (kind === 'next' || kind === 'down' || kind === 'quest' || kind === 'treasure' || kind === 'waypoint') - - // 1. Find the target piece in ctx.pieces - let piece: MazePiece | undefined - if (name !== undefined) { - piece = ctx.pieces.find(p => p.name === name) - ?? ctx.pieces.find(p => p.name.toLowerCase() === name.toLowerCase()) - } - if (piece === undefined && namePrefix !== undefined) { - if (sides !== undefined && sides !== '') { - const fullName = `${namePrefix} ${sides}` - piece = ctx.pieces.find(p => p.name === fullName) - ?? ctx.pieces.find(p => p.name.toLowerCase() === fullName.toLowerCase()) - } - if (piece === undefined) { - piece = ctx.pieces.find(p => p.name.startsWith(namePrefix)) - } - } - if (piece === undefined && sides !== undefined && sides !== '') { - piece = findSpecial(ctx.pieces, kind, sides) - } - if (piece === undefined) { - piece = ctx.pieces.find(p => p.kind === kind && (sides === undefined || sides === '' || p.sides === sides)) - } - - if (piece === undefined) { - const missingIdentifier = name ?? (namePrefix !== undefined ? `${namePrefix} ${sides ?? ''}`.trim() : `${kind} ${sides ?? ''}`.trim()) - ctx.stats.unresolvedRoles.push(missingIdentifier) - ctx.stats.notes.push(`special piece not found: ${missingIdentifier}`) - return null - } - - const token = sides ?? piece.sides ?? '' - const distMap = bfsDistances(ctx.level) - - // 2. Phase 1: In-place replacement - 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) return roomSideMask(r) === wantedMask - return r.orths.length <= 1 - }) - - if (candidates.length > 0) { - candidates.sort((a, b) => { - const da = distMap.get(a) ?? 0 - const db = distMap.get(b) ?? 0 - return isExit ? db - da : da - db - }) - const chosen = candidates[0]! - chosen.piece = piece - chosen.variant = 0 - chosen.hasDs1 = true - noteSpecial(chosen, piece, token !== '' ? token : sideTokenFromMask(roomSideMask(chosen)), ctx.stats) - return chosen - } - - if (inPlaceOnly) { - ctx.stats.notes.push(`no matching room for in-place special piece: ${piece.name}`) - return null - } - - // 3. Phase 2: Grow an adjacent room - const parents = ctx.level.rooms.filter(r => !r.hasDs1 || r.piece?.kind === 'theme') - parents.sort((a, b) => { - const da = distMap.get(a) ?? 0 - const db = distMap.get(b) ?? 0 - return isExit ? db - da : da - db - }) - - 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] - - 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 = dirPiece - created.variant = 0 - created.hasDs1 = true - noteSpecial(created, dirPiece, dirPiece.sides || dirSide, ctx.stats) - return created - } - parent.hasDs1 = wasHasDs1 - } - } - - ctx.stats.notes.push(`could not place special piece ${piece.name}: no adjacent space clear`) - return null -} - -/* ------------------------------------------------------------------------- * - * 1. Act 2 - Tomb - * ------------------------------------------------------------------------- */ - -/** - * Tal Rasha's Tombs, Stony Tomb, Halls of the Dead, Claw Viper Temple. - * - * Places True Tomb Horadric Staff orifice, False Tomb golden chest rooms, Ancient Kaa the Soulless, - * Horadric Cube chest, Leatherarm, and staircases. - */ -export function placeAct2TombStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - const staffTomb = ctx.request.staffTombLevelId ?? 66 - const bossTomb = ctx.request.bossTombLevelId - - // Determine initial entrance direction from warp room - let nDirection = 0 - const warpRoom = ctx.level.rooms.find(r => r.piece?.kind === 'prev') - if (warpRoom?.piece !== null && warpRoom?.piece !== undefined) { - const name = warpRoom.piece.name.toUpperCase() - if (name.includes('NSW')) nDirection = 0 - else if (name.includes('NEW')) nDirection = 1 - else if (name.includes('NSE')) nDirection = 2 - else if (name.includes('SEW')) nDirection = 3 - else nDirection = rng.int(0, 3) - } else { - nDirection = rng.int(0, 3) - } - - const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST] - - const placeTombPiece = (kind: MazePieceKind, role: string) => { - const side = DIRS[nDirection]! - const growDir = GROW_DIRS[nDirection]! - placeNamedPiece(ctx, { - kind, - namePrefix: `Act 2 - Tomb ${role}`, - sides: side, - plainSides: side, - direction: growDir, - preferFurthest: role !== 'Prev', - }) - nDirection = (nDirection + 1) % 4 - } - - // 0. Claw Viper Temple Level 2 (levelId === 61): 1.13c fixed Tainted Sun Altar chamber (Def 633) - if (levelId === 61) { - const altarCandidates = ['Act 2 - Tomb Tainted Sun X', 'Act 2 - Tomb Sanctuary', 'Act 2 - Tomb Talrasha'] - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const singlePiece = ctx.pieces.find(p => altarCandidates.some(c => p.name.startsWith(c))) - if (singlePiece !== undefined) { - ctx.level.first.piece = singlePiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, singlePiece, '', ctx.stats) - ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT2_TOMB_TAINTED_SUN_X)} (${singlePiece.name})`) - return - } - } - const matchedAltarPiece = ctx.pieces.find(p => altarCandidates.some(c => p.name === c || p.name.startsWith(c))) - const placedAltar = replaceRoomByPresetDefId(ctx, LVLPREST_ACT2_TOMB_TAINTED_SUN_X, { - kind: 'quest', - ...(matchedAltarPiece !== undefined ? { name: matchedAltarPiece.name } : { name: 'Act 2 - Tomb Tainted Sun X' }), - preferFurthest: true, - }) - if (placedAltar === null) { - placeTombPiece('quest', 'Talrasha') - } - if ( - ctx.level.rooms.length > 1 && - !ctx.level.rooms.some(r => r.piece?.kind === 'prev') && - ctx.pieces.some(p => p.name.startsWith('Act 2 - Tomb Prev')) - ) { - placeTombPiece('prev', 'Prev') - } - return - } - - // 1. Stony Tomb 1, Stony Tomb 2, Halls of the Dead 1, Claw Viper Temple 1 get Next stairs - if (levelId >= 55 && levelId <= 58) { - placeTombPiece('next', 'Next') - } - - // 2. Halls of the Dead 2 gets Waypoint - if (levelId === 57 || levelId === 59 || ctx.request.hasWaypoint === true) { - placeTombPiece('waypoint', 'Waypoint') - } - - // 3. Stony Tomb 2 gets golden chest - if (levelId === 56) { - placeTombPiece('treasure', 'Chest') - } - - // 4. False Tal Rasha Tombs (66..72 except staff tomb) get golden chest - if (levelId >= 66 && levelId <= 72 && levelId !== staffTomb) { - placeTombPiece('treasure', 'Chest') - } - - // 5. Stony Tomb 2 gets Leatherarm - if (levelId === 56) { - placeTombPiece('quest', 'Leatherarm') - } - - // 6. Halls of the Dead 3 gets Horadric Cube - if (levelId === 60) { - placeTombPiece('quest', 'Cube') - } - - // 7. Stony Tomb 2 gets Treasure - if (levelId === 56) { - placeTombPiece('treasure', 'Treasure') - } - - // 8. True Tal Rasha Tomb gets Horadric Staff Orifice (Def 625..628) - if (levelId === staffTomb) { - const talrashaDef = LVLPREST_ACT2_TOMB_TALRASHA_BASE + (nDirection % 4) - ctx.stats.notes.push(`lvlPrestDef=${String(talrashaDef)} (Act 2 - Tomb Talrasha)`) - placeTombPiece('quest', 'Talrasha') - } - - // 9. Boss Tomb gets Ancient Kaa the Soulless (Def 629..632) - if (bossTomb !== undefined && levelId === bossTomb) { - const kaaDef = LVLPREST_ACT2_TOMB_KAA_BASE + (nDirection % 4) - ctx.stats.notes.push(`lvlPrestDef=${String(kaaDef)} (Act 2 - Tomb Kaa)`) - placeTombPiece('quest', 'Kaa') - } -} - -/* ------------------------------------------------------------------------- * - * 2. Act 2 - Lair - * ------------------------------------------------------------------------- */ - -/** - * Maggot Lair Level 1, 2, 3. - * - * Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W). - */ -export function placeAct2LairStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - - if (levelId === 64) { - // Coldworm the Burrower room - placeNamedPiece(ctx, { - kind: 'quest', - name: 'Act 2 - Lair Tight Spot S', - plainSides: 'S', - direction: DIRECTION_NORTH, - preferFurthest: true, - }) - - // Staff of Kings chest room - placeNamedPiece(ctx, { - kind: 'treasure', - name: 'Act 2 - Lair Treasure W', - plainSides: 'W', - direction: DIRECTION_EAST, - preferFurthest: true, - }) - - // Up staircase - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 2 - Lair Prev', - sides: 'N', - direction: DIRECTION_SOUTH, - preferFurthest: false, - }) - } else { - const nRand = rng.int(0, 3) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 2 - Lair Next', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 2 - Lair Prev', - sides: DIRS[(nRand + 1) % 4], - plainSides: DIRS[(nRand + 1) % 4], - preferFurthest: false, - }) - } -} - -/* ------------------------------------------------------------------------- * - * 3. Act 3 - Dungeon - * ------------------------------------------------------------------------- */ - -/** - * Spider Cavern, Arachnid Lair, Swampy Pit 1..3, Flayer Dungeon 1..3, and Kurast Temples. - * - * Flayer Dungeon 3 places Khalim's Brain chest (Treasure 2). - * Swampy Pit 3 places Treasure 1. - * Temples (levels 94..99) place Lam Esen's Tome altar. - */ -export function placeAct3DungeonStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - const nDirection = rng.int(0, 3) - - if (levelId === 91) { - // Flayer Dungeon Level 3: Khalim's Brain chest - placeNamedPiece(ctx, { - kind: 'treasure', - name: 'Act 3 - Dungeon Treasure 2', - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 3 - Dungeon Prev', - sides: DIRS[nDirection], - preferFurthest: false, - }) - } else if (levelId === 90) { - // Swampy Pit Level 3 - placeNamedPiece(ctx, { - kind: 'treasure', - name: 'Act 3 - Dungeon Treasure 1', - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 3 - Dungeon Prev', - sides: DIRS[nDirection], - preferFurthest: false, - }) - } else if (levelId >= 94 && levelId <= 99) { - // Ruined Temple, Disused Fane, Forgotten Reliquary, etc. (Lam Esen's Tome) - placeNamedPiece(ctx, { - kind: 'quest', - name: `Act 3 - Temple ${levelId - 93}`, - preferFurthest: true, - }) - } else { - // Levels 86..89 - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 3 - Dungeon Prev', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: false, - }) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 3 - Dungeon Next', - sides: DIRS[(nDirection + 1) % 4], - plainSides: DIRS[(nDirection + 1) % 4], - preferFurthest: true, - }) - } -} - -/* ------------------------------------------------------------------------- * - * 4. Act 3 - Sewer - * ------------------------------------------------------------------------- */ - -/** - * Kurast Sewers Level 1 & 2 (`DRLGMAZE_PlaceAct3SewerStuff`). - * - * In 1.13c, Level 1 (levelId === 92) places the 4 corner entrance stair rooms (`LVLPREST_ACT3_SEWER_SW/SE/NW/NE`, Def 585..588), - * the drain descent lever (`LVLPREST_ACT3_SEWER_DRAIN`, Def 589..592), and the chest room (`Def 593..596`). - */ -export function placeAct3SewerStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - const nDirection = rng.int(0, 3) - - if (levelId === 92) { - // 1.13c 4 corner entrance stair rooms (SW=585, SE=586, NW=587, NE=588) - const cornerDefs = [ - LVLPREST_ACT3_SEWER_SW, - LVLPREST_ACT3_SEWER_SE, - LVLPREST_ACT3_SEWER_NW, - LVLPREST_ACT3_SEWER_NE, - ] as const - for (const defId of cornerDefs) { - const entry = LVLPREST_MAZE_DEF_REGISTRY[defId]! - if (ctx.pieces.some(p => p.name.toLowerCase() === entry.name.toLowerCase())) { - replaceRoomByPresetDefId(ctx, defId, { preferFurthest: false }) - } else { - ctx.stats.notes.push(`lvlPrestDef=${String(defId)} (${entry.name})`) - } - } - - // Sewer Level 1: Drain lever / stairs down to Sewer 2 (Def 589..592) - const drainDef = LVLPREST_ACT3_SEWER_DRAIN_BASE + nDirection - ctx.stats.notes.push(`lvlPrestDef=${String(drainDef)} (Act 3 - Sewer Drain ${DIRS[nDirection]})`) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 3 - Sewer Drain', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - direction: DIRECTION_NORTH, - preferFurthest: true, - }) - // Chest room (Def 593..596) - const chestDir = (nDirection + 1) % 4 - const chestDef = LVLPREST_ACT3_SEWER_CHEST_BASE + chestDir - ctx.stats.notes.push(`lvlPrestDef=${String(chestDef)} (Act 3 - Sewer Chest ${DIRS[chestDir]})`) - placeNamedPiece(ctx, { - kind: 'treasure', - namePrefix: 'Act 3 - Sewer Chest', - sides: DIRS[chestDir], - plainSides: DIRS[chestDir], - direction: DIRECTION_SOUTH, - preferFurthest: true, - }) - } else { - // Sewer Level 2 (or other) - placeNamedPiece(ctx, { - kind: 'treasure', - namePrefix: 'Act 3 - Sewer Chest', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'treasure', - name: 'Act 3 - Sewer Treasure X', - preferFurthest: true, - }) - } -} - -/* ------------------------------------------------------------------------- * - * 5. Act 3 - Mephisto - * ------------------------------------------------------------------------- */ - -/** - * Durance of Hate Level 1, 2, 3. - * - * Level 3 places the Mephisto Complex (Council Hall, blood moat, red portal to Act 4). - * Level 2 places Waypoint, Next and Prev. - */ -export function placeAct3MephistoStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - const nRand = rng.int(0, 3) - - if (levelId === 102) { - // Durance of Hate Level 3: Mephisto Complex - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const complexPiece = ctx.pieces.find(p => p.name === 'Act 3 - Mephisto Complex') - if (complexPiece !== undefined) { - ctx.level.first.piece = complexPiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, complexPiece, '', ctx.stats) - return - } - } - placeNamedPiece(ctx, { - kind: 'quest', - name: 'Act 3 - Mephisto Complex', - preferFurthest: true, - }) - } else if (levelId === 101) { - // Durance of Hate Level 2: Prev, Waypoint, Next - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 3 - Mephisto Prev', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: false, - }) - placeNamedPiece(ctx, { - kind: 'waypoint', - namePrefix: 'Act 3 - Mephisto Waypoint', - sides: DIRS[(nRand + 1) % 4], - plainSides: DIRS[(nRand + 1) % 4], - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 3 - Mephisto Next', - sides: DIRS[(nRand + 2) % 4], - plainSides: DIRS[(nRand + 2) % 4], - preferFurthest: true, - }) - } else { - // Durance of Hate Level 1: Prev, Next - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 3 - Mephisto Prev', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: false, - }) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 3 - Mephisto Next', - sides: DIRS[(nRand + 1) % 4], - plainSides: DIRS[(nRand + 1) % 4], - preferFurthest: true, - }) - } -} - -/* ------------------------------------------------------------------------- * - * 6. Act 5 - Temple - * ------------------------------------------------------------------------- */ - -/** - * Halls of Anguish, Halls of Pain, Halls of Vaught (`DRLGMAZE_PlaceAct5TempleStuff`). - * - * Enforces 1.13c `nExpansionHardcodedPresetsRemapping` for SW/SE/NW/NE orientations (`Def 1044..1047`). - * Halls of Vaught (level 124) places Nihlathak's altar (`Def 1059`) and deterministically rolls - * Nihlathak's active wing orientation (`NW/NE/SE/SW`, `Def 1060..1063`). - */ -export function placeAct5TempleStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - - if (levelId === 124) { - // Halls of Vaught: Nihlathak Final Room (Def 1059) + 1 of 4 Nihlathak wing orientations (Def 1060..1063) - const wingIdx = rng.int(0, 3) - const nihlathakWing = ACT5_NIHLATHAK_WINGS[wingIdx]! - const wingDefId = LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE + wingIdx - ctx.stats.notes.push(`lvlPrestDef=${String(wingDefId)} (nihlathakWing=${nihlathakWing})`) - - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const finalPiece = ctx.pieces.find(p => p.name === 'Act 5 - Temple Final Room') - if (finalPiece !== undefined) { - ctx.level.first.piece = finalPiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, finalPiece, '', ctx.stats) - ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_TEMPLE_FINAL_ROOM)} (Act 5 - Temple Final Room)`) - return - } - } - replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_TEMPLE_FINAL_ROOM, { - kind: 'quest', - name: 'Act 5 - Temple Final Room', - preferFurthest: true, - }) - } else { - // Levels 122 and 123: apply 1.13c nExpansionHardcodedPresetsRemapping (SW=1044, SE=1045, NW=1046, NE=1047) - const templeDownDirs = ['NE', 'NW', 'SW'] as const - const nDirection = rng.int(0, templeDownDirs.length - 1) - const downSide = templeDownDirs[nDirection]! - const remapIdx = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.indexOf(downSide) - const remappedDefId = LVLPREST_ACT5_TEMPLE_REMAPPING_BASE + (remapIdx >= 0 ? remapIdx : 0) - ctx.stats.notes.push(`lvlPrestDef=${String(remappedDefId)} (Act 5 - Temple ${downSide} Down)`) - - placeNamedPiece(ctx, { - kind: 'down', - name: `Act 5 - Temple ${downSide} Down`, - sides: downSide, - plainSides: downSide, - preferFurthest: true, - }) - - let wpSide: string | undefined - if (levelId === 123) { - 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`, - sides: wpSide, - plainSides: wpSide, - preferFurthest: true, - }) - } - - if (wpSide !== 'SE') { - placeNamedPiece(ctx, { - kind: 'prev', - name: 'Act 5 - Temple SE up', - sides: 'SE', - plainSides: 'SE', - preferFurthest: false, - }) - } - } -} - -/* ------------------------------------------------------------------------- * - * 7. Act 5 - Baal - * ------------------------------------------------------------------------- */ - -/** - * Worldstone Keep Level 1, 2, 3 and Throne of Destruction. - * - * Throne of Destruction (level 131) places Baal Entrance / ThroneRoom. - * Worldstone Keep 2 places Waypoint and Next stairs. - */ -export function placeAct5BaalStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - const nDirection = rng.int(0, 3) - - if (levelId === 131) { - // Throne of Destruction - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const thronePiece = ctx.pieces.find(p => p.name === 'Act 5 - ThroneRoom' || p.name === 'Act 5 - Baal Entrance') - if (thronePiece !== undefined) { - ctx.level.first.piece = thronePiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, thronePiece, '', ctx.stats) - return - } - } - const placed = placeNamedPiece(ctx, { - kind: 'quest', - name: 'Act 5 - ThroneRoom', - preferFurthest: true, - }) - if (placed === null) { - placeNamedPiece(ctx, { - kind: 'quest', - name: 'Act 5 - Baal Entrance', - preferFurthest: true, - }) - } - } else if (levelId === 129) { - // Worldstone Keep Level 2: Next and Waypoint - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 5 - Baal Next', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'waypoint', - namePrefix: 'Act 5 - Baal Waypoint', - sides: DIRS[(nDirection + 1) % 4], - plainSides: DIRS[(nDirection + 1) % 4], - preferFurthest: true, - }) - } else { - // Worldstone Keep Levels 1, 3 - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 5 - Baal Next', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - } -} - -/* ------------------------------------------------------------------------- * - * 8. Act 5 - Ice - * ------------------------------------------------------------------------- */ - -/** - * Crystalline Passage, Frozen River, Glacial Trail, Drifter Cavern, Ancients' Way, Icy Cellar (`DRLGMAZE_PlaceAct5IceStuff`). - * - * Frozen River (level 114) rolls Anya's frozen platform (`LVLPREST_ACT5_ICE_RIVER_A = 1030` vs `B = 1031`) and places entry stairs. - * Drifter Cavern (116) places `LVLPREST_ACT5_ICE_POOL_A = 1032`; Icy Cellar (119) places `LVLPREST_ACT5_ICE_POOL_B = 1033`. - * Crystalline Passage (113), Glacial Trail (115), and Ancients' Way (118) place Prev, Next, Down, Theme, and Waypoint. - */ -export function placeAct5IceStuff(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - - if (levelId === 114) { - // Frozen River (Anya platform): 1.13c rolls Def 1030 (River A) vs Def 1031 (River B) - const variantIdx = rng.int(0, 1) - const variant = variantIdx === 0 ? 'A' : 'B' - const riverDefId = variantIdx === 0 ? LVLPREST_ACT5_ICE_RIVER_A : LVLPREST_ACT5_ICE_RIVER_B - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`) - ?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River')) - if (riverPiece !== undefined) { - ctx.level.first.piece = riverPiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, riverPiece, '', ctx.stats) - ctx.stats.notes.push(`lvlPrestDef=${String(riverDefId)} (${riverPiece.name})`) - return - } - } - replaceRoomByPresetDefId(ctx, riverDefId, { - kind: 'quest', - name: `Act 5 - Ice River ${variant}`, - preferFurthest: true, - }) - if ( - ctx.level.rooms.length > 1 && - !ctx.level.rooms.some(r => r.piece?.kind === 'prev') && - ctx.pieces.some(p => p.name.startsWith('Act 5 - Ice Prev')) - ) { - const prevDir = rng.int(0, 3) - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 5 - Ice Prev', - sides: DIRS[prevDir], - plainSides: DIRS[prevDir], - preferFurthest: false, - }) - } - } else if (levelId === 116) { - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool A') - if (poolPiece !== undefined) { - ctx.level.first.piece = poolPiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, poolPiece, '', ctx.stats) - ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_A)} (${poolPiece.name})`) - return - } - } - replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_ICE_POOL_A, { - kind: 'quest', - name: 'Act 5 - Ice Pool A', - preferFurthest: true, - }) - } else if (levelId === 119) { - if (ctx.level.rooms.length === 1 && ctx.level.first !== null) { - const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool B') - if (poolPiece !== undefined) { - ctx.level.first.piece = poolPiece - ctx.level.first.hasDs1 = true - noteSpecial(ctx.level.first, poolPiece, '', ctx.stats) - ctx.stats.notes.push(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_B)} (${poolPiece.name})`) - return - } - } - replaceRoomByPresetDefId(ctx, LVLPREST_ACT5_ICE_POOL_B, { - kind: 'quest', - name: 'Act 5 - Ice Pool B', - preferFurthest: true, - }) - } else { - // Levels 113, 115, 118 - let nDirection = rng.int(0, 3) - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 5 - Ice Prev', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: false, - }) - nDirection = (nDirection + 1) % 4 - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 5 - Ice Next', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - nDirection = (nDirection + 1) % 4 - placeNamedPiece(ctx, { - kind: 'down', - namePrefix: 'Act 5 - Ice Down', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - - if (levelId === 115) { - nDirection = (nDirection + 1) % 4 - placeNamedPiece(ctx, { - kind: 'theme', - namePrefix: 'Act 5 - Ice Theme', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - } - - if (levelId === 113 || levelId === 115 || levelId === 118) { - nDirection = (nDirection + 1) % 4 - const wpDef = LVLPREST_ACT5_ICE_WAYPOINT_BASE + nDirection - ctx.stats.notes.push(`lvlPrestDef=${String(wpDef)} (Act 5 - Ice waypoint ${DIRS[nDirection]})`) - placeNamedPiece(ctx, { - kind: 'waypoint', - namePrefix: 'Act 5 - Ice waypoint', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - } - } -} - -/* ------------------------------------------------------------------------- * - * 9. Act 1 - Barracks - * ------------------------------------------------------------------------- */ - -/** - * Monastery Barracks (level 28). - * - * Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail. - */ -export function placeAct1Barracks(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - // Connect towards Outer Cloister - placeNamedPiece(ctx, { - kind: 'prev', - name: 'Act 1 - Barracks Court Connect', - direction: DIRECTION_WEST, - preferFurthest: false, - }) - - const nDirection = rng.int(0, 3) - const roll = rng.int(0, 1) - - if (roll === 1) { - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 1 - Barracks Next', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'quest', - namePrefix: 'Act 1 - Barracks Forge', - sides: DIRS[(nDirection + 1) % 4], - plainSides: DIRS[(nDirection + 1) % 4], - preferFurthest: true, - }) - } else { - placeNamedPiece(ctx, { - kind: 'quest', - namePrefix: 'Act 1 - Barracks Forge', - sides: DIRS[nDirection], - plainSides: DIRS[nDirection], - preferFurthest: true, - }) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 1 - Barracks Next', - sides: DIRS[(nDirection + 1) % 4], - plainSides: DIRS[(nDirection + 1) % 4], - preferFurthest: true, - }) - } -} - -/* ------------------------------------------------------------------------- * - * 10. Act 4 - Lava - * ------------------------------------------------------------------------- */ - -/** - * River of Flame (level 107). - * - * 1.13c D2MOO DRLGMAZE_PlaceAct4Lava parity: - * 1. Places Warp N (entrance from City of Damned) at max Y going DIRECTION_SOUTH. - * 2. Chains 3 bridge rooms going DIRECTION_NORTH towards Chaos Sanctuary: - * - Bridge 1 (LVLPREST_ACT4_BRIDGE_1) - * - Bridge 2 segment 1 (LVLPREST_ACT4_BRIDGE_2) - * - Bridge 2 segment 2 (LVLPREST_ACT4_BRIDGE_2) - * 3. Hellforge (Hephasto the Armorer): - * - Roll forgeSide ('W' or 'E'). - * - Scan & Replace: In-place replace matching plain room (sides === forgeSide) if found. - * - Fallback: Grow adjacent room in DIRECTION_EAST (for W) or DIRECTION_WEST (for E). - * 4. Fill blank maze spaces with Act 4 - Lava X, IGNORING Bridge 2 segment 2 (bridge3) - * so the threshold to Chaos Sanctuary is not walled off. - */ -export function placeAct4Lava(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - - // Target pieces - const warpNPiece = ctx.pieces.find(p => p.name === 'Act 4 - Lava Warp N') - ?? ctx.pieces.find(p => p.name.startsWith('Act 4 - Lava Warp')) - ?? findSpecial(ctx.pieces, 'prev', 'N') - const bridge1Piece = ctx.pieces.find(p => p.name === 'Act 4 - Bridge 1') - ?? ctx.pieces.find(p => p.name.toLowerCase() === 'act 4 - bridge 1') - const bridge2Piece = ctx.pieces.find(p => p.name === 'Act 4 - Bridge 2') - ?? ctx.pieces.find(p => p.name.toLowerCase() === 'act 4 - bridge 2') - - // 1. Warp N (entrance from City of Damned) - // D2MOO DRLGMAZE_GetFreeLocationForRoomNorth: find room with max Y that can place DIRECTION_SOUTH - const southCandidates = [...ctx.level.rooms].sort((a, b) => b.y - a.y) - let warpRoom: Room | null = null - for (const parent of southCandidates) { - warpRoom = addAdjacentMazeRoom(ctx.level, parent, DIRECTION_SOUTH, false, true, ctx.index, ctx.stats) - if (warpRoom !== null) break - } - if (warpRoom === null && warpNPiece !== undefined) { - warpRoom = placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 4 - Lava Warp', - sides: 'N', - plainSides: 'N', - direction: DIRECTION_SOUTH, - preferFurthest: false, - }) - } else if (warpRoom !== null && warpNPiece !== undefined) { - warpRoom.piece = warpNPiece - warpRoom.variant = 0 - warpRoom.hasDs1 = true - noteSpecial(warpRoom, warpNPiece, 'N', ctx.stats) - } - - // 2. Bridges towards Chaos Sanctuary - // D2MOO DRLGMAZE_GetFreeLocationForRoomSouth: find room with min Y that can place DIRECTION_NORTH - // Chains 3 bridge rooms: Bridge 1, Bridge 2 (seg 1), Bridge 2 (seg 2) - const northCandidates = [...ctx.level.rooms].filter(r => r !== warpRoom).sort((a, b) => a.y - b.y) - let bridge1: Room | null = null - let bridge2: Room | null = null - let bridge3: Room | null = null - - for (const parent of northCandidates) { - bridge1 = addAdjacentMazeRoom(ctx.level, parent, DIRECTION_NORTH, false, true, ctx.index, ctx.stats) - if (bridge1 !== null) { - bridge2 = addAdjacentMazeRoom(ctx.level, bridge1, DIRECTION_NORTH, false, false, ctx.index, ctx.stats) - if (bridge2 !== null) { - bridge3 = addAdjacentMazeRoom(ctx.level, bridge2, DIRECTION_NORTH, false, false, ctx.index, ctx.stats) - if (bridge3 !== null) break - } - } - } - - // Fallback if tight space prevented placing all 3 chained bridges - if (bridge1 === null) { - bridge1 = placeNamedPiece(ctx, { - kind: 'quest', - name: 'Act 4 - Bridge 1', - direction: DIRECTION_NORTH, - preferFurthest: true, - }) - } else if (bridge1Piece !== undefined) { - bridge1.piece = bridge1Piece - bridge1.variant = 0 - bridge1.hasDs1 = true - noteSpecial(bridge1, bridge1Piece, '', ctx.stats) - } - - if (bridge2 === null) { - bridge2 = placeNamedPiece(ctx, { - kind: 'quest', - name: 'Act 4 - Bridge 2', - direction: DIRECTION_NORTH, - preferFurthest: true, - }) - } else if (bridge2Piece !== undefined) { - bridge2.piece = bridge2Piece - bridge2.variant = 0 - bridge2.hasDs1 = true - noteSpecial(bridge2, bridge2Piece, '', ctx.stats) - } - - if (bridge3 === null && bridge2 !== null) { - bridge3 = addAdjacentMazeRoom(ctx.level, bridge2, DIRECTION_NORTH, false, false, ctx.index, ctx.stats) - } - if (bridge3 !== null && bridge2Piece !== undefined) { - bridge3.piece = bridge2Piece - bridge3.variant = 0 - bridge3.hasDs1 = true - noteSpecial(bridge3, bridge2Piece, '', ctx.stats) - } - - // 3. Hellforge (Hephasto the Armorer) - // D2MOO DRLGMAZE_ScanReplaceSpecialPreset with nAct4LavaForgeIds: - // Forge W (Def 853) replaces Lava W (Def 837) or attaches in direction 2 (East) - // Forge E (Def 854) replaces Lava E (Def 838) or attaches in direction 0 (West) - const forgeSide = rng.int(0, 1) === 0 ? 'W' : 'E' - const forgeName = `Act 4 - Lava Forge ${forgeSide}` - const forgePiece = ctx.pieces.find(p => p.name === forgeName) - ?? ctx.pieces.find(p => p.name.startsWith('Act 4 - Lava Forge') && p.sides === forgeSide) - ?? findSpecial(ctx.pieces, 'quest', forgeSide) - ?? ctx.pieces.find(p => p.kind === 'quest' && p.sides === forgeSide) - - if (forgePiece !== undefined) { - const targetMask = sideMaskFromToken(forgeSide) - // Scan existing rooms: first check if a room already matches the single-door side - const replaceableRooms = ctx.level.rooms.filter(r => !r.hasDs1 && roomSideMask(r) === targetMask) - let forgeRoom: Room | null = replaceableRooms[0] ?? null - - if (forgeRoom !== null) { - // In-place replace - forgeRoom.piece = forgePiece - forgeRoom.variant = 0 - forgeRoom.hasDs1 = true - noteSpecial(forgeRoom, forgePiece, forgeSide, ctx.stats) - } else { - // Not found: attempt to attach in the designated direction (East for W, West for E) - const attachDir = forgeSide === 'W' ? DIRECTION_EAST : DIRECTION_WEST - const attachCandidates = ctx.level.rooms.filter(r => !r.hasDs1) - for (const parent of attachCandidates) { - forgeRoom = addAdjacentMazeRoom(ctx.level, parent, attachDir, false, true, ctx.index, ctx.stats) - if (forgeRoom !== null) break - } - if (forgeRoom === null) { - // Fallback to general placement - forgeRoom = placeNamedPiece(ctx, { - kind: 'quest', - name: forgeName, - plainSides: forgeSide, - preferFurthest: true, - }) - } else { - forgeRoom.piece = forgePiece - forgeRoom.variant = 0 - forgeRoom.hasDs1 = true - noteSpecial(forgeRoom, forgePiece, forgeSide, ctx.stats) - } - } - } - - // 4. Fill blank maze spaces with Act 4 - Lava X, ignoring bridge3 - fillBlankSpaces(ctx.level, ctx.pieces, ctx.index, ctx.stats, bridge3) -} - -/* ------------------------------------------------------------------------- * - * 11. Act 2 - Sewers - * ------------------------------------------------------------------------- */ - -/** - * Lut Gholein Sewers Level 1, 2, 3 and Ancient Tunnels. - * - * Level 3 places Radament's Lair (Book of Skill / Atma's quest). - * Level 1 places Prev E & Prev NS. - * Level 2 places Waypoint, Prev, Next. - * Ancient Tunnels (level 50) places Chest and Prev. - */ -export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void { - const rng = getPassRng(ctx) - const levelId = ctx.request.levelId - let nRand = rng.int(0, 3) - - if (levelId === 49) { - // Sewers Level 3: Radament's Lair - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 2 - Sewer Prev', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: false, - }) - nRand = (nRand + 1) % 4 - placeNamedPiece(ctx, { - kind: 'quest', - namePrefix: "Act 2 - Sewer Radament's Lair", - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: true, - }) - } else if (levelId === 47) { - // Sewers Level 1: Dock and City entrances - placeNamedPiece(ctx, { - kind: 'prev', - name: 'Act 2 - Sewer Prev E', - sides: 'E', - direction: DIRECTION_WEST, - preferFurthest: false, - }) - placeNamedPiece(ctx, { - kind: 'prev', - name: 'Act 2 - Sewer Prev NS', - sides: 'NS', - direction: DIRECTION_NORTH, - preferFurthest: false, - }) - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 2 - Sewer Next', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: true, - }) - } else if (levelId === 48) { - // Sewers Level 2: Prev, Waypoint, Next - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 2 - Sewer Prev', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: false, - }) - nRand = (nRand + 1) % 4 - placeNamedPiece(ctx, { - kind: 'waypoint', - namePrefix: 'Act 2 - Sewer Waypoint', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: true, - }) - nRand = (nRand + 1) % 4 - placeNamedPiece(ctx, { - kind: 'next', - namePrefix: 'Act 2 - Sewer Next', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: true, - }) - } else { - // Ancient Tunnels (level 50) or other - placeNamedPiece(ctx, { - kind: 'prev', - namePrefix: 'Act 2 - Sewer Prev', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: false, - }) - nRand = (nRand + 1) % 4 - placeNamedPiece(ctx, { - kind: 'treasure', - namePrefix: 'Act 2 - Sewer Chest', - sides: DIRS[nRand], - plainSides: DIRS[nRand], - preferFurthest: true, - }) - } -} - -/* ------------------------------------------------------------------------- * - * Master Runner - * ------------------------------------------------------------------------- */ - -export function runMazeSpecialPasses(passName: SpecialPassName, ctx: SpecialPassContext): void { - ctx.stats.specialPassesApplied.push(passName) - switch (passName) { - case 'DRLGMAZE_PlaceAct2TombStuff': - placeAct2TombStuff(ctx) - break - case 'DRLGMAZE_PlaceAct2LairStuff': - placeAct2LairStuff(ctx) - break - case 'DRLGMAZE_PlaceAct3DungeonStuff': - placeAct3DungeonStuff(ctx) - break - case 'DRLGMAZE_PlaceAct3SewerStuff': - placeAct3SewerStuff(ctx) - break - case 'DRLGMAZE_PlaceAct3MephistoStuff': - placeAct3MephistoStuff(ctx) - break - case 'DRLGMAZE_PlaceAct5TempleStuff': - placeAct5TempleStuff(ctx) - break - case 'DRLGMAZE_PlaceAct5BaalStuff': - placeAct5BaalStuff(ctx) - break - case 'DRLGMAZE_PlaceAct5IceStuff': - placeAct5IceStuff(ctx) - break - case 'DRLGMAZE_PlaceAct1Barracks': - placeAct1Barracks(ctx) - break - case 'DRLGMAZE_PlaceAct4Lava': - placeAct4Lava(ctx) - break - case 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets': - scanReplaceSpecialAct2SewersPresets(ctx) - break - } -} diff --git a/src/game/maze.ts b/src/game/maze.ts deleted file mode 100644 index b2cb92a..0000000 --- a/src/game/maze.ts +++ /dev/null @@ -1,2785 +0,0 @@ -/** - * Diablo II's random-maze level generator (`Levels.txt.DrlgType == 1`). - * - * 70 of the game's 136 levels have no fixed layout: their shape is produced at - * load time from `LvlMaze.txt` (how many sections, how big each section is, and - * how eagerly neighbouring sections are joined) plus the level type's - * `LvlPrest.txt` *piece* rows — each piece a DS1 that knows which of its four - * sides has a doorway. This module rebuilds that generator and emits a single - * synthesized {@link Ds1} the existing isometric renderer can draw unmodified. - * - * ## Provenance - * - * This is an **independent TypeScript port** of the level-generation logic 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 at each step. The algorithms themselves are Blizzard's, and D2MOO's - * contribution is having recovered them from the shipped binary. - * - * Primary sources, all under `source/D2Common/` in D2MOO: - * - * - `src/Drlg/DrlgMaze.cpp` — `DRLGMAZE_GenerateLevel` (the per-level-type - * dispatch), `DRLGMAZE_AddAdjacentMazeRoom` + `DRLGMAZE_BuildBasicMaze` + - * `DRLGMAZE_MergeMazeRooms` (placement and the `Merge` roll), - * `DRLGMAZE_InitBasicMazeLayout` (the ring layout), `DRLGMAZE_PickRoomPreset` - * (side bitmask → piece), `DRLGMAZE_RollAct_1_2_3_BasicPresets` (the `Theme` - * pass), `DRLGMAZE_ScanReplaceSpecialPreset` (`Prev`/`Next`/quest replacement), - * `DRLGMAZE_PlaceArcaneSanctuary`, `DRLGMAZE_PlaceAct5LavaPresets`, - * `DRLGMAZE_FillBlankMazeSpaces`, `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`. - * - `src/Drlg/DrlgDrlgRoom.cpp` — `DRLG_ComputeManhattanDistance`, - * `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`, - * `DRLGMAZE_CheckRoomNotOverlaping`, `DRLGROOM_AllocDrlgOrthsForRooms`. - * - `src/Drlg/DrlgDrlg.cpp` — `DRLG_GetDirectionFromCoordinates`, - * `DRLG_UpdateRoomExCoordinates`. - * - `src/Drlg/DrlgPreset.cpp` — `DRLGPRESET_InitPresetRoomGrids` and - * `DRLGPRESET_AddPresetUnitToDrlgMap`, which fix the stamping geometry and the - * unit of a DS1 object's coordinates. - * - `doc/Coordinates.md` — the coordinate systems. - * - * ## What D2MOO settles that this repository did not know - * - * **`LvlMaze.Merge` is a per-mille probability, not a flag.** Both - * `DRLGMAZE_AddAdjacentMazeRoom` (inline) and `DRLGMAZE_MergeMazeRooms` contain - * `if (SEED_RollRandomNumber(&i->pSeed) % 1000 < pLevel->pMaze->dwMerge)` before - * adding an extra doorway. So while a level grows, every *accidental* adjacency - * between the room just placed and some other already-placed room (orthogonally - * touching, not already connected) is joined with probability `Merge / 1000`. - * `Merge = 0` yields a tree (Act 1's cave treasures), `Merge = 500` joins about - * half of those contacts, and `Merge = 1000` (Act 2's Harem, Basement and the - * Arcane Sanctuary) joins all of them. The shipped values are 0, 500, 800 and - * 1000, which is why a per-mille reading is the only one that fits. The knowledge - * base's "possibly related to how adjacent ds1s are connected, but what the - * different values are for is unknown" is answered: it controls how many *loops* - * the maze has, i.e. how many alternate routes exist between rooms. - * - * **Connectivity is structural, not luck.** Growth only ever attaches a new room - * to an existing one and immediately records a doorway between them - * (`DRLGROOM_AllocDrlgOrthsForRooms`), so the doorway graph always contains a - * spanning tree rooted at the first room. `Merge` only ever *adds* doorways. - * {@link generateMaze} re-derives that with a flood fill and throws if it ever - * fails, so the guarantee is checked rather than asserted. - * - * **A maze piece is exactly one cell larger than its section.** `LvlMaze.SizeX` - * is the placement stride (24 in Act 1) while the piece DS1 is 25×25 — verified - * against the shipped archives: `Act1/Caves/CaveW.ds1` is 25×25 with - * `LvlPrest.SizeX = 24`. `DRLGPRESET_InitPresetRoomGrids` confirms the intent: it - * fills a room grid of `nTileWidth + 1` by `nTileHeight + 1` cells from the - * piece, so adjacent pieces overlap by exactly one cell and the later-stamped - * piece owns the shared border. Both halves of that border are authored to match, - * so the overwrite is harmless. The repository's assumption was correct. - * - * **Objects are in sub-tiles, cells are in cells.** - * `DRLGPRESET_AddPresetUnitToDrlgMap` converts the room's *tile* origin with - * `DUNGEON_GameTileToSubtileCoords` (×5) and *then* adds the DS1 object's own - * coordinates, which are therefore already sub-tiles relative to the piece. A - * stamp offset of `N` cells shifts an object by `N * 5` sub-tiles, exactly as - * this repository assumed. - */ -import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' -import { actOfLevel, type D2Table } from './acts.ts' -import { isWaypointDs1Object } from './object-lookup.ts' -import { Rng } from './rng.ts' -import { SUB_TILES_PER_TILE } from './map.ts' -export { SUB_TILES_PER_TILE } -import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.ts' -import { - populateDungeonObjects, - type PlacedMazeObject, - type MazeObjectPopulationOptions, - type MazeRoomDescriptor, -} from './maze-objects.ts' - -export type { - PlacedMazeObject, - MazeObjectPopulationOptions, - MazeObjectCategory, - ShrineSubType, - ChestSubType, - RackSubType, - ContainerSubType, - WallAlignment, -} from './maze-objects.ts' - -/* ------------------------------------------------------------------------- * - * Directions - * ------------------------------------------------------------------------- */ - -/** - * Direction indices, in D2MOO's `D2AltDirections` order. - * - * `DRLG_GetDirectionFromCoordinates` returns the `D2Directions` *names* but the - * same numbers, and D2MOO relies on that coincidence: `DIRECTION_SOUTHWEST` is 0 - * and `ALTDIR_WEST` is 0, `DIRECTION_NORTHWEST`/`ALTDIR_NORTH` are 1, - * `DIRECTION_SOUTHEAST`/`ALTDIR_EAST` are 2 and `DIRECTION_NORTHEAST`/`ALTDIR_SOUTH` - * are 3. Both are reproduced here as one space, since a maze direction is always - * an offset on the cell grid. - * - * The signs are the ones `doc/Coordinates.md` states: East is +X, West is -X, - * South is +Y, North is -Y. Note that D2's *names* for the quarter directions do - * not mean screen directions — `DIRECTION_NORTHEAST` is +Y, i.e. towards the - * bottom-left of the isometric view. Only the numbers are meaningful. - */ -export const DIRECTION_WEST = 0 -/** North is -Y in game coordinates. */ -export const DIRECTION_NORTH = 1 -/** East is +X in game coordinates. */ -export const DIRECTION_EAST = 2 -/** South is +Y in game coordinates. */ -export const DIRECTION_SOUTH = 3 - -/** - * The side bit `DRLGMAZE_PickRoomPreset` sets for each direction. - * - * It ORs 1 for direction 0, 8 for 1, 2 for 2 and 4 for 3, so the resulting 4-bit - * value is **W=1, E=2, S=4, N=8**. That looks arbitrary until it is compared with - * `LvlPrest.txt`: side tokens are written with their letters in N,S,E,W order and - * read as a bitfield most-significant-bit first, so `NS` = 8+4 = 12, `EW` = 2+1 = - * 3 and `NSEW` = 15. Verified against the real table — `Act 1 - Cave - * W/E/EW/S/SW/SE/SEW/N/NW/NE/NEW/NS/NSW/NSE/NSEW` are fifteen consecutive rows - * following the level type's base row, so a piece's row number is `base + mask`. - */ -const DIRECTION_SIDE_BIT = [1, 8, 2, 4] as const - -/** - * Cell offset for one step in a direction. - * - * Directions 4..7 are the diagonals, which only `DRLGMAZE_FillBlankMazeSpaces` - * uses (it tries all eight). Quarters and diagonals were cross-checked against - * `DRLGMAZE_LinkMazeRooms`' eight `case`s. - * - * @param direction - 0..7. - * @param width - one section's width in cells. - * @param height - one section's height in cells. - * @returns the offset. - */ -function step(direction: number, width: number, height: number): { dx: number; dy: number } { - const dx = (direction === DIRECTION_WEST || direction === 4 || direction === 7 ? -width - : direction === DIRECTION_EAST || direction === 5 || direction === 6 ? width : 0) - const dy = (direction === DIRECTION_NORTH || direction === 4 || direction === 5 ? -height - : direction === DIRECTION_SOUTH || direction === 6 || direction === 7 ? height : 0) - return { dx, dy } -} - -/* ------------------------------------------------------------------------- * - * Side tokens - * ------------------------------------------------------------------------- */ - -/** The four side letters, in the order `LvlPrest.txt` writes them. */ -const SIDE_LETTERS = ['N', 'S', 'E', 'W'] as const - -/** - * Decode a side token such as `NSEW`, `EW` or `N` into a bitmask. - * - * @param token - the token; characters other than `NSEW` are ignored, so a whole - * piece name may be passed. - * @returns the mask, 0..15. - */ -export function sideMaskFromToken(token: string): number { - let mask = 0 - for (const letter of token.toUpperCase()) { - const index = SIDE_LETTERS.indexOf(letter as (typeof SIDE_LETTERS)[number]) - if (index !== -1) mask |= 1 << (3 - index) - } - return mask -} - -/** - * Encode a bitmask the way `LvlPrest.txt` spells it. - * - * @param mask - the mask, 0..15. - * @returns the token, e.g. `NSEW`; an empty string for 0. - */ -export function sideTokenFromMask(mask: number): string { - let token = '' - SIDE_LETTERS.forEach((letter, index) => { - if ((mask & (1 << (3 - index))) !== 0) token += letter - }) - return token -} - -/* ------------------------------------------------------------------------- * - * Public request / response shapes - * ------------------------------------------------------------------------- */ - -/** How a maze piece participates in generation. */ -export type MazePieceKind = - | 'entrance' - | 'room' - | 'theme' - | 'prev' - | 'next' - | 'down' - | 'quest' - | 'treasure' - | 'waypoint' -/** - * A waypoint room, as reported in `stats.waypoints`. - */ -export interface MazeWaypoint { - /** The room the piece went into, matching `specialsApplied`. */ - readonly room: number - /** The room's top-left corner in the final map, in cells. */ - readonly x: number - readonly y: number - /** The room's extent, in cells. */ - readonly width: number - readonly height: number - /** The room's centre, where the player lands or clicks. */ - readonly centreX: number - readonly centreY: number - /** Sub-tile coordinates of the waypoint entity within the synthesized map. */ - readonly entityX: number - readonly entityY: number -} - -/** - * One `LvlPrest.txt` row that can be stamped into a maze level. - * - * The caller owns the classification, because it is the only party that knows - * which DS1 files sit behind each row. {@link classifyMazePieceName} does that - * classification for callers that want it, since the trailing-token grammar is - * subtle enough to be worth centralising. - */ -export interface MazePiece { - /** `LvlPrest.txt` `Name`, e.g. `Act 1 - Cave NSEW`. */ - readonly name: string - /** Which generator pass may place this piece. */ - readonly kind: MazePieceKind - /** - * The side token decoded from the name — `NSEW`, `EW`, `N`, or `''` for a piece - * with no doorways (`Entrance`). - */ - readonly sides: string - /** - * Decoded variants, in `File1..File6` order. - * - * A piece usually has several: `Act 1 - Cave W` ships `CaveW.ds1` and - * `CaveW2.ds1`. D2MOO walks them round-robin per piece so consecutive rooms of - * the same shape do not look identical (`DRLGMAZE_RollBasicPresets`), and - * {@link generateMaze} does the same. - */ - readonly levels: readonly Ds1[] - /** - * `LvlPrest.txt` `KillEdge`. - * - * 1.13c reads this off the maze map's `LvlPrest` record and, when set, trims - * the one-cell overhang off whichever room sits on the map's outer edge — see - * {@link stamp} and `DRLGPRESET_InitPresetRoomGrids` - * (`DrlgPreset.cpp:1105`). The shipped table sets it per *family*, not per - * row: `Act 1 - Cave`, `Act 2 - Sewer`, `Act 2 - Arcane`, `Act 4 - Mesa`, - * `Act 5 - Temple` and `Act 5 - Ice` are all `1`, while `Act 1 - Crypt`, - * `Barracks`, `Jail`, `Catacombs`, `Act 2 - Basement`, `Act 2 - Tomb` and - * `Act 3 - Sewer` are all `0`. - * - * Optional so that callers which do not read the column keep the pre-1.13c - * behaviour of never trimming. - */ - readonly killEdge?: boolean - /** - * Animated tile playback speed from `LvlPrest.txt` `Animate` column. - * In 1.13c, 0 defaults to 80 (128ms per frame at 25Hz). - */ - readonly animSpeed?: number -} - -/** Everything {@link generateMaze} needs. */ -export interface MazeRequest { - /** `Levels.txt` `Id`; used in diagnostics and for a few per-level specials. */ - readonly levelId: number - /** `Levels.txt` `Name`, e.g. `Act 1 - Cave 1`. */ - readonly levelName: string - /** `LvlMaze.txt` `SizeX`: the section stride in cells. */ - readonly sectionSize: number - /** - * `LvlMaze.txt` `SizeY`. - * - * Defaults to {@link sectionSize}. It is a separate field because the two - * genuinely differ for Act 1's Barracks (`SizeX = 10`, `SizeY = 14`), and a - * single number would silently produce a wrong layout there. - */ - readonly sectionHeight?: number - /** `LvlMaze.txt` `Rooms`: the target number of sections. */ - readonly minRooms: number - /** - * `LvlMaze.txt` `Merge`, in per-mille. - * - * Values in the shipped table are 0, 500, 800 and 1000. - */ - readonly merge: number - /** Seed for the whole generator; reported back in `stats`. */ - readonly seed: number - /** The level type's pieces, classified by kind and side token. */ - readonly pieces: readonly MazePiece[] - /** - * Overrides the `KillEdge` value derived from {@link pieces}. - * - * 1.13c takes the flag from the maze map's own `LvlPrest` record - * (`pMazeMap->pLvlPrestTxtRecord->dwKillEdge`), which in the shipped table - * always agrees with the level type's piece rows. When this is omitted the - * value is derived from the pieces instead, so a caller that already parsed - * `LvlPrest.txt` needs to do nothing extra. - */ - readonly killEdge?: boolean - - /** - * `LvlTypes.txt` name, e.g. `Act 1 - Cave`. - * - * Optional because it is derivable from {@link pieces} — every piece name - * starts with it — and the derivation is attempted when it is absent. - */ - readonly levelTypeName?: string - /** - * `Levels.txt` `Id` of the tomb holding the Horadric Staff, when the caller - * knows it. In the shipped game this is one of the seven Tal Rasha tombs and - * that tomb's `LvlMaze.Rooms` is tripled. - */ - readonly staffTombLevelId?: number - /** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */ - readonly bossTombLevelId?: number - /** - * Explicit override for waypoint placement. - * If true, forces a waypoint room to be placed (using waypoint pieces). - * If false, skips waypoint room placement even if the level profile includes it. - * If omitted, defaults to whether the level id has a waypoint in the profile. - */ - readonly hasWaypoint?: boolean - /** - * Maximum allowed width of the entire maze in cells (`Levels.txt` `SizeX`). - * When specified, any room placement that would expand the overall maze bounding box - * beyond this width will be rejected. - */ - readonly maxCellsX?: number | undefined - /** - * Maximum allowed height of the entire maze in cells (`Levels.txt` `SizeY`). - * When specified, any room placement that would expand the overall maze bounding box - * beyond this height will be rejected. - */ - readonly maxCellsY?: number | undefined - /** - * Maximum allowed sections along X axis. - * If provided and `maxCellsX` is not specified, `maxCellsX` is computed as `maxSectionsX * sectionSize`. - */ - readonly maxSectionsX?: number | undefined - /** - * Maximum allowed sections along Y axis. - * If provided and `maxCellsY` is not specified, `maxCellsY` is computed as `maxSectionsY * (sectionHeight ?? sectionSize)`. - */ - readonly maxSectionsY?: number | undefined - /** Dynamic dungeon object seeding options. */ - readonly objectOptions?: MazeObjectPopulationOptions - /** Configurable object density multiplier or options. */ - readonly objectDensity?: number | MazeObjectPopulationOptions -} - -/** The generated level. */ -export interface MazeResult { - /** A single synthesized map covering every placed section. */ - readonly level: Ds1 - /** Dynamically seeded interactive objects (shrines, chests, racks, breakables). */ - readonly objects: readonly PlacedMazeObject[] - /** What the generator did, for reporting and tests. */ - readonly stats: Record - /** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */ - readonly animSpeed?: number -} - -/* ------------------------------------------------------------------------- * - * Classification - * ------------------------------------------------------------------------- */ - -/** Roles whose names are spelled ` ` after the level type. */ -const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [ - ['Den Of Evil', 'quest'], - ['Coldcrow', 'quest'], - ['Pitspawn', 'quest'], - ['Bonebreak', 'quest'], - ['Chest', 'treasure'], - ['Treasure', 'treasure'], - ['Cath', 'down'], - ['Drain', 'next'], - ['Warp', 'prev'], - ['Down', 'down'], - ['Next', 'next'], - ['Prev', 'prev'], - ['Theme', 'theme'], - ['Waypoint', 'waypoint'], - ['waypoint', 'waypoint'], - ['Talrasha', 'quest'], - ["Radament's Lair", 'quest'], - ['Radament', 'quest'], - ['Tight Spot', 'quest'], - ['Forge', 'quest'], - ['Court Connect', 'next'], - ['Bridge', 'next'], - ['Complex', 'quest'], - ['Final Room', 'quest'], - ['Cube', 'treasure'], - ['Leatherarm', 'quest'], - ['Kaa', 'quest'], -] - -/** - * Relationship between Levels.txt, LvlTypes.txt, and LvlPrest.txt: - * - * In Diablo II's data architecture, map pieces and environments are resolved - * across three interconnected tables: - * - * 1. `Levels.txt` contains level declarations. Each row has an `Id` (1..136) and - * a `LevelType` foreign key pointing to `LvlTypes.txt` (or 0 for preset-only - * levels like towns). - * 2. `LvlTypes.txt` defines visual and audio themes (e.g. `Act 1 - Cave`, - * `Act 1 - Catacombs`, `Act 2 - Harem`, `Act 3 - Kurast`). It specifies up to - * 32 DT1 tile libraries (`File 1` .. `File 32`) containing the graphic art. - * 3. `LvlPrest.txt` catalogs the authored DS1 map presets and maze room pieces. - * Each row contains `Def` (the preset ID), `Name`, `LevelId`, `Dt1Mask`, - * `SizeX`, `SizeY`, and up to 6 DS1 file paths (`File1` .. `File6`). - * - * ### Why Catacombs / Sewers index pieces via `Def` / `LevelId = 0` / first `LevelId`: - * - * - Preset levels (like Towns, Tristram, Countess Tower) have fixed layouts and - * their rows in `LvlPrest.txt` set `LevelId` to their exact `Levels.txt.Id` - * (e.g., Rogue Encampment has `LevelId = 1`, Lut Gholein has `LevelId = 40`). - * - Maze levels (Catacombs 1-4 [Levels 32-35], Sewers Act 2 [Levels 41-43], - * Sewers Act 3 [Levels 92-93], Caves 1-2 [Levels 2-3]), in contrast, are generated - * dynamically by stitching dozens of modular 25x25 cell DS1 rooms (with 1..4 doors, - * stairs up/down, waypoints, quest rooms, themed variants). - * - Blizzard North did not duplicate 30+ room rows for each level layer in - * `LvlPrest.txt`. Instead: - * - Room pieces are shared across all levels sharing the theme, stored with - * `LevelId = 0` or set to the first `LevelId` where that theme appears (e.g. 32 - * for Catacombs). - * - In D2MOO and the native Blizzard engine (`DRLGMAZE_PickRoomPreset`, - * `DRLGMAZE_RollBasicPresets`, `DRLGMAZE_ScanReplaceSpecialPreset`), pieces - * are indexed either by hardcoded `Def` base offsets (`base + door_bitmask`) - * or discovered by prefix matching against the piece's `Name` column. - * - * ### Why composite themes have sub-families: - * - * Certain levels share a primary tile theme (`LevelType`) but introduce distinct - * visual zones, boss chambers, or quest rooms authored under different `Name` - * prefixes in `LvlPrest.txt`: - * - `Act 2 - Harem`: Combines normal palace rooms (`Act 2 - Harem ...`) with - * corrupted harem cellar and basement pieces (`Act 2 - Corrupt Harem ...`). - * - `Act 3 - Kurast`: Standard Kurast dungeon rooms (`Act 3 - Kurast ...`) are - * supplemented in Durance of Hate Level 3 with Mephisto's sanctum pieces - * (`Act 3 - Mephisto ...`). - * - `Act 3 - Dungeon`: Jungle dungeon rooms (`Act 3 - Dungeon ...`) incorporate - * temple quest chambers (`Act 3 - Temple ...`). - * - `Act 4 - Lava`: River of Flame and Chaos Sanctuary lava island pieces - * (`Act 4 - Lava ...`) are linked by preset bridge pieces (`Act 4 - Bridge ...`). - * - `Act 5 - Ice Caves`: Contains both full-name (`Act 5 - Ice Caves ...`) and - * abbreviated (`Act 5 - Ice ...`) piece definitions in `LvlPrest.txt`. - * - `Act 5 - Baal`: Worldstone Keep pieces (`Act 5 - Baal ...`) terminate in the - * Throne Room of Destruction (`Act 5 - ThroneRoom ...`). - * - * Because `LvlPrest.txt` lacks a `LevelType` column, these sub-families cannot be - * joined by database foreign key alone; they reflect Blizzard's C++ DRLG engine - * conventions and are captured canonically in {@link MAZE_PIECE_FAMILIES}. - */ -export const MAZE_PIECE_FAMILIES: Readonly> = { - 'Act 1 - Cave': ['Act 1 - Cave'], - 'Act 1 - Crypt': ['Act 1 - Crypt'], - 'Act 1 - Courtyard': ['Act 1 - Courtyard'], - 'Act 1 - Barracks': ['Act 1 - Barracks'], - 'Act 1 - Jail': ['Act 1 - Jail'], - 'Act 1 - Catacombs': ['Act 1 - Catacombs'], - 'Act 2 - Sewer': ['Act 2 - Sewer'], - 'Act 2 - Harem': ['Act 2 - Corrupt Harem', 'Act 2 - Harem'], - 'Act 2 - Basement': ['Act 2 - Basement'], - 'Act 2 - Tomb': ['Act 2 - Tomb'], - 'Act 2 - Lair': ['Act 2 - Lair'], - 'Act 2 - Sanctuary': ['Act 2 - Sanctuary'], - 'Act 3 - Sewer': ['Act 3 - Sewer'], - 'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'], - 'Act 3 - Dungeon': ['Act 3 - Dungeon', 'Act 3 - Temple'], - 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Bridge'], - 'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'], - 'Act 5 - Temple': ['Act 5 - Temple'], - 'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'], -} - -/** - * Discover the `LvlPrest` name families belonging to a maze level type. - * - * If `lvlPrestTable` is supplied, validates and discovers family prefixes present - * in the table that correspond to the given `levelTypeName`. - * If `lvlPrestTable` is omitted or contains no matching family rows, falls back to - * {@link MAZE_PIECE_FAMILIES}. - * - * @param levelTypeName - the owning `LvlTypes.txt` name (e.g. `'Act 2 - Harem'`). - * @param lvlPrestTable - optional loaded `LvlPrest.txt` table. - * @returns list of family name prefixes in precedence order (longer/more specific prefixes first). - */ -export function discoverMazePieceFamilies( - levelTypeName: string, - lvlPrestTable?: D2Table, -): readonly string[] { - const staticFamilies = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName] - if (lvlPrestTable === undefined) { - return staticFamilies - } - - const nameCol = lvlPrestTable.header.indexOf('Name') - if (nameCol === -1) { - return staticFamilies - } - - const discovered = new Set() - for (const fam of staticFamilies) { - const lowerFam = fam.toLowerCase() - for (const row of lvlPrestTable.rows) { - const rowName = (row[nameCol] ?? '').trim().toLowerCase() - if (rowName.startsWith(lowerFam)) { - discovered.add(fam) - break - } - } - } - - if (discovered.size === 0) { - return staticFamilies - } - - const result = staticFamilies.filter(fam => discovered.has(fam)) - return result.length > 0 ? result : staticFamilies -} - -/** - * Classify one `LvlPrest.txt` piece name. - * - * The grammar is ` [] []`: `Act 1 - Cave NSEW` is a - * plain room, `Act 1 - Cave Theme NS` a themed variant, `Act 1 - Cave Prev W` a - * staircase, `Act 1 - Cave Treasure 3` a numbered treasure room. A name that does - * not start with the level type's name is not a piece of that type and yields - * `null`, which is how `Act 1 - DOE Entrance` stays out of the `Act 1 - Cave` - * group even though D2MOO treats its row as that group's base. - * - * @param name - the `LvlPrest.txt` `Name`. - * @param levelTypeName - the owning `LvlTypes.txt` name. - * @param lvlPrestTable - optional loaded `LvlPrest.txt` table for dynamic family discovery. - * @returns the kind and side token, or `null` when the name is not a piece. - */ -export function classifyMazePieceName( - name: string, - levelTypeName: string, - lvlPrestTable?: D2Table, -): { kind: MazePieceKind; sides: string } | null { - const families = discoverMazePieceFamilies(levelTypeName, lvlPrestTable) - const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase())) - if (matchedFamily === undefined) return null - const rest = name.slice(matchedFamily.length).trim() - if (rest === '' || rest.toLowerCase() === 'entrance') { - if (matchedFamily.toLowerCase().endsWith('throneroom')) return { kind: 'quest', sides: '' } - return { kind: 'entrance', sides: '' } - } - for (const [prefix, kind] of KIND_PREFIXES) { - if (rest.toLowerCase().startsWith(prefix.toLowerCase())) { - const tail = rest.slice(prefix.length).trim() - // `Treasure 1`, `Temple 1`, `Bridge 1` are numbered rather than sided; everything else uses sides. - if ((kind === 'treasure' || kind === 'quest' || kind === 'next') && /^\d+$/.test(tail)) { - return { kind, sides: '' } - } - return { kind, sides: tail } - } - } - if (/^\d+$/.test(rest)) { - if (matchedFamily.toLowerCase().endsWith('bridge')) return { kind: 'next', sides: '' } - if (matchedFamily.toLowerCase().endsWith('temple')) return { kind: 'quest', sides: '' } - if (matchedFamily.toLowerCase().endsWith('treasure')) return { kind: 'treasure', sides: '' } - } - const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest) - if (reversedMatch !== null) { - const sides = reversedMatch[1]!.toUpperCase() - const role = reversedMatch[2]!.toLowerCase() - return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', sides } - } - // Anything left is a bare side token: `W`, `EW`, `NSEW`, or `X` (blank filler) - if (/^([NSEW]+|X)$/i.test(rest)) return { kind: 'room', sides: rest.toUpperCase() === 'X' ? '' : rest.toUpperCase() } - if (/^(River|Pool|Tainted Sun)/i.test(rest)) return { kind: 'room', sides: '' } - return null -} - -/** - * Derive a level type's name from its pieces. - * - * Used when {@link MazeRequest.levelTypeName} is omitted: the name is the longest - * prefix shared by every piece name, trimmed back to a word boundary, because - * `Act 1 - Cave NSEW` and `Act 1 - Cave Theme W` agree on `Act 1 - Cave ` and the - * trailing space is the boundary. - * - * @param pieces - the level's pieces. - * @returns the inferred name, or `null` when the pieces disagree. - */ -export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null { - const first = pieces[0] - if (first === undefined) return null - let prefix = first.name - for (const piece of pieces) { - while (prefix.length > 0 && !piece.name.startsWith(prefix)) prefix = prefix.slice(0, -1) - } - const trimmed = prefix.replace(/[\s-]+$/, '') - if (trimmed === '') return null - if (trimmed === 'Act 5 - Ice') return 'Act 5 - Ice Caves' - return trimmed -} - -/* ------------------------------------------------------------------------- * - * Level-type profiles - * ------------------------------------------------------------------------- */ - -/** - * One `DRLGMAZE_ScanReplaceSpecialPreset` call site. - * - * That function has two modes and both are reproduced: it first looks for the - * earliest room still holding the *plain* piece for this side pattern and swaps - * the piece in place; only if no such room exists does it grow a brand-new room - * off the first available room in `direction` and put the special piece there. - * - * `DRLGMAZE_ReplaceRoomPreset`, used by Act 3's Sewer 1, is the in-place-only - * variant and is modelled with {@link inPlaceOnly}. - */ -interface SpecialStep { - /** Which kind of special piece to place. */ - readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' | 'waypoint' - /** Side token of the plain room this step replaces, e.g. `N`. */ - readonly plainSides: string - /** Side token of the special piece. */ - readonly sides: string - /** Direction used when a new room must be created (`nDirection`). */ - readonly direction: number - /** Only replace an existing room; never grow one (D2MOO's `ReplaceRoomPreset`). */ - readonly inPlaceOnly?: boolean -} - -/** - * A group of four candidate steps selected by one rotating counter. - * - * D2MOO draws `nRand = SEED_RollRandomNumber(&pLevel->pSeed) & 3` once and then - * indexes each hardcoded table with it, incrementing the counter after every - * call that actually happens. So the *n*-th group applied to a level uses entry - * `(nRand + n) % 4`, not the first entry. Modelling the tables as groups with a - * shared cursor reproduces that exactly; flattening them into one ordered list - * would not. - */ -interface SpecialGroup { - readonly kind: SpecialStep['kind'] - /** Four entries, in D2MOO's table order (N, E, S, W). */ - readonly entries: readonly Omit[] - /** Restrict the group to these `Levels.txt` ids; absent means every level. */ - readonly levelIds?: readonly number[] - /** Apply to every level *except* these ids (D2MOO's `else` arms). */ - readonly exceptLevelIds?: readonly number[] - /** Only replace; never grow (D2MOO's `ReplaceRoomPreset`). */ - readonly inPlaceOnly?: boolean -} - -/** - * How one maze level type is laid out. - * - * D2MOO's `DRLGMAZE_GenerateLevel` `switch (pLevel->nLevelType)` turned into - * data. Where that function has both a `while (pLevel->nRooms < nRooms)` loop and - * a `DRLGMAZE_BuildBasicMaze` call, the two are the same growth primitive spelled - * twice — see {@link growMaze} — so both are represented by `grow: true`. - */ -export interface MazeLevelProfile { - /** `LvlTypes.txt` name this profile applies to. */ - readonly levelTypeName: string - /** - * `DRLGMAZE_InitBasicMazeLayout(nRoomsPerDirection)`: pre-place a closed ring of - * `4n - 4` rooms, then grow from it. Act 3's Sewer 1 overrides this with 5. - */ - readonly ring?: number - /** Grow to `LvlMaze.Rooms` sections with the random-adjacency loop. */ - readonly grow: boolean - /** - * `DRLGMAZE_RollAct_1_2_3_BasicPresets`: convert up to - * `max(rooms / 5 + 1, 2)` plain rooms to their `Theme` variant. - */ - readonly theme: boolean - /** `'catacombs'`: the Act 1 Catacombs opening moves. */ - readonly opening?: 'catacombs' - /** Chain of three rooms plus a `Prev` piece on the entrance. */ - readonly prevChain?: 'tomb' | 'baal' - /** `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches. */ - readonly arcane?: boolean - /** `DRLGMAZE_PlaceAct5LavaPresets`: two fixed pieces plus blank filling. */ - readonly lavaPairs?: boolean - /** - * Levels that are a single fixed `LvlPrest.txt` row rather than a maze, with - * the exact names to try, in order. Used for the three Act 5 ice set pieces and - * Claw Viper Temple level 2. - */ - readonly singleRoom?: Readonly> - /** `Prev`/`Next`/quest/treasure replacement groups, in call order. */ - readonly specials: readonly SpecialGroup[] - /** Optional official dungeon special pass from D2MOO. */ - readonly specialPass?: SpecialPassName - /** Animated tile speed override from LvlPrest.txt (default 80 if 0 or omitted). */ - readonly animSpeed?: number -} - -/** The four single-side layouts, in the order the D2MOO tables list them. */ -const NESW = ['N', 'E', 'S', 'W'] as const - -/** Direction paired with each of {@link NESW} in the D2MOO tables. */ -const NESW_DIRECTIONS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST] as const - -/** - * Build the four side entries of a D2MOO table. - * - * @param plainSides - the four plain side tokens, in table order. - * @returns the entries. - */ -function entries(plainSides: readonly string[] = NESW): Omit[] { - return plainSides.map((sides, index) => ({ plainSides: sides, sides, direction: NESW_DIRECTIONS[index]! })) -} - -/** - * The per-level-type layout table, transcribed from `DRLGMAZE_GenerateLevel`. - * - * `specials` covers the replacement tables that live inline in that function. - * The per-act decoration passes that follow it in D2MOO — `DRLGMAZE_PlaceAct2TombStuff`, - * `DRLGMAZE_PlaceAct3DungeonStuff`, `DRLGMAZE_PlaceAct5IceStuff` and the rest — - * live in `maze-special-passes.ts` and are named by a row's `specialPass`; each - * one that runs is listed in `stats.specialPassesApplied`. A row with an empty - * `specials` table has no transcribed staircase pass at all, and - * {@link unimplementedPassesFor} reports that gap in `stats.unimplementedPasses` - * so the omission is visible rather than silent. - */ -export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ - { - levelTypeName: 'Act 1 - Cave', - grow: true, - theme: true, - specials: [ - { kind: 'prev', entries: entries() }, - { kind: 'quest', entries: entries(), levelIds: [8] }, - { kind: 'down', entries: entries(), exceptLevelIds: [8] }, - { kind: 'quest', entries: entries(), levelIds: [9] }, - { kind: 'next', entries: entries(), levelIds: [10] }, - ], - }, - { - levelTypeName: 'Act 1 - Crypt', - grow: true, - theme: true, - specials: [ - { kind: 'prev', entries: entries() }, - { kind: 'quest', entries: entries(), levelIds: [18] }, - { kind: 'treasure', entries: entries(), levelIds: [19] }, - { kind: 'next', entries: entries(), levelIds: [21, 22, 23, 24] }, - ], - }, - { - levelTypeName: 'Act 1 - Barracks', - ring: 2, - grow: true, - theme: true, - specials: [ - { kind: 'next', entries: entries() }, - ], - specialPass: 'DRLGMAZE_PlaceAct1Barracks', - }, - { - levelTypeName: 'Act 1 - Jail', - ring: 2, - grow: true, - theme: true, - specials: [ - { kind: 'prev', entries: entries() }, - { kind: 'waypoint', entries: entries(), levelIds: [29] }, - { kind: 'quest', entries: entries(), levelIds: [30] }, - // D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a - // `Next` staircase and level 3 gets the Cathedral descent instead. - { kind: 'next', entries: entries(), levelIds: [29, 30] }, - { kind: 'down', entries: entries(), levelIds: [31] }, - ], - }, - { - levelTypeName: 'Act 1 - Catacombs', - grow: true, - theme: true, - opening: 'catacombs', - specials: [ - { kind: 'next', entries: entries() }, - { kind: 'waypoint', entries: entries(), levelIds: [35] }, - ], - }, - { - levelTypeName: 'Act 2 - Sewer', - ring: 2, - grow: true, - theme: true, - specials: [ - { kind: 'prev', entries: entries() }, - { kind: 'next', entries: entries(), levelIds: [47, 48] }, - ], - specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets', - }, - { - levelTypeName: 'Act 2 - Tomb', - grow: true, - theme: true, - prevChain: 'tomb', - singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] }, - specials: [ - { kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] }, - ], - specialPass: 'DRLGMAZE_PlaceAct2TombStuff', - }, - { - levelTypeName: 'Act 2 - Lair', - ring: 2, - grow: true, - theme: false, - specials: [ - { kind: 'prev', entries: entries() }, - { kind: 'next', entries: entries(), levelIds: [62, 63] }, - ], - specialPass: 'DRLGMAZE_PlaceAct2LairStuff', - }, - { - levelTypeName: 'Act 2 - Arcane', - grow: false, - theme: false, - arcane: true, - specials: [{ kind: 'quest', entries: entries() }], - }, - { - levelTypeName: 'Act 2 - Harem', - ring: 2, - grow: false, - theme: false, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [51] }, - { kind: 'next', entries: entries(), levelIds: [51] }, - ], - }, - { - levelTypeName: 'Act 2 - Basement', - ring: 2, - grow: false, - theme: false, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [52, 53, 54] }, - { kind: 'waypoint', entries: entries(), levelIds: [52] }, - { kind: 'next', entries: entries(), levelIds: [52, 53] }, - ], - }, - { - levelTypeName: 'Act 3 - Spider', - ring: 2, - grow: false, - theme: false, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [84, 85] }, - { kind: 'treasure', entries: entries(), levelIds: [84, 85] }, - ], - }, - { - levelTypeName: 'Act 3 - Kurast', - ring: 2, - grow: true, - theme: true, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [100, 101] }, - { kind: 'next', entries: entries(), levelIds: [100, 101] }, - ], - specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff', - }, - { - levelTypeName: 'Act 3 - Dungeon', - ring: 2, - grow: true, - theme: true, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] }, - { kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] }, - ], - specialPass: 'DRLGMAZE_PlaceAct3DungeonStuff', - }, - { - levelTypeName: 'Act 3 - Sewer', - ring: 2, - grow: true, - theme: true, - specials: [ - // Sewer 1 runs `InitBasicMazeLayout(5)` and places corner staircases for - // both surface exits (Kurast Bazaar via NW/NE and Upper Kurast via SW/SE), - // plus the drain descent to Sewer Level 2. - { - kind: 'prev', - entries: [ - { plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH }, - { plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH }, - { plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH }, - { plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH }, - ], - levelIds: [92], - inPlaceOnly: true, - }, - { - kind: 'prev', - entries: [ - { plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH }, - { plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH }, - { plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH }, - { plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH }, - ], - levelIds: [92], - inPlaceOnly: true, - }, - { - kind: 'next', - entries: entries(), - levelIds: [92], - }, - ], - specialPass: 'DRLGMAZE_PlaceAct3SewerStuff', - }, - { - levelTypeName: 'Act 4 - Lava', - grow: true, - theme: false, - animSpeed: 1, - specials: [], - specialPass: 'DRLGMAZE_PlaceAct4Lava', - }, - { - levelTypeName: 'Act 5 - Ice Caves', - ring: 2, - grow: true, - theme: false, - singleRoom: { - 114: ['Act 5 - Ice River A', 'Act 5 - Ice River B'], - 116: ['Act 5 - Ice Pool A'], - 119: ['Act 5 - Ice Pool B'], - }, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [113, 115, 118] }, - { kind: 'next', entries: entries(), levelIds: [113, 115, 118] }, - { kind: 'down', entries: entries(), levelIds: [113, 115, 118] }, - ], - specialPass: 'DRLGMAZE_PlaceAct5IceStuff', - }, - { - levelTypeName: 'Act 5 - Temple', - ring: 2, - grow: false, - theme: false, - specials: [], - specialPass: 'DRLGMAZE_PlaceAct5TempleStuff', - }, - { - levelTypeName: 'Act 5 - Baal', - grow: true, - theme: false, - prevChain: 'baal', - specials: [ - { kind: 'next', entries: entries(), levelIds: [128, 129, 130] }, - ], - specialPass: 'DRLGMAZE_PlaceAct5BaalStuff', - }, - { - levelTypeName: 'Act 5 - Lava', - grow: false, - theme: false, - lavaPairs: true, - specials: [ - { kind: 'prev', entries: entries(), levelIds: [125, 126, 127] }, - { kind: 'treasure', entries: entries(), levelIds: [125, 126, 127] }, - ], - }, -] - -/** - * `LvlPrest.txt` names a level type needs from outside its own name prefix. - * - * Only Act 5's Lava levels do this: `DRLGMAZE_PlaceAct5LavaPresets` fills the - * blank space around the Infernal Pit's two set pieces with the *Act 4* lava - * filler row, so a caller that groups pieces strictly by name prefix must fetch - * these extra rows too. - */ -export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X'] - -/** - * The gaps one level type still has, derived from its own profile row. - * - * This used to be a module constant hardcoded to `[]` with a comment claiming - * every pass was implemented, which made `stats.unimplementedPasses` report - * "no gaps" for a level that stamps no staircase at all. The list is now - * *measured* from {@link MAZE_LEVEL_TYPE_PROFILES}, so it cannot drift away - * from the table it describes: - * - * - **no profile row** — `generateMaze` cannot build the type at all. - * - **an empty `specials` table** — the `Prev`/`Next`/quest replacement rows - * that `DRLGMAZE_GenerateLevel` runs inline have not been transcribed, so the - * level stamps no staircase room and the packer has to invent a fallback warp - * (see `scripts/pack-act-assets.ts`). A `singleRoom` type is exempt: it is one - * fixed `LvlPrest.txt` row whose stairs are part of the artwork. - * - * The 11 per-act decoration passes named in D2MOO (`DRLGMAZE_PlaceAct2TombStuff` - * and the rest) *are* reproduced, in `maze-special-passes.ts`; a type that runs - * one names it in `stats.specialPassesApplied` instead. - * - * @param levelTypeName - the `LvlTypes.txt` name. - * @returns the gaps, empty when the type is fully transcribed. - */ -export function unimplementedPassesFor(levelTypeName: string): readonly string[] { - const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName) - if (profile === undefined) return [`DRLGMAZE_GenerateLevel profile (${levelTypeName})`] - const gaps: string[] = [] - if (profile.specials.length === 0 && profile.singleRoom === undefined && profile.specialPass === undefined) { - gaps.push(`DRLGMAZE_GenerateLevel specials table (${levelTypeName})`) - } - return gaps -} - -/** - * Every gap {@link unimplementedPassesFor} can name, across all level types. - * - * Non-empty while any level type is still missing its replacement table. Prefer - * the per-level-type function: a caller that reports this constant for one level - * is reporting other levels' gaps as its own. - */ -export const UNIMPLEMENTED_PASSES: readonly string[] = MAZE_LEVEL_TYPE_PROFILES - .flatMap(profile => unimplementedPassesFor(profile.levelTypeName)) - -/* ------------------------------------------------------------------------- * - * Geometry - * ------------------------------------------------------------------------- */ - -/** A rectangle in cells. Mirrors the `nTileXPos`/`nTileYPos`/`nTileWidth`/ - * `nTileHeight` view of `D2DrlgRoomStrc`, which D2MOO also reads as - * `D2DrlgCoordStrc` through a union. */ -interface Rect { - x: number - y: number - width: number - height: number -} - -/** - * Signed gap between two cell rectangles, per axis. - * - * `DRLG_ComputeManhattanDistance` verbatim, including its one-axis-at-a-time - * formulation: a negative value means the rectangles overlap on that axis, and - * two rooms sharing a face give one gap of 0 and one of `-height`, which is the - * signature every caller tests for. - * - * @param a - first rectangle. - * @param b - second rectangle. - * @returns the per-axis gaps. - */ -function manhattan(a: Rect, b: Rect): { dx: number; dy: number } { - const dx = a.x >= b.x ? a.x - b.width - b.x : b.x - a.width - a.x - const dy = a.y >= b.y ? a.y - b.height - b.y : b.y - a.height - a.y - return { dx, dy } -} - -/** - * Whether two rectangles are clear of each other by at least `margin` cells. - * - * `DRLG_GetRectanglesManhattanDistanceAndCheckNotOverlapping`: true when either - * axis is separated by `margin` or more. With `margin = 0` a pair of face-to-face - * rooms has `dx = 0` and therefore *passes*, so sharing a face counts as "not - * overlapping" — which is what lets rooms tile a grid. - * - * @param a - first rectangle. - * @param b - second rectangle. - * @param margin - required separation on at least one axis. - * @returns true when they are clear. - */ -function notOverlapping(a: Rect, b: Rect, margin: number): boolean { - const { dx, dy } = manhattan(a, b) - return dx >= margin || dy >= margin -} - -/** - * The quarter direction from `from` to `to`, or `-1` when they are not - * face-to-face neighbours. - * - * `DRLG_GetDirectionFromCoordinates`. Only the four quarters are produced; the - * diagonals exist solely as placement offsets in `FillBlankMazeSpaces`. - * - * @param from - the origin rectangle. - * @param to - the candidate neighbour. - * @returns a direction index 0..3, or -1. - */ -function directionFrom(from: Rect, to: Rect): number { - if (from.x <= to.x) { - if (to.x === from.x + from.width) return DIRECTION_EAST - } else if (from.x === to.x + to.width) { - return DIRECTION_WEST - } - if (from.y <= to.y) { - if (to.y === from.y + from.height) return DIRECTION_SOUTH - } else if (from.y === to.y + to.height) { - return DIRECTION_NORTH - } - return -1 -} - -/* ------------------------------------------------------------------------- * - * Rooms - * ------------------------------------------------------------------------- */ - -/** One maze section. */ -export interface Room extends Rect { - /** Doorways to neighbouring rooms, in insertion order. */ - readonly orths: { room: Room; direction: number }[] - /** Creation index, so diagnostics can name a room stably. */ - readonly id: number - /** The chosen piece; `null` until a preset pass runs. */ - piece: MazePiece | null - /** Which variant of `piece` to stamp. */ - variant: number - /** - * `DRLGPRESETROOMFLAG_HAS_MAP_DS1`. - * - * Set by the passes that call `DRLGMAZE_SetPickedFileAndPresetId` with - * `bResetFlag = FALSE`; cleared by every `DRLGMAZE_PickRoomPreset` call, which - * passes 1. A set flag means "this room's artwork is final", so the merge pass - * skips it and no later preset pass may overwrite it. - */ - hasDs1: boolean - /** Per-room random stream, seeded from the level's (`DRLGROOM_AllocRoomEx`). */ - readonly rng: Rng -} - -/** The level under construction. */ -export interface MazeLevel { - /** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */ - readonly rooms: Room[] - /** The first room created; D2MOO keeps this pointer across prepends. */ - readonly first: Room - readonly width: number - readonly height: number - readonly rng: Rng - readonly mergePerMille: number - /** Next room id. */ - nextId: number - /** Doorways added by the merge pass, for reporting. */ - merges: number - readonly maxCellsX?: number | undefined - readonly maxCellsY?: number | undefined - readonly stats?: MazeStats | undefined -} - -/** - * Report collector shared by the passes. - * - * Dense on purpose: the generator's job is to be inspectable, and the interesting - * facts are which piece answered which side pattern and where the exact pattern - * was missing. - */ -export interface MazeStats { - readonly patterns: Record> - readonly fallbacks: { room: number; requested: string; used: string }[] - readonly specialsApplied: { room: number; kind: string; sides: string }[] - readonly specialPassesApplied: string[] - readonly unresolvedRoles: string[] - readonly notes: string[] - ringRooms: number - placementAttempts: number - boundsRejected?: number - /** Stair rooms, with the coordinates only `stamp` can work out. */ - warps: MazeWarp[] - /** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */ - waypoints: MazeWaypoint[] -} - -/** - * A staircase, as reported in `stats.warps`. - * - * `specialsApplied` already records which room got a stair piece, but a room id - * is not a place: rooms are positioned in an unbounded coordinate space that - * `stamp` shifts by `minX`/`minY` to produce the final map. The coordinates - * below are in that final map's cells, so they can be baked straight into the - * pack. - */ -export interface MazeWarp { - /** The room the piece went into, matching `specialsApplied`. */ - readonly room: number - /** - * Which way it goes: - * - `up` — `Prev`, back towards the level above. - * - `down` — `Next` or `Down`, deeper in. `Down` is the Act 1 cave descent, - * which uses a different piece but means the same thing here. - */ - readonly direction: 'up' | 'down' - /** The piece kind it came from, for tracing back to the artwork. */ - readonly kind: 'prev' | 'next' | 'down' - /** The room's top-left corner in the final map, in cells. */ - readonly x: number - readonly y: number - /** The room's extent, in cells. */ - readonly width: number - readonly height: number - /** The room's centre, where the player lands or the warp is clicked. */ - readonly centreX: number - readonly centreY: number -} - -/** - * Create a room with no position or piece. - * - * @param level - the level. - * @returns the room. - */ -function makeRoom(level: MazeLevel): Room { - const room: Room = { - x: 0, y: 0, width: level.width, height: level.height, - orths: [], id: level.nextId, piece: null, variant: 0, hasDs1: false, - rng: new Rng(level.rng.int(0, 0x7fffffff)), - } - level.nextId += 1 - return room -} - -/** - * Add a doorway between two rooms. - * - * `DRLGROOM_AllocDrlgOrthsForRooms`: idempotent per room pair, and the reciprocal - * orth points the opposite way, `(direction - 2) & 3`. - * - * @param a - first room. - * @param b - second room. - * @param direction - direction from `a` to `b`. - */ -function linkRooms(a: Room, b: Room, direction: number): void { - if (!a.orths.some(orth => orth.room === b)) a.orths.push({ room: b, direction }) - const opposite = (direction - 2) & 3 - if (!b.orths.some(orth => orth.room === a)) b.orths.push({ room: a, direction: opposite }) -} - -/** - * The 4-bit side mask a room's doorways produce. - * - * `DRLGMAZE_PickRoomPreset` ORs {@link DIRECTION_SIDE_BIT} for every doorway. A - * room with no doorways gets 0, which in D2MOO selects the level type's base row - * and here selects the `entrance` piece. - * - * @param room - the room. - * @returns the mask, 0..15. - */ -export function roomSideMask(room: Room): number { - let mask = 0 - for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]! - return mask -} - -/** - * Whether placing `room` keeps the overall maze within `maxCellsX` and `maxCellsY`. - * - * In Diablo II, maze room growth is constrained by the level's global SizeX and SizeY limits - * from `Levels.txt`. If growing a candidate room would expand the bounding box of all placed - * rooms beyond the allowed dimensions, the placement attempt is rejected, forcing the maze - * to grow in other available directions and cluster compactly. - * - * @param level - the level. - * @param room - the candidate room with proposed coordinates and dimensions. - * @returns true if within bounds or bounds are unconstrained. - */ -function checkRoomWithinBounds(level: MazeLevel, room: Rect): boolean { - if (level.maxCellsX === undefined && level.maxCellsY === undefined) return true - if (level.rooms.length === 0) return true - - if (level.maxCellsX !== undefined && level.maxCellsX > 0) { - let minX = room.x - let maxX = room.x + room.width - for (const other of level.rooms) { - if (other.x < minX) minX = other.x - if (other.x + other.width > maxX) maxX = other.x + other.width - } - if (maxX - minX > level.maxCellsX) { - if (level.stats) { - level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1 - } - return false - } - } - - if (level.maxCellsY !== undefined && level.maxCellsY > 0) { - let minY = room.y - let maxY = room.y + room.height - for (const other of level.rooms) { - if (other.y < minY) minY = other.y - if (other.y + other.height > maxY) maxY = other.y + other.height - } - if (maxY - minY > level.maxCellsY) { - if (level.stats) { - level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1 - } - return false - } - } - - return true -} - -/** - * Whether a room overlaps anything other than itself and one ignored room, and fits within level bounds. - * - * `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours - * are fine and only genuine overlap fails. - * - * @param level - the level. - * @param room - the candidate. - * @param ignored - a room to skip, usually its parent. - * @returns true when the room is clear and within global level bounds. - */ -function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean { - if (!checkRoomWithinBounds(level, room)) return false - for (const other of level.rooms) { - if (other === room || other === ignored) continue - if (!notOverlapping(room, other, 0)) return false - } - return true -} - -/** - * Position `room` against `anchor` in `direction` and validate the result. - * - * `DRLGMAZE_LinkMazeRooms`: the coordinate assignment happens first, so a failed - * link leaves the caller to discard the room. - * - * @param level - the level. - * @param room - the room to move. - * @param anchor - the room to sit against. - * @param direction - which side of the anchor to occupy. - * @returns true when the position is legal. - */ -function linkMazeRooms(level: MazeLevel, room: Room, anchor: Room, direction: number): boolean { - const { dx, dy } = step(direction, anchor.width, anchor.height) - room.x = anchor.x + dx - room.y = anchor.y + dy - for (const orth of anchor.orths) { - // Anything already touching the anchor must not be run into. - if (!notOverlapping(room, orth.room, 0)) return false - } - return checkRoomNotOverlapping(level, room, anchor) -} - -/** - * The earliest room that still needs a piece, optionally of a given shape. - * - * @param level - the level, newest-first. - * @param mask - required side mask, or `null` for any. - * @returns the room, or null. - */ -function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null { - for (const room of level.rooms) { - if (room.hasDs1) continue - if (mask === null || roomSideMask(room) === mask) return room - } - return null -} - -/** - * Grow one room off `parent`, or return null when the spot is taken. - * - * `DRLGMAZE_AddAdjacentMazeRoom`, with its inline copy of the merge loop factored - * into {@link mergeMazeRooms}. The fold is safe because in D2MOO the new room has - * not been added to the roster when either spelling runs, so both iterate exactly - * the same candidates in the same order; the only difference is that - * `DRLGMAZE_BuildBasicMaze` calls `MergeMazeRooms` unconditionally while - * `AddAdjacentMazeRoom` guards it with `bMergeRooms`, and every caller that - * passes `FALSE` has already established that there is nothing to merge. - * - * @param level - the level. - * @param parent - the room to grow from. - * @param direction - which side to grow towards, 0..7. - * @param merge - whether the accidental-adjacency join pass runs. - * @param repickParent - whether to re-derive the parent's piece afterwards. - * `DRLGMAZE_FillBlankMazeSpaces` deliberately does not. - * @returns the new room, or null. - */ -export function addAdjacentMazeRoom( - level: MazeLevel, - parent: Room, - direction: number, - merge: boolean, - repickParent: boolean, - index: PieceIndex, - stats: MazeStats, -): Room | null { - const room = makeRoom(level) - const { dx, dy } = step(direction, parent.width, parent.height) - room.x = parent.x + dx - room.y = parent.y + dy - for (const orth of parent.orths) { - if (!notOverlapping(room, orth.room, 0)) return null - } - if (!checkRoomNotOverlapping(level, room, parent)) return null - - linkRooms(parent, room, direction) - if (merge) mergeMazeRooms(level, room, index, stats) - level.rooms.unshift(room) - if (repickParent) pickRoomPreset(parent, index, stats) - pickRoomPreset(room, index, stats) - return room -} - -/** - * Join a newly placed room to every already-placed room it happens to touch. - * - * **This is what `LvlMaze.Merge` does.** `DRLGMAZE_MergeMazeRooms` (and the - * inline copy inside `DRLGMAZE_AddAdjacentMazeRoom`) walks the whole roster for - * rooms no more than one cell away where `dx !== dy` — which, given that overlap - * has already been ruled out, can only mean a face-to-face neighbour — and, when - * the two are not already connected, rolls - * `SEED_RollRandomNumber(&i->pSeed) % 1000 < dwMerge`, on the *candidate's* seed - * rather than the pivot's. A success adds a doorway and re-picks that room's - * piece, because its side pattern just changed. - * - * The consequence is why `Merge` matters: the growth loop attaches a room on one - * side only, but the section grid is coarse enough that a new room frequently - * lands flush against an unrelated older room. With `Merge = 0` those contacts - * stay walls and the level is a bare tree; with `Merge = 1000` every one of them - * becomes a door and the level acquires loops. - * - * @param level - the level. - * @param pivot - the room just placed. - * @param index - the piece index, for re-picking. - * @param stats - report collector. - */ -function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats: MazeStats): void { - if (pivot.hasDs1) return - for (const other of level.rooms) { - if (other === pivot || other.hasDs1) continue - const { dx, dy } = manhattan(pivot, other) - // `!(dx >= 1 || dy >= 1)`, then `dx !== dy`. - if (dx >= 1 || dy >= 1) continue - if (dx === dy) continue - if (pivot.orths.some(orth => orth.room === other)) continue - if (other.rng.int(0, 999) >= level.mergePerMille) continue - const direction = directionFrom(other, pivot) - if (direction === -1) continue - linkRooms(other, pivot, direction) - level.merges += 1 - pickRoomPreset(other, index, stats) - } -} - -/* ------------------------------------------------------------------------- * - * Piece lookup - * ------------------------------------------------------------------------- */ - -/** Indexed pieces, so a side mask is one map lookup. */ -export interface PieceIndex { - /** - * The piece for a room's doorway mask. - * - * @param mask - the room's doorway mask. - * @param stats - report collector for fallbacks. - * @param roomId - the room being served, for the report. - * @returns the piece to stamp. - */ - choose(mask: number, stats: MazeStats, roomId: number): MazePiece -} - -/** - * Count set bits. - * - * @param value - the value. - * @returns the population count. - */ -function popcount(value: number): number { - let count = 0 - let rest = value - while (rest !== 0) { count += rest & 1; rest >>>= 1 } - return count -} - -/** - * Index a level type's pieces by kind and side mask. - * - * `DRLGMAZE_PickRoomPreset` resolves a mask by reading the level type's - * `LvlPrest` row at `base + mask`, where `base` is a hardcoded per-type preset - * id. This port has no preset ids, so it resolves by *kind and mask* instead — - * the same thing expressed in the data the caller supplies. - * - * A level type may genuinely lack a piece for a side pattern (Act 1's Catacombs - * ships no four-sided room). D2MOO would read whatever row happens to sit at - * `base + mask` — a neighbouring level type's artwork — and produce a map whose - * doors do not line up. Here the closest piece is substituted and the mismatch is - * recorded in `stats.fallbacks`, because a map that cannot be walked is worse for - * this project than a map with one wrong room. This is a deliberate divergence. - * - * @param pieces - the level type's pieces. - * @returns the index. - */ -function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex { - const rooms = new Map() - let entrance: MazePiece | undefined - for (const piece of pieces) { - if (piece.kind === 'entrance' && entrance === undefined) entrance = piece - if (piece.kind !== 'room') continue - const mask = sideMaskFromToken(piece.sides) - if (!rooms.has(mask)) rooms.set(mask, piece) - } - for (const piece of pieces) { - if (piece.kind !== 'prev') continue - const mask = sideMaskFromToken(piece.sides) - if (mask !== 0 && !rooms.has(mask)) rooms.set(mask, piece) - } - const entrancePiece = entrance - - /** - * Pick the closest available room piece to a mask. - * - * @param mask - the wanted mask. - * @returns the piece and whether it is exact. - */ - const closest = (mask: number): { piece: MazePiece; exact: boolean } | null => { - const exact = rooms.get(mask) - if (exact !== undefined) return { piece: exact, exact: true } - let best: MazePiece | null = null - let bestCost = Number.POSITIVE_INFINITY - for (const [candidateMask, piece] of rooms) { - // A missing doorway seals the room off completely; an extra one merely - // adds a dead end. Weight the two accordingly. - const cost = popcount(mask & ~candidateMask) * 100 + popcount(candidateMask & ~mask) - if (cost < bestCost) { bestCost = cost; best = piece } - } - return best === null ? null : { piece: best, exact: false } - } - - return { - choose: (mask: number, stats: MazeStats, roomId: number): MazePiece => { - const result = closest(mask) - if (result === null) { - if (entrancePiece !== undefined) return entrancePiece - const any = pieces[0] - if (any === undefined) throw new Error('maze piece list is empty') - return any - } - if (!result.exact) { - stats.fallbacks.push({ - room: roomId, - requested: sideTokenFromMask(mask), - used: `${result.piece.name} (${sideTokenFromMask(sideMaskFromToken(result.piece.sides)) || 'no side'})`, - }) - } - return result.piece - }, - } -} - -/** - * Look up a piece by kind and side token. - * - * @param pieces - the level's pieces. - * @param kind - the kind wanted. - * @param sides - the side token; `''` accepts any piece of that kind. - * @returns the piece, or undefined. - */ -export function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined { - return pieces.find(piece => piece.kind === kind - && (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides))) -} - -/** - * Give a room the piece its doorways call for. - * - * `DRLGMAZE_PickRoomPreset`. Rooms whose artwork is already final (`hasDs1`) are - * left alone, mirroring the passes that call it with `bResetFlag = FALSE`; every - * other call clears the flag, which is why the flag cannot be used as a "was - * visited" marker. - * - * @param room - the room to give a piece. - * @param index - the piece lookup. - * @param stats - the report collector. - */ -function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void { - if (room.hasDs1) return - const mask = roomSideMask(room) - const chosen = index.choose(mask, stats, room.id) - room.piece = chosen - room.variant = 0 - room.hasDs1 = false - const token = sideTokenFromMask(mask) === '' ? 'no side' : sideTokenFromMask(mask) - stats.patterns[chosen.kind] ??= {} - const bucket = stats.patterns[chosen.kind]! - bucket[token] = (bucket[token] ?? 0) + 1 -} - -/** - * Record that a special piece was placed. - * - * @param room - the room it went into. - * @param piece - the piece. - * @param token - the side token it answers. - * @param stats - report collector. - */ -export function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void { - stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token }) - stats.patterns[piece.kind] ??= {} - stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1 -} - -/* ------------------------------------------------------------------------- * - * Layout passes - * ------------------------------------------------------------------------- */ - -/** - * Pre-place a closed ring of `4n - 4` rooms. - * - * `DRLGMAZE_InitBasicMazeLayout`. Four legs walk north, west, south and east, and - * the last room is then linked back to the first — so `n = 2` gives a 2×2 square - * and `n = 5` (Act 3's Sewer 1) a 4×4 ring of 16 rooms. The east leg runs one - * iteration short because the first room already occupies that corner. Each leg - * calls `DRLGMAZE_MergeMazeRooms` just like the growth loop does, so the - * accidental contacts a ring makes are subject to `Merge` too. - * - * @param level - the level. - * @param roomsPerDirection - `n`; `4n - 4` rooms are created. - * @param index - the piece index. - * @param stats - report collector. - */ -function ringLayout(level: MazeLevel, roomsPerDirection: number, index: PieceIndex, stats: MazeStats): void { - const legs: readonly number[] = [DIRECTION_NORTH, DIRECTION_WEST, DIRECTION_SOUTH, DIRECTION_EAST] - let cursor = level.first - for (const direction of legs) { - const count = direction === DIRECTION_EAST ? roomsPerDirection - 2 : roomsPerDirection - 1 - for (let remaining = count; remaining > 0; remaining -= 1) { - const room = addAdjacentMazeRoom(level, cursor, direction, true, true, index, stats) - if (room !== null) stats.ringRooms += 1 - cursor = room ?? cursor - } - } - linkRooms(cursor, level.first, DIRECTION_EAST) - pickRoomPreset(cursor, index, stats) - pickRoomPreset(level.first, index, stats) -} - -/** - * Grow the level to its target room count. - * - * The shared body of `DRLGMAZE_BuildBasicMaze` and of the - * `while (pLevel->nRooms < nRooms)` loop `DRLGMAZE_GenerateLevel` inlines for the - * Act 1 dungeons: pick a room from the roster at a uniformly random index (the - * roster is newest-first, so recent rooms are favoured exactly as in the game), - * roll a direction from *that room's* seed, and try to attach a neighbour. - * Failure is routine — the target square may already be occupied — so the loop - * simply tries again. That also means it can only be bounded by a retry cap - * rather than by construction, and D2MOO would spin forever where this throws. - * - * @param level - the level. - * @param target - rooms wanted. - * @param index - the piece index. - * @param stats - report collector. - * @returns the number of attempts consumed. - */ -function growMaze(level: MazeLevel, target: number, index: PieceIndex, stats: MazeStats): number { - const cap = target * 400 + 20_000 - let attempts = 0 - while (level.rooms.length < target) { - attempts += 1 - if (attempts > cap) { - throw new Error(`layout stalled at ${String(level.rooms.length)}/${String(target)} rooms after ${String(attempts)} attempts`) - } - const room = level.rooms[level.rng.int(0, level.rooms.length - 1)]! - const direction = room.rng.int(0, 3) - if (room.hasDs1) continue - addAdjacentMazeRoom(level, room, direction, true, true, index, stats) - } - return attempts -} - -/** - * The opening moves Act 1's Catacombs get before growth. - * - * `DRLGMAZE_GenerateLevel`'s `LVLTYPE_ACT1_CATACOMBS` arm. Level 1 opens in all - * four directions and pins the entrance to the `NSEW` staircase piece; the rest - * pick an axis at random and open both ways, pinning `EW` or `NS`. These are the - * only places the entrance room is finalised with `bResetFlag = FALSE`, i.e. the - * only rooms `DRLGMAZE_MergeMazeRooms` will later refuse to touch. - * - * @param level - the level. - * @param horizontal - whether the non-first levels open west/east rather than - * north/south. - * @param pieces - the level's pieces. - * @param index - the piece index. - * @param stats - report collector. - */ -function catacombsOpening(level: MazeLevel, horizontal: boolean, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void { - const directions = horizontal ? [DIRECTION_WEST, DIRECTION_EAST] : [DIRECTION_NORTH, DIRECTION_SOUTH] - for (const direction of directions) addAdjacentMazeRoom(level, level.first, direction, true, true, index, stats) - const token = horizontal ? 'EW' : 'NS' - const piece = findSpecial(pieces, 'prev', token) - if (piece === undefined) { - stats.unresolvedRoles.push(`Act 1 - Catacombs Prev ${token}`) - return - } - level.first.piece = piece - level.first.hasDs1 = true - noteSpecial(level.first, piece, token, stats) -} - -/** - * The three-room prologue Act 2's tombs and Act 5's Baal levels share. - * - * `DRLGMAZE_PlaceAct2TombPrev_Act5BaalPrev`: three rooms are chained off the - * entrance, one per direction starting from a random one, each merging as it - * lands; the entrance then takes the `Prev` piece whose three free sides face the - * rooms that actually got built. That piece is chosen with the direction counter - * *after* the loop, which is why it is the successor of the last direction tried - * rather than the last direction used. - * - * @param level - the level. - * @param kind - which act's `Prev` set to use. - * @param pieces - the level's pieces. - * @param index - the piece index. - * @param stats - report collector. - */ -function prevChain(level: MazeLevel, kind: 'tomb' | 'baal', pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void { - let direction = level.rng.int(0, 3) - for (let attempt = 0; attempt < 3; attempt += 1) { - const room = makeRoom(level) - if (linkMazeRooms(level, room, level.first, direction)) { - linkRooms(level.first, room, direction) - mergeMazeRooms(level, room, index, stats) - level.rooms.unshift(room) - pickRoomPreset(level.first, index, stats) - pickRoomPreset(room, index, stats) - } - direction = (direction + 1) % 4 - } - const tokens = ['NSE', 'SEW', 'NSW', 'NEW'] - const token = tokens[direction]! - const piece = findSpecial(pieces, 'prev', token) - if (piece === undefined) { - stats.unresolvedRoles.push(`${kind === 'tomb' ? 'Act 2 - Tomb' : 'Act 5 - Baal'} Prev ${token}`) - return - } - level.first.piece = piece - level.first.hasDs1 = true - noteSpecial(level.first, piece, token, stats) -} - -/** - * `DRLGMAZE_PlaceArcaneSanctuary`: four 15-room spiral branches. - * - * Each branch starts at the level's first room and walks the pattern D2MOO - * documents in a comment, encoded in - * `DRLGMAZE_ArcaneSanctuaryDirectionFromRoomIdx`: one room forward, then a side - * step, then a long run, then a side step back. Rooms 8 and 12 are placed but - * deliberately *not* recorded and do not advance the parent, which D2MOO flags - * with "Is this a mistake?" and then reproduces anyway; so does this port, since - * the object is fidelity to the shipped maze rather than to the diagram. - * - * The four branches then take four different variants of the same pieces, - * `(nRand + branch) % 4`, which is what gives the Sanctuary its noticeably varied - * arms; the entry room takes variant 4. - * - * @param level - the level. - * @param pieces - the level's pieces. - * @param index - the piece index. - * @param stats - report collector. - */ -function arcaneSanctuary(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void { - const rand = level.rng.int(0, 3) - const branches: Room[] = [] - for (let branch = 0; branch < 4; branch += 1) { - let parent = level.first - for (let slot = 0; slot < 15; slot += 1) { - let direction = branch - if (slot === 2 || slot === 12) direction = branch + 3 - else if (slot === 7 || slot === 9) direction = branch + 1 - else if (slot === 10 || slot === 11 || slot === 13 || slot === 14) direction = branch + 2 - direction %= 4 - const room = addAdjacentMazeRoom(level, parent, direction, true, true, index, stats) - if (room === null) continue - if (slot !== 8 && slot !== 12) { - branches.push(room) - parent = room - } - } - } - branches.forEach((room, position) => { - room.variant = (rand + Math.floor(position / 15)) % 4 - }) - level.first.variant = 4 - void pieces -} - -/** - * `DRLGMAZE_PlaceAct5LavaPresets`: the Infernal Pit's two fixed pieces. - * - * The level is not a maze at all. It is an entrance plus exactly two lava pieces, - * chosen as a pair by a single random draw, after which every still-empty - * adjacent square is filled with the shared `Act 4 - Lava X` filler — see - * {@link fillBlankSpaces}. - * - * @param level - the level. - * @param pieces - the level's pieces. - * @param index - the piece index. - * @param stats - report collector. - */ -function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void { - // `dword_6FDCE850`, transcribed as (piece name, direction, variant). - const table: readonly (readonly [string, number, number])[] = [ - ['Act 5 - Lava S', DIRECTION_SOUTH, 0], - ['Act 5 - Lava N', DIRECTION_NORTH, 1], - ['Act 5 - Lava S', DIRECTION_SOUTH, 1], - ['Act 5 - Lava N', DIRECTION_NORTH, 0], - ['Act 5 - Lava E', DIRECTION_EAST, 1], - ['Act 5 - Lava W', DIRECTION_WEST, 0], - ['Act 5 - Lava E', DIRECTION_EAST, 0], - ['Act 5 - Lava W', DIRECTION_WEST, 1], - ] - const set = 2 * level.first.rng.int(0, 3) - for (const [name, direction, variant] of [table[set]!, table[set + 1]!]) { - const room = makeRoom(level) - 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 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, opposite) - level.rooms.unshift(room) - pickRoomPreset(level.first, index, stats) - const piece = pieces.find(candidate => candidate.name === name) - if (piece === undefined) { - stats.unresolvedRoles.push(name) - pickRoomPreset(room, index, stats) - continue - } - room.piece = piece - room.hasDs1 = true - noteSpecial(room, piece, 'no side', stats) - } - fillBlankSpaces(level, pieces, index, stats) -} - -/** - * `DRLGMAZE_FillBlankMazeSpaces`: wall a fixed-tile level in with filler rooms. - * - * The roster is snapshotted first, then every room in it tries all eight compass - * directions in order and keeps whatever fits. The filler never takes a role, so - * this only ever adds area. - * - * D2MOO does not re-derive the parent's piece here, which would leave a wall - * where the filler attaches and make the filler unreachable; this port does - * re-derive it, because an unreachable room is a defect rather than a detail. - * - * @param level - the level. - * @param pieces - the level's pieces. - * @param index - the piece index. - * @param stats - report collector. - */ -export function fillBlankSpaces( - level: MazeLevel, - pieces: readonly MazePiece[], - index: PieceIndex, - stats: MazeStats, - ignoreRoom?: Room | null, -): void { - const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X') - if (filler === undefined) { - stats.unresolvedRoles.push('Act 4 - Lava X') - return - } - for (const room of [...level.rooms]) { - if (ignoreRoom !== null && ignoreRoom !== undefined && room === ignoreRoom) continue - for (let direction = 0; direction < 8; direction += 1) { - const created = addAdjacentMazeRoom(level, room, direction, false, true, index, stats) - if (created === null) continue - created.piece = filler - created.hasDs1 = true - noteSpecial(created, filler, 'filler', stats) - } - } -} - -/** - * `DRLGMAZE_RollAct_1_2_3_BasicPresets`: the `Theme` decoration pass. - * - * A shuffled list of the fifteen side patterns is walked cyclically and the first - * plain room matching each pattern is converted to that pattern's `Theme` - * variant, until `max(rooms / 5 + 1, 2)` rooms have been converted or the attempt - * budget of `2 × rooms` is spent. D2MOO shuffles the offset list with fifteen - * Fisher-Yates swaps drawn from the level seed, which is reproduced here. - * - * A level type with no `Theme` pieces for a pattern is skipped rather than given - * a neighbouring type's artwork, and the shortfall is reported. - * - * @param level - the level. - * @param pieces - the level's pieces. - * @param stats - report collector. - */ -function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeStats): void { - const offsets = Array.from({ length: 15 }, (_, index) => index) - let cursor = level.rng.int(0, 14) - for (let swap = 0; swap < 15; swap += 1) { - const a = level.rng.int(0, 14) - const b = level.rng.int(0, 14) - const temporary = offsets[a]! - offsets[a] = offsets[b]! - offsets[b] = temporary - } - let wanted = Math.max(Math.floor(level.rooms.length / 5) + 1, 2) - let budget = 2 * level.rooms.length - while (wanted > 0 && budget > 0) { - budget -= 1 - const mask = offsets[cursor]! + 1 - cursor = (cursor + 1) % 15 - const token = sideTokenFromMask(mask) - const room = findUnfinishedRoom(level, mask) - if (room === null) continue - const themed = findSpecial(pieces, 'theme', token) - if (themed === undefined) { - // D2MOO would write a neighbouring level type's row; here the room keeps - // its plain piece and the gap is reported. - if (!stats.unresolvedRoles.includes(`Theme ${token}`)) stats.unresolvedRoles.push(`Theme ${token}`) - continue - } - room.piece = themed - room.variant = 0 - room.hasDs1 = true - noteSpecial(room, themed, token, stats) - wanted -= 1 - } -} - -/** - * Compute BFS topological distance of every room from the level's entrance room - * (the room holding a 'prev' or 'entrance' piece, or `level.first`). - */ -export function bfsDistances(level: MazeLevel): Map { - const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first - const dist = new Map([[start, 0]]) - const queue: Room[] = [start] - while (queue.length > 0) { - const current = queue.shift()! - const d = dist.get(current)! - for (const orth of current.orths) { - if (!dist.has(orth.room)) { - dist.set(orth.room, d + 1) - queue.push(orth.room) - } - } - } - return dist -} - -/** - * Place a special room group following standard D2 scanning rules: - * scans room candidates against valid wall connections, LvlPrest file definitions, - * and room flags, rather than imposing an artificial global maximum BFS distance. - * - * @param level - the level. - * @param pieces - the level's pieces. - * @param group - which special group to place. - * @param startCursor - initial table offset. - * @param index - the piece index. - * @param stats - report collector. - */ -function placeSpecialGroup( - level: MazeLevel, - pieces: readonly MazePiece[], - group: SpecialGroup, - startCursor: number, - index: PieceIndex, - stats: MazeStats, -): void { - // Phase 1: Try to replace an existing unfinished room in place, scanning - // room candidates against valid wall connections, LvlPrest definitions, - // and room flags in standard D2 roster order. - for (let offset = 0; offset < group.entries.length; offset += 1) { - const entry = group.entries[(startCursor + offset) % group.entries.length]! - const wanted = sideMaskFromToken(entry.plainSides) - const token = sideTokenFromMask(wanted) - const special = findSpecial(pieces, group.kind, entry.sides) - if (special === undefined) continue - - const chosen = level.rooms.find(r => { - if (roomSideMask(r) !== wanted) return false - if (!r.hasDs1) return true - if (group.inPlaceOnly === true && (r.piece === special || r.piece?.kind === 'theme')) return true - return false - }) - - if (chosen !== undefined) { - chosen.piece = special - chosen.variant = 0 - chosen.hasDs1 = true - noteSpecial(chosen, special, token, stats) - return - } - } - - if (group.inPlaceOnly === true) { - const firstEntry = group.entries[startCursor]! - stats.notes.push(`no ${firstEntry.plainSides} room left for a ${group.kind} ${firstEntry.sides} replacement`) - return - } - - // Phase 2: Grow a new dead-end room off an available parent room in D2 - // roster order (plain rooms without DS1 scanned first, followed by theme rooms). - const parentCandidates = [ - ...level.rooms.filter(r => !r.hasDs1), - ...level.rooms.filter(r => r.hasDs1 && r.piece?.kind === 'theme'), - ] - - for (const parent of parentCandidates) { - for (let offset = 0; offset < group.entries.length; offset += 1) { - const entry = group.entries[(startCursor + offset) % group.entries.length]! - const wanted = sideMaskFromToken(entry.plainSides) - const token = sideTokenFromMask(wanted) - const special = findSpecial(pieces, group.kind, entry.sides) - if (special === undefined) continue - - const wasHasDs1 = parent.hasDs1 - parent.hasDs1 = false - const created = addAdjacentMazeRoom(level, parent, entry.direction, false, true, index, stats) - if (created !== null) { - created.piece = special - created.variant = 0 - created.hasDs1 = true - noteSpecial(created, special, token, stats) - return - } - parent.hasDs1 = wasHasDs1 - } - } - - const fallbackEntry = group.entries[startCursor]! - stats.notes.push(`could not place ${group.kind} ${fallbackEntry.sides}: no room could host it`) -} - -/* ------------------------------------------------------------------------- * - * Stamping - * ------------------------------------------------------------------------- */ - -/** A blank wall cell, matching the decoder's placeholder. */ -function emptyWall(): Ds1Wall { - return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -/** A blank floor/shadow cell. */ -function emptyFloor(): Ds1Floor { - return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -/* ------------------------------------------------------------------------- * - * Edge suppression and doorway thresholds (1.13c `DrlgRoomTile.cpp`) - * ------------------------------------------------------------------------- */ - -/** `TILETYPE_WALL_LEFT_DOOR` — a wall cell that a door unit occupies. */ -const TILETYPE_WALL_LEFT_DOOR = 8 -/** `TILETYPE_WALL_RIGHT_DOOR` — the mirrored door wall. */ -const TILETYPE_WALL_RIGHT_DOOR = 9 -/** `TILETYPE_WALL_LEFT_EXIT` — a warp marker, not artwork. */ -const TILETYPE_WALL_LEFT_EXIT = 10 -/** `TILETYPE_WALL_RIGHT_EXIT` — the mirrored warp marker. */ -const TILETYPE_WALL_RIGHT_EXIT = 11 - -/** - * Highest `style` an exit tile may carry before 1.13c ignores it outright. - * - * `DRLGROOMTILE_LoadInitRoomTiles` opens with - * `if ((nTileType == TILETYPE_WALL_LEFT_EXIT || nTileType == TILETYPE_WALL_RIGHT_EXIT) - * && nTileStyle >= 8) continue;` — the style is the `Levels.txt` `Warp0..7` slot, - * so anything at 8 or above is not a warp and the cell is dropped. - */ -const MAX_EXIT_WARP_SLOT = 7 - -/** - * The 2×2 offsets `DRLGROOMTILE_LoadFloorWarpTiles` writes its threshold under. - * - * Verbatim `gWarpTileOffsets_6FDD1320`; each is applied to `(nX - 1, nY - 1)`, so - * the patch covers the doorway cell and the three cells up-left of it. - */ -const WARP_TILE_OFFSETS: readonly (readonly [number, number])[] = [ - [0, 0], [1, 0], [0, 1], [1, 1], -] - -/** - * Decide whether a wall cell survives 1.13c's load pass. - * - * Three rules from `DRLGROOMTILE_LoadInitRoomTiles`, in the order it applies - * them: - * - * 1. An exit tile whose `style` is above {@link MAX_EXIT_WARP_SLOT} is skipped - * before anything else looks at it (line 466). - * 2. A *hidden* door tile spawns a door unit and `continue`s, so its wall - * artwork is never emitted (line 485). - * 3. A *hidden* exit tile registers a warp and `continue`s likewise (line 500). - * - * Everything else is ordinary artwork and is kept. - * - * @param wall - the wall cell as the piece authored it. - * @returns whether the wall should be drawn. - */ -function wallSurvivesLoad(wall: Ds1Wall): boolean { - const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT - if (isExit && wall.style > MAX_EXIT_WARP_SLOT) return false - if (!wall.hidden) return true - const isDoor = wall.type === TILETYPE_WALL_LEFT_DOOR || wall.type === TILETYPE_WALL_RIGHT_DOOR - return !(isDoor || isExit) -} - -/** - * Whether a dropped wall is the kind that gets a threshold patch. - * - * 1.13c calls `DRLGROOMTILE_LoadFloorWarpTiles` from exactly one place - * (`DrlgRoomTile.cpp:505`): a *hidden exit* tile whose style is a real - * `Warp0..7` slot. A hidden *door* takes the branch above it and only spawns a - * door unit, and an out-of-range exit is skipped before either runs — neither - * lays down floor, so neither may widen the patch here. - * - * @param wall - the wall that `wallSurvivesLoad` rejected. - * @returns whether its cell needs the 2×2 floor patch. - */ -function needsThresholdBackfill(wall: Ds1Wall): boolean { - const isExit = wall.type === TILETYPE_WALL_LEFT_EXIT || wall.type === TILETYPE_WALL_RIGHT_EXIT - return isExit && wall.hidden && wall.style <= MAX_EXIT_WARP_SLOT -} - -/** - * Whether a floor cell actually draws something. - * - * A slot left at style 0 / sequence 0 is the decoder's placeholder, which is the - * hole the threshold backfill exists to cover. - * - * @param floor - the floor cell. - * @returns whether it references a tile. - */ -function floorIsBlank(floor: Ds1Floor | undefined): boolean { - return floor === undefined || (floor.style === 0 && floor.sequence === 0 && floor.prop1 === 0) -} - -/** - * Backfill the threshold floor under a doorway, the way 1.13c does. - * - * `DRLGROOMTILE_LoadFloorWarpTiles` reserves six floor slots per hidden exit - * (`DRLGROOMTILE_CountWallWarpTiles` does `pTiles.nFloors += 6`) and fills a 2×2 - * block anchored one cell up-left of the doorway, flagging each tile hidden so - * it renders under the warp artwork rather than over it. Without it the cells a - * doorway punches through the wall line keep the piece's blank floor slot and - * the ground shows a hole at the threshold. - * - * The donor tile is the nearest non-blank floor around the doorway, which is the - * practical stand-in for `DRLGROOMTILE_GetTileCache(TILETYPE_FLOOR, ...)`: the - * room's own floor style is exactly what the tile cache would have returned. - * - * @param cells - the composite grid, mutated in place. - * @param doorX - the doorway cell's x in the composite grid. - * @param doorY - the doorway cell's y in the composite grid. - * @returns how many blank cells were filled. - */ -function backfillThresholdFloor(cells: Ds1Cell[][], doorX: number, doorY: number): number { - const donor = findDonorFloor(cells, doorX, doorY) - if (donor === null) return 0 - let filled = 0 - for (const [dx, dy] of WARP_TILE_OFFSETS) { - const x = doorX - 1 + dx - const y = doorY - 1 + dy - const row = cells[y] - if (row === undefined) continue - const cell = row[x] - if (cell === undefined) continue - if (!floorIsBlank(cell.floors[0])) continue - const floors = [...cell.floors] - // 1.13c marks these `MAPTILE_HIDDEN` so they sit beneath the warp artwork. - floors[0] = { ...donor, hidden: true } - row[x] = { ...cell, floors } - filled += 1 - } - return filled -} - -/** - * Find a floor tile to copy into a doorway threshold. - * - * Searches the doorway cell first and then rings outwards, so the patch always - * picks up the floor of the room the doorway belongs to rather than a default. - * - * @param cells - the composite grid. - * @param atX - the doorway cell's x. - * @param atY - the doorway cell's y. - * @returns the donor floor, or `null` when nothing nearby draws a floor. - */ -function findDonorFloor(cells: readonly (readonly Ds1Cell[])[], atX: number, atY: number): Ds1Floor | null { - for (let radius = 0; radius <= 2; 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 floor = cells[atY + dy]?.[atX + dx]?.floors[0] - if (!floorIsBlank(floor)) return floor! - } - } - } - return null -} - -/** - * Choose each room's variant the way `DRLGMAZE_RollBasicPresets` does. - * - * For the plain side pieces D2MOO keeps a per-preset counter initialised to a - * random file index and then increments it modulo the file count, so two - * consecutive rooms of the same shape never get the same artwork. Specials and - * themed pieces keep whatever index the earlier pass assigned — for the Arcane - * Sanctuary that is the branch number, and for the Infernal Pit an explicit 0/1. - * - * @param level - the level. - * @param levelId - `Levels.txt` ID. - * @param stats - report collector. - */ -function assignVariants(level: MazeLevel, levelId: number, stats: MazeStats): void { - const counters = new Map() - const usage = new Map() - // D2MOO walks the roster from its head, i.e. newest room first. - for (const room of level.rooms) { - const piece = room.piece - if (piece === null) continue - usage.set(piece.name, (usage.get(piece.name) ?? 0) + 1) - if (piece.kind !== 'room' || piece.levels.length <= 1) continue - // Arcane Sanctuary (Level 74): branches and center room (level.first) have their - // variants explicitly assigned by arcaneSanctuary (DRLGMAZE_ArcaneSanctuary). - if (levelId === 74) continue - // On Palace Cellar Levels 1 & 2 (52, 53), SE and NW variants 0..2 carry the - // down-staircase warp tiles while variant 3 (CelSE3/CelNW3) is reserved for - // Level 54 (Palace Cellar Level 3) which has no descent to a fourth cellar. - const count = piece.name.startsWith('Act 2 - Basement') && levelId !== 54 - ? Math.min(piece.levels.length, 3) - : piece.levels.length - let counter = counters.get(piece.name) - if (counter === undefined) counter = level.rng.int(0, count - 1) - counters.set(piece.name, (counter + 1) % count) - room.variant = counter - } - for (const [name, count] of usage) stats.notes.push(`piece "${name}" used ${String(count)} time(s)`) -} - -/** - * Flatten the room list into one synthesized map. - * - * The size is the union of the stamped piece extents — `(span of the room - * origins) + (piece size)`. Because a piece is one cell larger than the stride, - * consecutive rooms overlap by exactly one cell and the piece stamped later owns - * that border, reproducing `DRLGPRESET_InitPresetRoomGrids`, which fills - * `nTileWidth + 1` cells per room. D2MOO stamps in roster order (newest first), - * so the level's *first* room is stamped last and wins every overlap it takes - * part in; the same order is used here. - * - * Objects keep their per-piece sub-tile coordinates and gain the room origin - * converted from cells at 5 sub-tiles per cell. - * - * Two 1.13c load-time behaviours are reproduced here rather than at draw time, - * because this is the only pass that sees both the piece and its place: - * - * - **`bKillEdge`** (`DrlgPreset.cpp:1105`, `DrlgRoomTile.cpp:449`). When the - * level type's `LvlPrest.KillEdge` is set, the room flush against the map's - * outer edge stamps `nTileWidth` cells instead of `nTileWidth + 1`, so the - * shared-border overhang that has no neighbour to be overwritten by is - * dropped instead of duplicating the wall line one cell past the map. - * - **Hidden door and exit walls** (`DrlgRoomTile.cpp:466`, `479`). A hidden - * door or exit tile is a marker, not artwork: 1.13c spawns a door unit or - * registers a warp and `continue`s, then backfills the threshold with the 2×2 - * floor patch `DRLGROOMTILE_LoadFloorWarpTiles` writes. Emitting the wall - * instead leaves the doorway blocked and the threshold floor blank. - * - * @param level - the level. - * @param stats - report collector. - * @param killEdge - the level type's `LvlPrest.KillEdge`. - * @returns the synthesized map. - */ -function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean, effectiveAct?: number): Ds1 { - const minX = Math.min(...level.rooms.map(room => room.x)) - const minY = Math.min(...level.rooms.map(room => room.y)) - const maxX = Math.max(...level.rooms.map(room => room.x)) - const maxY = Math.max(...level.rooms.map(room => room.y)) - - let wallLayers = 1 - let floorLayers = 1 - let substitutionType = 0 - let version = 0 - let act = effectiveAct ?? 1 - let widest = 1 - let highest = 1 - for (const room of level.rooms) { - const piece = room.piece - if (piece === null) continue - const variant = piece.levels[room.variant % piece.levels.length] - if (variant === undefined) throw new Error(`piece "${piece.name}" has no decoded map to stamp`) - wallLayers = Math.max(wallLayers, variant.wallLayers) - floorLayers = Math.max(floorLayers, variant.floorLayers) - substitutionType = Math.max(substitutionType, variant.substitutionType) - version = Math.max(version, variant.version) - if (effectiveAct === undefined) act = variant.act - widest = Math.max(widest, variant.width) - highest = Math.max(highest, variant.height) - } - - // `KillEdge` shortens the map by the overhang column and row that the - // right-most and bottom-most rooms would otherwise contribute. A piece is one - // cell wider than its section so that neighbours share a border; at the outer - // edge there is no neighbour to share with, so 1.13c drops it rather than - // letting the piece spill past `pDrlgCoord.nWidth` - // (`DrlgPreset.cpp:1109`, `DrlgRoomTile.cpp:449`). - const edgeTrim = killEdge ? 1 : 0 - const width = (maxX - minX) + Math.max(1, widest - edgeTrim) - const height = (maxY - minY) + Math.max(1, highest - edgeTrim) - if (width <= 0 || height <= 0 || width > MAX_CELLS_PER_SIDE || height > MAX_CELLS_PER_SIDE - || width * height > MAX_CELLS) { - throw new Error(`synthesized map is ${String(width)}x${String(height)} cells, outside the supported bound`) - } - - const substitutionLayers = substitutionType === 1 || substitutionType === 2 ? 1 : 0 - 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) - } - - const objects: Ds1Object[] = [] - // Doorway cells discovered while blitting; their thresholds are backfilled - // after every room is down, so a neighbour's floor can serve as the donor. - const thresholds: { x: number; y: number }[] = [] - let stampedRooms = 0 - let killedEdgeX = 0 - let killedEdgeY = 0 - let hiddenWalls = 0 - for (const room of level.rooms) { - const piece = room.piece - if (piece === null) continue - const variant = piece.levels[room.variant % piece.levels.length]! - const originX = room.x - minX - const originY = room.y - minY - - // `bKillEdgeX = pDrlgRoom->nTileXPos + pDrlgRoom->nTileWidth == pDrlgCoord.nPosX + nWidth` - // — true only for the room flush against the map's outer edge, which is the - // one whose overhang has no neighbour to be overwritten by. - const killEdgeX = killEdge && originX + room.width >= width - const killEdgeY = killEdge && originY + room.height >= height - if (killEdgeX) killedEdgeX += 1 - if (killEdgeY) killedEdgeY += 1 - - // `nTileCountX = pDrlgRoom->nTileWidth + (bKillEdgeX == 0)`. The clamp keeps - // an oddly-sized piece from running off the grid rather than trusting that - // every piece is exactly one cell wider than its section. - const spanX = Math.min(variant.width - (killEdgeX ? 1 : 0), width - originX) - const spanY = Math.min(variant.height - (killEdgeY ? 1 : 0), height - originY) - if (originX + spanX > width || originY + spanY > height) { - throw new Error(`piece "${piece.name}" overflows the ${String(width)}x${String(height)} map`) - } - for (let y = 0; y < spanY; y += 1) { - const source = variant.cells[y] - if (source === undefined) continue - const target = cells[originY + y]! - 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. - const kept = cell.walls.filter(wallSurvivesLoad) - if (kept.length === cell.walls.length) { - target[originX + x] = cell - } else { - hiddenWalls += cell.walls.length - kept.length - while (kept.length < cell.walls.length) kept.push(emptyWall()) - target[originX + x] = { ...cell, walls: kept } - if (cell.walls.some(needsThresholdBackfill)) { - thresholds.push({ x: originX + x, y: originY + y }) - } - } - } - } - - for (const object of variant.objects) { - 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, - }) - } - // This is the only pass that knows both which piece a room got and where - // that room ends up, so it is also the only place the stairs can be - // located. `Down` is the Act 1 cave descent and `Next` the ordinary one; - // they differ in artwork, not in meaning. - if (piece.kind === 'prev' || piece.kind === 'next' || piece.kind === 'down') { - stats.warps.push({ - room: room.id, - direction: piece.kind === 'prev' ? 'up' : 'down', - kind: piece.kind, - x: originX, - y: originY, - width: variant.width, - height: variant.height, - centreX: originX + Math.floor(variant.width / 2), - centreY: originY + Math.floor(variant.height / 2), - }) - } - const wpObj = variant.objects.find(obj => isWaypointDs1Object(act, obj.type, obj.id)) - if (piece.kind === 'waypoint' || wpObj !== undefined) { - let entityX: number - let entityY: number - if (wpObj !== undefined) { - entityX = originX * SUB_TILES_PER_TILE + wpObj.x - entityY = originY * SUB_TILES_PER_TILE + wpObj.y - } else { - entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2 - entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2 - const defaultWpId = act === 2 ? 156 : act === 3 ? 237 : act === 4 ? 238 : act === 5 ? 496 : 119 - objects.push({ - type: 2, - id: defaultWpId, - x: entityX, - y: entityY, - flags: 0, - }) - } - stats.waypoints.push({ - room: room.id, - x: originX, - y: originY, - width: variant.width, - height: variant.height, - centreX: originX + Math.floor(variant.width / 2), - centreY: originY + Math.floor(variant.height / 2), - entityX, - entityY, - }) - } - stampedRooms += 1 - } - - // Thresholds are filled only after every room is down: a doorway sits on the - // border between two rooms, so the donor floor may belong to the neighbour - // that had not been stamped yet when the doorway was found. - let thresholdCells = 0 - for (const at of thresholds) thresholdCells += backfillThresholdFloor(cells, at.x, at.y) - - stats.notes.push(`stamped ${String(stampedRooms)} of ${String(level.rooms.length)} rooms`) - if (killEdge) { - stats.notes.push(`killEdge trimmed ${String(killedEdgeX)} east and ${String(killedEdgeY)} south room edges`) - } - if (hiddenWalls > 0) { - stats.notes.push(`hid ${String(hiddenWalls)} door/exit walls, backfilled ${String(thresholdCells)} threshold floor cells`) - } - - return { - version: version === 0 ? 18 : version, - width, - height, - act, - substitutionType, - wallLayers, - floorLayers, - cells, - objects, - // The synthesized map is written into an asset pack rather than re-encoded, - // so there is no NPC-path section to point at. - npcPathOffset: null, - } -} - -/** Largest synthesized map side, in cells. Well above the widest shipped maze. */ -const MAX_CELLS_PER_SIDE = 4096 -/** Largest synthesized map area, matching the decoder's own sanity limit. */ -const MAX_CELLS = 1 << 22 - -/* ------------------------------------------------------------------------- * - * Connectivity - * ------------------------------------------------------------------------- */ - -/** - * Flood fill the doorway graph from the first room. - * - * The generator's correctness claim is "every placed room is reachable", so it is - * verified rather than asserted: growth attaches each room to an existing one and - * `Merge` only adds doorways, so this must always reach everything. - * - * @param level - the level. - * @returns the number of rooms reached. - */ -function reachableRooms(level: MazeLevel): number { - const seen = new Set([level.first]) - const queue: Room[] = [level.first] - while (queue.length > 0) { - const room = queue.pop()! - for (const orth of room.orths) { - if (seen.has(orth.room)) continue - seen.add(orth.room) - queue.push(orth.room) - } - } - return seen.size -} - -/* ------------------------------------------------------------------------- * - * Entry point - * ------------------------------------------------------------------------- */ - -/** - * Generate one random-maze level. - * - * The pipeline is D2MOO's `DRLGMAZE_GenerateLevel`: an opening (ring and/or the - * Catacombs arms and/or the tomb prologue), optional growth to `LvlMaze.Rooms` - * sections, a preset for every room from its side pattern, the `Theme` pass, the - * `Prev`/`Next`/quest replacement passes, and finally one synthesized map. - * - * @param request - the real table data for one `DrlgType == 1` level. - * @returns the synthesized map plus a report of what was done. - * @throws when the level cannot be built — an unknown level type, a placement - * stall, or rooms that ended up unreachable. The message always names the level, - * because an empty map silently baked into an asset pack is worse than a crash. - */ -export function generateMaze(request: MazeRequest): MazeResult { - const width = request.sectionSize - const height = request.sectionHeight ?? request.sectionSize - const where = `level ${String(request.levelId)} (${request.levelName})` - if (!Number.isFinite(width) || width <= 0) throw new Error(`${where}: LvlMaze.SizeX is ${String(width)}`) - if (!Number.isFinite(height) || height <= 0) throw new Error(`${where}: LvlMaze.SizeY is ${String(height)}`) - if (request.pieces.length === 0) throw new Error(`${where}: no usable LvlPrest pieces`) - if (!Number.isFinite(request.minRooms) || request.minRooms < 1) { - throw new Error(`${where}: LvlMaze.Rooms is ${String(request.minRooms)}`) - } - - const levelTypeName = request.levelTypeName ?? inferLevelTypeName(request.pieces) - if (levelTypeName === null) throw new Error(`${where}: cannot infer the level type from the piece names`) - const profile = MAZE_LEVEL_TYPE_PROFILES.find(candidate => candidate.levelTypeName === levelTypeName) - const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed ?? profile?.animSpeed - if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`) - - const stats: MazeStats = { - patterns: {}, fallbacks: [], specialsApplied: [], specialPassesApplied: [], unresolvedRoles: [], notes: [], - ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [], - } - const rng = new Rng(request.seed) - const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge))) - const index = buildPieceIndex(request.pieces) - const maxCellsX = request.maxCellsX !== undefined - ? request.maxCellsX - : (request.maxSectionsX !== undefined ? request.maxSectionsX * width : undefined) - const maxCellsY = request.maxCellsY !== undefined - ? request.maxCellsY - : (request.maxSectionsY !== undefined ? request.maxSectionsY * height : undefined) - - const level: MazeLevel = { - rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0, - maxCellsX, maxCellsY, stats, - } - const first = makeRoom(level) - const levelRef: MazeLevel = { ...level, rooms: [first], first } - // `first` was built against `level`, whose `rooms` array is the live one; copy - // the identity over rather than duplicating state. - Object.assign(level, levelRef) - - // `DRLGMAZE_GetRandomRoomExFromLevel` draws uniformly over the roster and the - // level seed advances on every call, so the seed must be consumed in the same - // order as the passes below. - let target = request.minRooms - if (request.staffTombLevelId === request.levelId) target *= 3 - else if (request.bossTombLevelId === request.levelId) target *= 2 - - const singleNames = profile.singleRoom?.[request.levelId] - if (singleNames !== undefined) { - const piece = singleNames.map(name => request.pieces.find(candidate => candidate.name === name)) - .find(found => found !== undefined) - if (piece === undefined) { - for (const name of singleNames) stats.unresolvedRoles.push(name) - stats.notes.push('single-room level: no named preset resolved, using the first piece') - const fallback = request.pieces[0]! - first.piece = fallback - first.hasDs1 = true - noteSpecial(first, fallback, 'single room', stats) - } else { - first.piece = piece - first.hasDs1 = true - noteSpecial(first, piece, 'single room', stats) - } - } else { - if (profile.ring !== undefined) { - const roomsPerDirection = levelTypeName === 'Act 3 - Sewer' && request.levelId === 92 ? 5 : profile.ring - ringLayout(level, roomsPerDirection, index, stats) - } - if (profile.opening === 'catacombs') { - // D2MOO branches on the level id before rolling, so level 1 consumes no - // random number here. - const horizontal = request.levelId === 34 ? false : rng.int(0, 1) === 1 - catacombsOpening(level, horizontal, request.pieces, index, stats) - } - if (profile.prevChain !== undefined) prevChain(level, profile.prevChain, request.pieces, index, stats) - if (profile.arcane === true) arcaneSanctuary(level, request.pieces, index, stats) - if (profile.lavaPairs === true) lavaPairs(level, request.pieces, index, stats) - if (profile.grow) stats.placementAttempts = growMaze(level, target, index, stats) - - // Every room needs a piece before the replacement passes can look for the - // plain ones to convert. - for (const room of level.rooms) { - if (room.piece === null) pickRoomPreset(room, index, stats) - } - - if (profile.theme) themeRoll(level, request.pieces, stats) - - // One counter shared by every table, advanced once per call that happens. - let cursor = rng.int(0, 3) - for (const group of profile.specials) { - if (group.kind === 'waypoint') { - if (request.hasWaypoint === false) continue - if (request.hasWaypoint !== true) { - if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue - if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue - } - } else { - if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue - if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue - } - placeSpecialGroup(level, request.pieces, group, cursor, index, stats) - cursor = (cursor + 1) % 4 - } - } - - if (profile.specialPass !== undefined) { - runMazeSpecialPasses(profile.specialPass, { - level, - request, - pieces: request.pieces, - index, - stats, - rng, - }) - } - - const unreachable = level.rooms.length - reachableRooms(level) - if (unreachable !== 0) { - throw new Error(`${where}: ${String(unreachable)} of ${String(level.rooms.length)} rooms are unreachable`) - } - const orphans = level.rooms.filter(room => room.piece === null).length - if (orphans !== 0) throw new Error(`${where}: ${String(orphans)} rooms ended up with no piece`) - - assignVariants(level, request.levelId, stats) - // 1.13c reads `dwKillEdge` off the maze map's own `LvlPrest` record. The - // shipped table sets the column uniformly across a level type's piece rows, so - // deriving it from the pieces gives the same answer without asking callers to - // look the maze map up separately. - const killEdge = request.killEdge ?? request.pieces.some(piece => piece.killEdge === true) - const effectiveAct = actOfLevel(request.levelId, levelTypeName) - const stamped = stamp(level, stats, killEdge, effectiveAct) - - const minX = Math.min(...level.rooms.map(room => room.x)) - const minY = Math.min(...level.rooms.map(room => room.y)) - const roomDescriptors: MazeRoomDescriptor[] = level.rooms.map(room => { - const variant = room.piece ? room.piece.levels[room.variant % room.piece.levels.length] : undefined - return { - id: room.id, - x: room.x, - y: room.y, - width: variant?.width ?? width, - height: variant?.height ?? height, - orths: room.orths, - piece: room.piece, - } - }) - - const popOptions: MazeObjectPopulationOptions | undefined = - typeof request.objectDensity === 'number' - ? { density: request.objectDensity, ...request.objectOptions } - : { ...request.objectDensity, ...request.objectOptions } - - const objectRng = popOptions?.objectSeed !== undefined - ? new Rng(popOptions.objectSeed) - : rng.fork('dungeon-objects') - - const popResult = populateDungeonObjects( - roomDescriptors, - minX, - minY, - stamped, - levelTypeName, - request.levelId, - popOptions, - objectRng, - ) - - const finalLevel: Ds1 = { - ...stamped, - objects: [...stamped.objects, ...popResult.placedObjects], - } - - return { - level: finalLevel, - objects: popResult.placedObjects, - stats: { - levelId: request.levelId, - levelName: request.levelName, - levelTypeName, - seed: request.seed, - sectionX: width, - sectionY: height, - targetRooms: target, - roomsPlaced: level.rooms.length, - ringRooms: stats.ringRooms, - placementAttempts: stats.placementAttempts, - doorways: level.rooms.reduce((sum, room) => sum + room.orths.length, 0) / 2, - mergePerMille, - mergedDoorways: level.merges, - gridSize: { width: stamped.width, height: stamped.height }, - gridSizeSubtiles: { width: stamped.width * SUB_TILES_PER_TILE, height: stamped.height * SUB_TILES_PER_TILE }, - objects: finalLevel.objects.length, - dynamicObjects: popResult.placedObjects, - dynamicObjectsCount: popResult.stats.dynamicObjectsCount, - shrinesSpawned: popResult.stats.shrinesSpawned, - chestsSpawned: popResult.stats.chestsSpawned, - superChestsSpawned: popResult.stats.superChestsSpawned, - racksSpawned: popResult.stats.racksSpawned, - containersSpawned: popResult.stats.containersSpawned, - cells: stamped.width * stamped.height, - sidePatterns: stats.patterns, - fallbacks: stats.fallbacks, - specialsApplied: stats.specialsApplied, - specialPassesApplied: stats.specialPassesApplied, - warps: stats.warps, - waypoints: stats.waypoints, - waypoint: stats.waypoints[0] ?? null, - waypointTile: stats.waypoints[0] - ? { - x: Math.floor(stats.waypoints[0].entityX / SUB_TILES_PER_TILE), - y: Math.floor(stats.waypoints[0].entityY / SUB_TILES_PER_TILE), - } - : null, - unresolvedRoles: stats.unresolvedRoles, - unimplementedPasses: unimplementedPassesFor(levelTypeName), - maxCellsX, - maxCellsY, - boundsRejected: stats.boundsRejected ?? 0, - notes: stats.notes, - ...(animSpeed !== undefined ? { animSpeed } : {}), - }, - ...(animSpeed !== undefined ? { animSpeed } : {}), - } -} diff --git a/src/game/monster-rooms.ts b/src/game/monster-rooms.ts index e96a849..527b551 100644 --- a/src/game/monster-rooms.ts +++ b/src/game/monster-rooms.ts @@ -9,7 +9,7 @@ * in camp rings around their leader. */ import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts' -import { Rng } from './rng.ts' +import { Rng } from './d2-rng.ts' /** * A discrete spatial room or sector in a level. diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 012307b..885a22e 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -73,7 +73,7 @@ import type { D2Table } from './acts.ts' import { cell, parseTable } from './acts.ts' import type { MonsterPack, MonsterStats } from './combat.ts' -import { Rng } from './rng.ts' +import { Rng } from './d2-rng.ts' import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts' /** Which difficulty a number is being read for. */ diff --git a/src/game/object-lookup.ts b/src/game/object-lookup.ts index 85897dd..196a88c 100644 --- a/src/game/object-lookup.ts +++ b/src/game/object-lookup.ts @@ -162,17 +162,17 @@ export function objectLookupStats(): ObjectLookupStats { } /** - * Objects.txt 中全部 17 种 Waypoint 对象行号(OperateFn = 23)。 + * Objects.txt 中全部 16 种 Waypoint 对象行号(OperateFn = 23)。 */ export const WAYPOINT_OBJECTS_TXT_IDS: ReadonlySet = new Set([ - 38, 119, 145, 156, 157, 237, 238, 288, 323, 324, 398, 402, 429, 494, 496, 511, 539, + 119, 145, 156, 157, 237, 238, 288, 323, 324, 398, 402, 429, 494, 496, 511, 539, ]) /** * 全部 Waypoint 对象的 Token 集合。 */ export const WAYPOINT_TOKENS: ReadonlySet = new Set([ - 'WP', 'WI', 'WM', 'WN', 'WZ', 'WV', 'W7', 'QM', 'QL', 'YG', 'YI', 'YY', '8Y', '5A', '5O', '3P', 'WT', + 'WP', 'WI', 'WM', 'WN', 'WZ', 'WV', 'W7', 'QM', 'QL', 'YG', 'YI', 'YY', '8Y', '5A', '5O', '3P', ]) /** diff --git a/src/game/portal.ts b/src/game/portal.ts index d55688d..8c6021e 100644 --- a/src/game/portal.ts +++ b/src/game/portal.ts @@ -207,7 +207,7 @@ export function resolveTownPortalSubTile( } if (townLevelId === 1) { if (key.includes('townn')) return { x: 153, y: 73 } - if (key.includes('towne')) return { x: 173, y: 103 } + if (key.includes('towne') || key === '1-act-1-town-var1') return { x: 173, y: 103 } if (key.includes('towns')) return { x: 153, y: 158 } if (key.includes('townw')) return { x: 83, y: 128 } } diff --git a/src/game/preset.ts b/src/game/preset.ts deleted file mode 100644 index 598ceab..0000000 --- a/src/game/preset.ts +++ /dev/null @@ -1,763 +0,0 @@ -/** - * Diablo II's preset level generator (`Levels.txt.DrlgType == 2`). - * - * In Diablo II, preset levels are defined in `LvlPrest.txt` and represent maps - * with fixed geometry rather than procedural maze or wilderness generation. - * Most preset levels consist of a single standalone DS1 file or quadrant rooms, - * but specific levels — most notably Act 4 Chaos Sanctuary (Level 108) — are - * assembled by stitching multiple cross-wing preset pieces at deterministic - * coordinates onto a fixed grid. - * - * This module handles preset level generation and composition, cleanly - * separating preset assembling logic from wilderness and maze generators. - */ -import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Substitution, Ds1Wall } from '../formats/ds1.ts' -import { SUB_TILES_PER_TILE } from './map.ts' - -/** Level ID for Act 4 Chaos Sanctuary in Levels.txt. */ -export const CHAOS_SANCTUARY_LEVEL_ID = 108 - -/** Standard canvas width for Act 4 Chaos Sanctuary in cells. */ -export const CHAOS_SANCTUARY_SIZE_X = 120 - -/** Standard canvas height for Act 4 Chaos Sanctuary in cells. */ -export const CHAOS_SANCTUARY_SIZE_Y = 120 - -/** - * Complete canonical set of 35 preset level IDs defined in `Levels.txt` with `DrlgType == 2`: - * - * - Act 1 (13): 1 (Rogue Encampment), 13 (Cave 2), 14 (Underground Passage 2), - * 15 (Hole 2), 16 (Pit 2), 20 (Forgotten Tower), 25 (Tower Cellar 5), - * 26 (Monastery Gate), 27 (Outer Cloister), 32 (Inner Cloister), - * 33 (Cathedral), 37 (Catacombs 4), 38 (Tristram). - * - Act 2 (3): 40 (Lut Gholein), 50 (Harem 1), 73 (Duriel's Lair). - * - Act 3 (11): 75 (Kurast Docktown), 90 (Swampy Pit 3), 91 (Flayer Dungeon 3), - * 93 (Sewers 2), 94..99 (Ruined Temple, Disused Fane, Forgotten Reliquary, - * Forgotten Temple, Ruined Fane, Disused Reliquary), 102 (Durance of Hate 3). - * - Act 4 (1): 103 (The Pandemonium Fortress). - * (Note: Level 108 Chaos Sanctuary is assembled via cross-wing presets, while having DrlgType == 3 in Levels.txt). - * - Act 5 (7): 109 (Harrogath), 120 (Rocky Summit / Arreat Summit), - * 121 (Nihlathaks Temple), 124 (Halls of Vaught), 131 (Throne of Destruction), - * 132 (The Worldstone Chamber), 136 (Tristram / Pandemonium Finale). - */ -export const PRESET_LEVEL_IDS: readonly number[] = [ - // Act 1 (13) - 1, 13, 14, 15, 16, 20, 25, 26, 27, 32, 33, 37, 38, - // Act 2 (3) - 40, 50, 73, - // Act 3 (11) - 75, 90, 91, 93, 94, 95, 96, 97, 98, 99, 102, - // Act 4 (1) - 103, - // Act 5 (7) - 109, 120, 121, 124, 131, 132, 136, -] as const - -/** Set of canonical DrlgType == 2 preset level IDs for O(1) membership check. */ -export const PRESET_LEVEL_ID_SET: ReadonlySet = new Set(PRESET_LEVEL_IDS) - -/** All preset level IDs handled by the preset pipeline, including Level 108 Chaos Sanctuary. */ -export const ALL_PRESET_LEVEL_IDS: readonly number[] = [ - ...PRESET_LEVEL_IDS.filter(id => id < 108), - CHAOS_SANCTUARY_LEVEL_ID, - ...PRESET_LEVEL_IDS.filter(id => id > 108), -] as const - -/** - * Checks whether a level is handled by the preset level generator. - * Returns true for all 35 canonical DrlgType == 2 levels and Level 108 Chaos Sanctuary. - * - * @param levelId - `Levels.txt` level Id. - */ -export function isPresetLevel(levelId: number): boolean { - return levelId === CHAOS_SANCTUARY_LEVEL_ID || PRESET_LEVEL_ID_SET.has(levelId) -} - -/** One `LvlPrest.txt` piece or DS1 preset available for stamping. */ -export interface PresetPiece { - /** Name of the preset piece (e.g. from LvlPrest.txt Name or DS1 file name). */ - readonly name: string - /** Decoded DS1 level variants. */ - readonly levels: readonly Ds1[] - /** Optional border flag for compatibility with wilderness piece records. */ - readonly border?: boolean | undefined - /** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */ - readonly animSpeed?: number | undefined -} - -/** Target placement coordinate for a stamped preset piece. */ -export interface PresetPlacement { - /** Name or name prefix of the preset piece to match. */ - readonly name: string - /** Top-left X in cells on the canvas. */ - readonly x: number - /** Top-left Y in cells on the canvas. */ - readonly y: number -} - -/** A recorded preset placement substitution in generator stats. */ -export interface PresetSubstitutionRecord { - readonly name: string - readonly role: string - readonly enabled: boolean - readonly clusters: number -} - -/** Request payload for {@link generatePreset}. */ -export interface PresetRequest { - /** `Levels.txt` `Id`. */ - readonly levelId: number - /** `Levels.txt` `Name`. */ - readonly levelName?: string | undefined - /** `LvlTypes.txt` name, e.g. `Act 4 - Lava`. */ - readonly levelTypeName?: string | undefined - /** Act number, 1..5. Defaults to 4 for Level 108. */ - readonly act?: number | undefined - /** Width in cells. Defaults to 120 for Level 108. */ - readonly sizeX?: number | undefined - /** Height in cells. Defaults to 120 for Level 108. */ - readonly sizeY?: number | undefined - /** Deterministic generator seed. */ - readonly seed?: number | undefined - /** Preset pieces available to assemble this level. */ - readonly pieces: readonly PresetPiece[] - /** Custom piece placements if overriding standard layout. */ - readonly placements?: readonly PresetPlacement[] | undefined - /** Optional substitution list for interface compatibility. */ - readonly substitutions?: readonly unknown[] | undefined -} - -/** - * A discrete 8x8 cell room or sector in a preset level. - * Corresponds to 1.13c D2Common `DRLGPRESET_BuildArea` room allocations. - */ -export interface PresetRoom { - /** Identifier for this room (e.g. "preset_room_0_0"). */ - readonly id: string | number - /** Top-left cell X on preset canvas. */ - readonly x: number - /** Top-left cell Y on preset canvas. */ - readonly y: number - /** Width in cells (normally 8, clipped at canvas boundary). */ - readonly width: number - /** Height in cells (normally 8, clipped at canvas boundary). */ - readonly height: number - /** Grid column index (x / 8). */ - readonly gridX: number - /** Grid row index (y / 8). */ - readonly gridY: number - /** Grid cell coordinate X (alias for x for MapRoom compatibility). */ - readonly cellX: number - /** Grid cell coordinate Y (alias for y for MapRoom compatibility). */ - readonly cellY: number - /** Width in cells (alias for width for MapRoom compatibility). */ - readonly cellsX: number - /** Height in cells (alias for height for MapRoom compatibility). */ - readonly cellsY: number - /** Objects placed within this room. */ - readonly objects: readonly Ds1Object[] -} - -/** Configuration options for {@link slicePresetRooms}. */ -export interface SlicePresetRoomsOptions { - /** - * If true, treats object coordinates directly as cell coordinates rather than - * sub-tiles (5 sub-tiles per cell). Default is false (standard DS1 sub-tiles). - */ - readonly cellCoords?: boolean | undefined -} - -/** - * Slices any preset canvas into discrete 8x8 cell PresetRooms, assigning local - * bounds and partitioning objects by their room. - * - * Matches 1.13c DrlgPreset.cpp DRLGPRESET_BuildArea: - * ```cpp - * const int nXEnd = pDrlgMap->pDrlgCoord.nPosX + pDrlgMap->pDrlgCoord.nWidth; - * const int nYEnd = pDrlgMap->pDrlgCoord.nPosY + pDrlgMap->pDrlgCoord.nHeight; - * for (int nY = pDrlgMap->pDrlgCoord.nPosY; nY < nYEnd; nY += 8) { - * for (int nX = pDrlgMap->pDrlgCoord.nPosX; nX < nXEnd; nX += 8) { - * // 8x8 tile room allocation (clipped at border) - * } - * } - * ``` - * - * @param width - Canvas width in cells. - * @param height - Canvas height in cells. - * @param objects - Objects placed on canvas (coordinates in sub-tiles by default). - * @param options - Optional configuration flags. - * @returns Array of 8x8 PresetRoom descriptors covering the canvas. - */ -export function slicePresetRooms( - width: number, - height: number, - objects: readonly Ds1Object[] = [], - options?: SlicePresetRoomsOptions, -): readonly PresetRoom[] { - if (width <= 0 || height <= 0) return [] - - const rooms: PresetRoom[] = [] - let gridY = 0 - for (let y = 0; y < height; y += 8) { - const roomHeight = Math.min(8, height - y) - let gridX = 0 - for (let x = 0; x < width; x += 8) { - const roomWidth = Math.min(8, width - x) - - const roomObjects: Ds1Object[] = [] - for (const obj of objects) { - const ox = options?.cellCoords ? Math.floor(obj.x) : Math.floor(obj.x / SUB_TILES_PER_TILE) - const oy = options?.cellCoords ? Math.floor(obj.y) : Math.floor(obj.y / SUB_TILES_PER_TILE) - if (ox >= x && ox < x + roomWidth && oy >= y && oy < y + roomHeight) { - roomObjects.push(obj) - } - } - - rooms.push({ - id: `preset_room_${gridX}_${gridY}`, - x, - y, - width: roomWidth, - height: roomHeight, - gridX, - gridY, - cellX: x, - cellY: y, - cellsX: roomWidth, - cellsY: roomHeight, - objects: roomObjects, - }) - - gridX += 1 - } - gridY += 1 - } - - return rooms -} - -/** - * Seed structure matching 1.13c D2SeedStrc. - */ -export interface D2Seed { - low: number - high: number -} - -/** - * Initializes a 1.13c D2SeedStrc state matching SEED_InitLowSeed. - * - * @param seed - Initial seed value (low 32-bits). - * @param high - Initial high seed value (defaults to 666 matching 1.13c SEED_InitLowSeed). - */ -export function createD2Seed(seed: number, high = 666): D2Seed { - return { - low: seed >>> 0, - high: high >>> 0, - } -} - -/** - * Advances seed and returns 32-bit pseudo-random roll matching 1.13c SEED_RollRandomNumber: - * `uint64_t lSeed = pSeed->nHighSeed + 0x6AC690C5i64 * pSeed->nLowSeed;` - */ -export function rollD2Random(seedState: D2Seed): number { - const lSeed = BigInt(seedState.high) + 0x6ac690c5n * BigInt(seedState.low) - seedState.low = Number(lSeed & 0xffffffffn) - seedState.high = Number((lSeed >> 32n) & 0xffffffffn) - return seedState.low -} - -/** - * Rolls a limited random number in `[0, max - 1]` matching 1.13c SEED_RollLimitedRandomNumber: - * ```cpp - * if (nMax > 0) { - * if ((nMax - 1) & nMax) return (unsigned int)SEED_RollRandomNumber(pSeed) % nMax; - * else return SEED_RollRandomNumber(pSeed) & (nMax - 1); - * } - * return 0; - * ``` - */ -export function rollD2LimitedRandom(seedState: D2Seed | number, max: number): number { - if (max <= 0) return 0 - const state = typeof seedState === 'number' ? createD2Seed(seedState) : seedState - const roll = rollD2Random(state) - if (((max - 1) & max) === 0) { - return roll & (max - 1) - } - return roll % max -} - -/** - * Resolves 0-based picked file index (nPickedFile) for a preset piece from - * `LvlPrest.txt` `File1..File6` variants and level seed, matching 1.13c DRLGPRESET_AllocDrlgMap. - * - * In 1.13c DrlgPreset.cpp: - * `pDrlgMap->nPickedFile = SEED_RollLimitedRandomNumber(pSeed, pDrlgMap->pLvlPrestTxtRecord->dwFiles);` - * - * @param filesCount - Number of available file variants (`LvlPrest.txt.Files` or variants length). - * @param seed - Level seed or D2Seed state. - * @returns 0-based index of picked file variant. - */ -export function resolvePickedFileIndex(filesCount: number, seed: number | D2Seed): number { - return rollD2LimitedRandom(seed, filesCount) -} - -/** - * Resolves the picked file variant from an array of file candidates or DS1s. - */ -export function resolvePickedFile(files: readonly T[], seed: number | D2Seed): T | undefined { - if (files.length === 0) return undefined - const index = resolvePickedFileIndex(files.length, seed) - return files[index] -} - -/** Statistics and metadata returned from preset map generation. */ -export interface PresetStats { - readonly levelId: number - readonly levelName: string - readonly sizeX: number - readonly sizeY: number - readonly seed: number - readonly substitutedClusters: number - readonly objects: number - readonly cells: number - readonly substitutions: readonly PresetSubstitutionRecord[] - readonly groundCells: number - readonly borderBlocks?: number | undefined - readonly borderUsage?: Readonly> | undefined - readonly unresolved?: readonly unknown[] | undefined - readonly notes?: readonly string[] | undefined - readonly nPickedFile?: number | undefined - readonly rooms?: number | undefined - readonly animSpeed?: number | undefined - readonly [key: string]: unknown -} - -/** Result of generating a preset level. */ -export interface PresetResult { - /** The assembled DS1 level. */ - readonly level: Ds1 - /** Execution statistics and placement records. */ - readonly stats: PresetStats - /** Discrete 8x8 rooms sliced from the preset canvas. */ - readonly rooms?: readonly PresetRoom[] | undefined - /** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */ - readonly animSpeed?: number | undefined -} - -/** - * Placements for the 7 cross-wing presets of Act 4 Chaos Sanctuary (Level 108). - * Stitched on a 120x120 canvas. - */ -export const CHAOS_SANCTUARY_PLACEMENTS: readonly PresetPlacement[] = [ - { name: 'Act 4 - Diablo Arm N', x: 48, y: 0 }, - { name: 'Act 4 - Diablo Arm W', x: 24, y: 24 }, - { name: 'Act 4 - Diablo Heart', x: 48, y: 24 }, - { name: 'Act 4 - Diablo Arm E', x: 72, y: 24 }, - { name: 'Act 4 - Diablo Arm S', x: 48, y: 48 }, - { name: 'Act 4 - Diablo Entry', x: 48, y: 72 }, - { name: 'Act 4 - Bridge 2', x: 48, y: 96 }, -] - -/** Internal mutable canvas during preset assembly. */ -interface PresetCanvas { - readonly width: number - readonly height: number - readonly cells: Ds1Cell[][] - readonly wallLayers: number - readonly floorLayers: number - readonly substitutionLayers: number - readonly objects: Ds1Object[] -} - -function emptyWall(): Ds1Wall { - return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -function emptyFloor(): Ds1Floor { - return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -function makeCanvas( - width: number, - height: number, - wallLayers = 1, - floorLayers = 1, - substitutionLayers = 1, -): PresetCanvas { - 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: [] } -} - -/** - * Stamp a DS1 piece into the canvas at a given cell origin, offsetting objects by sub-tiles. - */ -function stampDs1(canvas: PresetCanvas, 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 - 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 - } - } - - // Stamp objects, converting cell offset to 5 sub-tiles per cell - 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 -} - -/** - * Match a target preset piece name flexibly against available pieces. - * Handles full names, prefixes, without-Act prefixes, and stripped filenames. - */ -export function findMatchingPresetPiece( - pieces: readonly PresetPiece[], - targetName: string, -): PresetPiece | undefined { - const norm = (s: string) => s.trim().toLowerCase() - const target = norm(targetName) - - // 1. Direct equality or startsWith - const direct = pieces.find(p => { - const pn = norm(p.name) - return pn === target || pn.startsWith(target) - }) - if (direct) return direct - - // 2. Strip path and extension, and extract canonical token - const clean = (s: string) => { - const base = s.split(/[\\/]/).pop() ?? s - return base.replace(/\.ds1$/i, '').trim().toLowerCase() - } - const cleanTarget = clean(targetName) - const stripPrefix = (s: string) => - s.replace(/^act\s*\d+\s*[-_ ]*/i, '').replace(/^diablo\s*[-_ ]*/i, '').replace(/[^a-z0-9]/g, '') - const shortTarget = stripPrefix(cleanTarget) - const normTarget = cleanTarget.replace(/[^a-z0-9]/g, '') - - const tokenMatch = pieces.find(p => { - const cp = clean(p.name) - const shortP = stripPrefix(cp) - const normP = cp.replace(/[^a-z0-9]/g, '') - return ( - normP === normTarget || - shortP === shortTarget || - normP.endsWith(shortTarget) || - shortTarget.endsWith(normP) || - shortTarget === normP || - normP.includes(shortTarget) || - shortTarget.includes(shortP) - ) - }) - if (tokenMatch) return tokenMatch - - return undefined -} - -/** - * Assemble the 7 cross-wing presets for Act 4 Chaos Sanctuary (Level 108, 120x120). - */ -export function generateChaosSanctuary(request: PresetRequest): PresetResult { - const width = request.sizeX && request.sizeX > 0 ? request.sizeX : CHAOS_SANCTUARY_SIZE_X - const height = request.sizeY && request.sizeY > 0 ? request.sizeY : CHAOS_SANCTUARY_SIZE_Y - const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed - - let wallLayers = 1 - let floorLayers = 1 - let substitutionLayers = 1 - let version = 18 - - for (const piece of request.pieces) { - for (const lvl of piece.levels) { - if (lvl.version && lvl.version > 0) version = lvl.version - if (lvl.wallLayers > wallLayers) wallLayers = lvl.wallLayers - if (lvl.floorLayers > floorLayers) floorLayers = lvl.floorLayers - const subLen = lvl.cells[0]?.[0]?.substitutions?.length ?? 0 - if (subLen > substitutionLayers) substitutionLayers = subLen - } - } - - const canvas = makeCanvas(width, height, wallLayers, floorLayers, substitutionLayers) - const lavaPiece = findMatchingPresetPiece(request.pieces, 'LavaX') - const placements: readonly PresetPlacement[] = request.placements ?? (lavaPiece ? [ - // 19 surrounding LavaX 24x24 macro-rooms per DrlgOutdoors.cpp:1361 nLavaLvlPrestIds[25] - { name: 'LavaX', x: 0, y: 0 }, { name: 'LavaX', x: 24, y: 0 }, { name: 'LavaX', x: 48, y: 0 }, { name: 'LavaX', x: 72, y: 0 }, { name: 'LavaX', x: 96, y: 0 }, - { name: 'LavaX', x: 0, y: 24 }, { name: 'LavaX', x: 24, y: 24 }, { name: 'LavaX', x: 72, y: 24 }, { name: 'LavaX', x: 96, y: 24 }, - { name: 'LavaX', x: 0, y: 48 }, { name: 'LavaX', x: 96, y: 48 }, - { name: 'LavaX', x: 0, y: 72 }, { name: 'LavaX', x: 24, y: 72 }, { name: 'LavaX', x: 72, y: 72 }, { name: 'LavaX', x: 96, y: 72 }, - { name: 'LavaX', x: 0, y: 96 }, { name: 'LavaX', x: 24, y: 96 }, { name: 'LavaX', x: 72, y: 96 }, { name: 'LavaX', x: 96, y: 96 }, - // 6 Core Chaos Sanctuary DS1s at exact DrlgOutdoors.cpp:1361 coordinates - { name: 'Act 4 - Diablo Arm N', x: 48, y: 24 }, - { name: 'Act 4 - Diablo Arm W', x: 24, y: 48 }, - { name: 'Act 4 - Diablo Heart', x: 48, y: 48 }, - { name: 'Act 4 - Diablo Arm E', x: 72, y: 48 }, - { name: 'Act 4 - Diablo Arm S', x: 48, y: 72 }, - { name: 'Act 4 - Diablo Entry', x: 48, y: 96 }, - ] : CHAOS_SANCTUARY_PLACEMENTS) - const placed = new Set() - const substitutions: PresetSubstitutionRecord[] = [] - - const seedVal = request.seed ?? 0 - for (let idx = 0; idx < placements.length; idx += 1) { - const item = placements[idx]! - // If seed selects variant 2 (e.g. WingN2 / WingW2 / WingE2 / WingS2), check for matching variant piece - let piece = findMatchingPresetPiece(request.pieces, item.name) - if (seedVal > 0 && item.name.includes('Arm')) { - const suffix = ((seedVal + idx) % 2) + 1 - const armDir = item.name.slice(-1) - const varPiece = findMatchingPresetPiece(request.pieces, `Wing${armDir}${suffix}`) - if (varPiece) piece = varPiece - } - const ds1 = piece?.levels[0] - if (!ds1) continue - stampDs1(canvas, ds1, item.x, item.y) - placed.add(item.name) - substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 }) - } - - let groundCells = 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)) { - groundCells += 1 - } - } - } - - const rooms = slicePresetRooms(canvas.width, canvas.height, canvas.objects) - - return { - level: { - version: version === 0 ? 18 : version, - width: canvas.width, - height: canvas.height, - act: request.act ?? 4, - substitutionType: 0, - wallLayers: canvas.wallLayers, - floorLayers: canvas.floorLayers, - cells: canvas.cells, - objects: canvas.objects, - npcPathOffset: null, - }, - stats: { - levelId: request.levelId, - levelName: request.levelName ?? 'Chaos Sanctuary', - sizeX: canvas.width, - sizeY: canvas.height, - seed: request.seed ?? 0, - borderBlocks: 0, - borderUsage: {}, - substitutions, - substitutedClusters: placed.size, - objects: canvas.objects.length, - cells: canvas.width * canvas.height, - groundCells, - rooms: rooms.length, - ...(animSpeed !== undefined ? { animSpeed } : {}), - }, - rooms, - ...(animSpeed !== undefined ? { animSpeed } : {}), - } -} - -/** - * Dedicated preset level generator pipeline (`Levels.txt.DrlgType == 2`). - * - * Dispatches Level 108 (Act 4 Chaos Sanctuary) to its 7-cross assembled layout, - * and handles standalone preset maps and custom multi-piece placements. - */ -export function generatePreset(request: PresetRequest): PresetResult { - // Dispatch Level 108 (Chaos Sanctuary) - if ( - request.levelId === CHAOS_SANCTUARY_LEVEL_ID || - request.levelName === 'Chaos Sanctuary' || - request.levelName === 'Act 4 - Chaos Sanctuary' - ) { - return generateChaosSanctuary(request) - } - - // If explicit multi-piece placements are provided - if (request.placements && request.placements.length > 0) { - return generateChaosSanctuary(request) - } - - // Handle single preset map (resolving multi-piece or multi-level variants if present) - if (request.pieces.length > 0) { - let pickedPieceIndex = 0 - let pickedPiece = request.pieces[0] - let nPickedFile: number | undefined = undefined - - if (request.pieces.length > 1) { - pickedPieceIndex = resolvePickedFileIndex(request.pieces.length, request.seed ?? 0) - pickedPiece = request.pieces[pickedPieceIndex] - nPickedFile = pickedPieceIndex - } - - if (pickedPiece && pickedPiece.levels.length > 0) { - let ds1 = pickedPiece.levels[0] - if (pickedPiece.levels.length > 1) { - const variantIndex = resolvePickedFileIndex(pickedPiece.levels.length, request.seed ?? 0) - ds1 = pickedPiece.levels[variantIndex] ?? ds1 - nPickedFile = variantIndex - } - - if (ds1) { - const width = request.sizeX && request.sizeX > 0 ? request.sizeX : ds1.width - const height = request.sizeY && request.sizeY > 0 ? request.sizeY : ds1.height - - let resultLevel: Ds1 = ds1 - if (width !== ds1.width || height !== ds1.height) { - const canvas = makeCanvas( - width, - height, - ds1.wallLayers, - ds1.floorLayers, - ds1.cells[0]?.[0]?.substitutions?.length ?? 1, - ) - stampDs1(canvas, ds1, 0, 0) - resultLevel = { - version: ds1.version === 0 ? 18 : ds1.version, - width: canvas.width, - height: canvas.height, - act: request.act ?? ds1.act ?? 1, - substitutionType: ds1.substitutionType, - wallLayers: canvas.wallLayers, - floorLayers: canvas.floorLayers, - cells: canvas.cells, - objects: canvas.objects, - npcPathOffset: ds1.npcPathOffset, - } - } - - let groundCells = 0 - for (let y = 0; y < resultLevel.height; y += 1) { - const row = resultLevel.cells[y] - if (row === undefined) continue - for (let x = 0; x < resultLevel.width; x += 1) { - const c = row[x] - if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) { - groundCells += 1 - } - } - } - - const rooms = slicePresetRooms(resultLevel.width, resultLevel.height, resultLevel.objects) - const animSpeed = pickedPiece.animSpeed ?? request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed - - return { - level: resultLevel, - stats: { - levelId: request.levelId, - levelName: request.levelName ?? pickedPiece.name, - sizeX: width, - sizeY: height, - seed: request.seed ?? 0, - borderBlocks: 0, - borderUsage: {}, - substitutions: [ - { name: pickedPiece.name, role: 'preset', enabled: true, clusters: 1 }, - ], - substitutedClusters: 1, - objects: resultLevel.objects.length, - cells: width * height, - groundCells, - nPickedFile: nPickedFile ?? 0, - rooms: rooms.length, - ...(animSpeed !== undefined ? { animSpeed } : {}), - }, - rooms, - ...(animSpeed !== undefined ? { animSpeed } : {}), - } - } - } - } - - // Fallback to general assembly - return generateChaosSanctuary(request) -} diff --git a/src/game/rng.ts b/src/game/rng.ts deleted file mode 100644 index acfc81b..0000000 --- a/src/game/rng.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Deterministic pseudo-random numbers. - * - * Every random decision in the game — drops, affix rolls, monster spawn jitter — - * goes through one of these, seeded from a value the caller controls. That is not - * about quality (this is not a cryptographic generator) but about *reproducibility*: - * a drop table that cannot be replayed cannot be tested, and a simulation whose - * randomness comes from `Math.random` can never be reconciled between two machines, - * which is exactly what lockstep multiplayer needs at M5. - * - * The algorithm is mulberry32: 32 bits of state, a handful of integer ops, and a - * period long enough for a game session. It is written here rather than pulled in - * because the whole engine has no runtime dependencies. - */ - -/** A seeded random source. */ -export class Rng { - private state: number - - /** - * @param seed - any 32-bit integer; the same seed replays the same sequence. - */ - constructor(seed: number) { - this.state = seed >>> 0 - } - - /** - * The generator's current state. - * - * Exposed so a save can capture the stream exactly: restoring a game without its - * random state would replay the same drops in a different order, which is the - * kind of difference that only shows up much later. - */ - get seed(): number { - return this.state - } - - /** - * Draw the next value. - * - * @returns a number in `[0, 1)`. - */ - next(): number { - this.state = (this.state + 0x6d2b79f5) >>> 0 - let t = this.state - t = Math.imul(t ^ (t >>> 15), t | 1) - t ^= t + Math.imul(t ^ (t >>> 7), t | 61) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } - - /** - * Draw an integer in an inclusive range. - * - * @param min - lower bound. - * @param max - upper bound (inclusive). - * @returns the integer. - */ - int(min: number, max: number): number { - if (max <= min) return Math.floor(min) - return Math.floor(min + this.next() * (max - min + 1)) - } - - /** - * Draw a floating-point value in a range. - * - * @param min - lower bound. - * @param max - upper bound. - * @returns the value. - */ - range(min: number, max: number): number { - return min + this.next() * (max - min) - } - - /** - * Draw one element. - * - * @param items - the candidates. - * @returns the element, or undefined when there are none. - */ - pick(items: readonly T[]): T | undefined { - if (items.length === 0) return undefined - return items[Math.floor(this.next() * items.length)] - } - - /** - * Decide a yes/no with a probability. - * - * @param chance - probability in `[0, 1]`. - * @returns true when the draw succeeds. - */ - chance(probability: number): boolean { - return this.next() < probability - } - - /** - * Derive an independent stream, so one subsystem's draws cannot shift another's. - * - * @param label - a stable name for the stream. - * @returns a new generator. - */ - fork(label: string): Rng { - let hash = 2166136261 - for (let index = 0; index < label.length; index += 1) { - hash ^= label.charCodeAt(index) - hash = Math.imul(hash, 16777619) >>> 0 - } - return new Rng((hash ^ this.state) >>> 0) - } -} - -/** - * A stable 32-bit hash of a string, for seeding streams from names. - * - * @param text - the input. - * @returns the hash. - */ -export function hashString32(text: string): number { - let hash = 2166136261 - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index) - hash = Math.imul(hash, 16777619) >>> 0 - } - return hash >>> 0 -} diff --git a/src/game/runeword-socket.ts b/src/game/runeword-socket.ts index c58f10e..3b910a8 100644 --- a/src/game/runeword-socket.ts +++ b/src/game/runeword-socket.ts @@ -31,7 +31,7 @@ import { type ItemBase, type RolledItemProp, } from './items.ts' -import { Rng } from './rng.ts' +import { Rng } from './d2-rng.ts' import { CANONICAL_GEMS_TABLE, getMaxSockets, diff --git a/src/game/save.ts b/src/game/save.ts index 436a6c2..5a5a927 100644 --- a/src/game/save.ts +++ b/src/game/save.ts @@ -25,7 +25,7 @@ import { rebindPlayer } from './combat.ts' import type { CombatWorld, Monster } from './combat.ts' import type { Inventory, Item, PlacedItem } from './items.ts' import type { QuestLog, QuestProgress } from './quests.ts' -import type { Rng } from './rng.ts' +import type { Rng } from './d2-rng.ts' /** Snapshot format version; bumped whenever the shape changes. */ export const SNAPSHOT_VERSION = 1 @@ -34,8 +34,10 @@ export const SNAPSHOT_VERSION = 1 export interface GameSnapshot { /** Format version. */ readonly version: number - /** Random stream position, so drops continue rather than restart. */ + /** Random stream low 32-bit position, so drops continue rather than restart. */ readonly rngState: number + /** Random stream high 32-bit carry position (64-bit D2Rng). */ + readonly rngHighState?: number /** The combat world, minus its per-tick event buffer. */ readonly world: { readonly tick: number @@ -83,6 +85,7 @@ export function captureSnapshot(sources: SnapshotSources): GameSnapshot { return { version: SNAPSHOT_VERSION, rngState: sources.rng.seed, + rngHighState: sources.rng.hi, world: { tick: sources.world.tick, kills: sources.world.kills, @@ -135,6 +138,9 @@ function assertSnapshot(value: unknown): asserts value is GameSnapshot { throw new Error(`save version ${String('version' in value ? value.version : '?')} is not supported (expected ${String(SNAPSHOT_VERSION)})`) } if (!('rngState' in value) || typeof value.rngState !== 'number') throw new Error('save has no random state') + if ('rngHighState' in value && value.rngHighState !== undefined && typeof value.rngHighState !== 'number') { + throw new Error('save has invalid rngHighState') + } if (!('world' in value) || typeof value.world !== 'object' || value.world === null) throw new Error('save has no world') if (!('tick' in value.world) || typeof value.world.tick !== 'number') throw new Error('save has no world') @@ -186,6 +192,7 @@ export function restoreSnapshot( ): { world: CombatWorld rngState: number + rngHighState: number inventory: Inventory quests: QuestLog ground: { x: number; y: number; item: Item }[] @@ -195,7 +202,8 @@ export function restoreSnapshot( // its player list is rebuilt rather than inherited: spreading a snapshot over a // live world would otherwise leave the previous player list in place. world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }), - rngState: snapshot.rngState, + rngState: snapshot.rngState >>> 0, + rngHighState: (snapshot.rngHighState ?? 0) >>> 0, inventory: build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed), quests: build.quests(snapshot.quests), ground: snapshot.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })), diff --git a/src/game/skills.ts b/src/game/skills.ts index d64e132..9e47c86 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -22,7 +22,7 @@ import { numberCell, resolveText, textCell } from './tables.ts' import type { TextSource, DataTable } from './tables.ts' import type { D2Table } from './acts.ts' -import type { Rng } from './rng.ts' +import type { Rng } from './d2-rng.ts' import type { CharacterClassCode } from './classes.ts' import type { CombatPlayer } from './combat.ts' import { diff --git a/src/scene/backend/scene-population.ts b/src/scene/backend/scene-population.ts index 4747995..4cd4a68 100644 --- a/src/scene/backend/scene-population.ts +++ b/src/scene/backend/scene-population.ts @@ -19,7 +19,7 @@ import { enrichMonsterPacks, rollEliteModifiers, } from '../../game/monsters.ts' -import { Rng } from '../../game/rng.ts' +import { Rng } from '../../game/d2-rng.ts' import { subTileToScene } from '../transition.ts' /** Base paths tried in order when `?base=` is absent. */ diff --git a/tests/adv_probe_35b9267.test.ts b/tests/adv_probe_35b9267.test.ts index a52744a..29aff73 100644 --- a/tests/adv_probe_35b9267.test.ts +++ b/tests/adv_probe_35b9267.test.ts @@ -10,7 +10,7 @@ import { UnitStatList, FIXED_ONE } from '../src/game/engine/stat-list.ts' import { StateBus } from '../src/game/engine/state-bus.ts' import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts' import { monsterStatsOf, applyEliteModifiers, type MonsterKind } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { GameEngine } from '../src/game/engine.ts' function makeUnit(registry: any, id: string, name: string, hp = 10000, lightRes = 0): CombatUnitContext { diff --git a/tests/animated-tiles-speed.test.ts b/tests/animated-tiles-speed.test.ts index cf7a0cd..072505b 100644 --- a/tests/animated-tiles-speed.test.ts +++ b/tests/animated-tiles-speed.test.ts @@ -6,7 +6,6 @@ import { updateAnimatableTiles, } from '../src/game/animated-tiles.ts' import type { AnimatableTile } from '../src/game/animated-tiles.ts' -import { MAZE_LEVEL_TYPE_PROFILES } from '../src/game/maze.ts' describe('Animated Tile Speed from LvlPrest.txt Animate Column (Issue #85)', () => { describe('frameDurationMsFromAnimSpeed formula', () => { @@ -144,12 +143,4 @@ describe('Animated Tile Speed from LvlPrest.txt Animate Column (Issue #85)', () expect(tile.currentFrameIndex).toBe(2) }) }) - - describe('Act 4 Lava Profile configuration', () => { - test('Act 4 - Lava profile specifies animSpeed: 1', () => { - const lavaProfile = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 4 - Lava') - expect(lavaProfile).toBeDefined() - expect(lavaProfile?.animSpeed).toBe(1) - }) - }) }) diff --git a/tests/cast-overlay-113c.test.ts b/tests/cast-overlay-113c.test.ts index 9919f35..835d37e 100644 --- a/tests/cast-overlay-113c.test.ts +++ b/tests/cast-overlay-113c.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { parseOverlayTxt, getOverlayTxtData, diff --git a/tests/challenger-m1-elite-stats.test.ts b/tests/challenger-m1-elite-stats.test.ts index 05a6ca4..00425f8 100644 --- a/tests/challenger-m1-elite-stats.test.ts +++ b/tests/challenger-m1-elite-stats.test.ts @@ -21,7 +21,7 @@ import { MONUMOD_CONSTANTS, type MonsterKind, } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' describe('Challenger M1: Elite Monster Generation Statistical Verification', () => { let tables: { @@ -100,18 +100,20 @@ describe('Challenger M1: Elite Monster Generation Statistical Verification', () expect(uniqueBossMultiplier).toBe(4) expect(minionMultiplier).toBe(2) - // Verify in generated pack members - const planned = planLevelMonsters(tables, 4, 6400, 42, 170, 'normal') + // Verify in generated pack members across a few deterministic seeds (20% champion, 80% unique) let foundChampion = false let foundUnique = false let foundMinion = false - for (const pack of planned.packs) { - if (pack.superUniqueId) continue - for (const member of pack.members) { - if (member.rank === 'champion') foundChampion = true - if (member.rank === 'unique') foundUnique = true - if (member.rank === 'minion') foundMinion = true + for (let s = 42; s < 52; s += 1) { + const planned = planLevelMonsters(tables, 4, 6400, s, 170, 'normal') + for (const pack of planned.packs) { + if (pack.superUniqueId) continue + for (const member of pack.members) { + if (member.rank === 'champion') foundChampion = true + if (member.rank === 'unique') foundUnique = true + if (member.rank === 'minion') foundMinion = true + } } } diff --git a/tests/challenger-m1-stress.test.ts b/tests/challenger-m1-stress.test.ts index 1587f54..12a966e 100644 --- a/tests/challenger-m1-stress.test.ts +++ b/tests/challenger-m1-stress.test.ts @@ -25,7 +25,7 @@ import { type Difficulty, type MonsterKind, } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' /** * Independent ground-truth mathematical oracle for monsterBudget. diff --git a/tests/challenger-m2-adversarial.test.ts b/tests/challenger-m2-adversarial.test.ts index 6ccae35..9a384a9 100644 --- a/tests/challenger-m2-adversarial.test.ts +++ b/tests/challenger-m2-adversarial.test.ts @@ -32,8 +32,7 @@ import { } from '../src/game/item-ratio.ts' import { executeDropPipeline } from '../src/game/drop-pipeline.ts' import { ItemQuality } from '../src/game/items.ts' -import { D2Rng } from '../src/game/d2-rng.ts' -import { Rng } from '../src/game/rng.ts' +import { D2Rng, Rng } from '../src/game/d2-rng.ts' import { computeEffectivePlayers, computeScaledNoDrop, diff --git a/tests/challenger-m2-statistical-stress.test.ts b/tests/challenger-m2-statistical-stress.test.ts index c484364..53dcc6b 100644 --- a/tests/challenger-m2-statistical-stress.test.ts +++ b/tests/challenger-m2-statistical-stress.test.ts @@ -6,8 +6,7 @@ import { computeEffectivePlayers, computeScaledNoDrop, } from '../src/game/treasure-engine.ts' -import { D2Rng } from '../src/game/d2-rng.ts' -import { Rng } from '../src/game/rng.ts' +import { D2Rng, Rng } from '../src/game/d2-rng.ts' import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts' import { createWorld, spawnMonsters, tickCombat, damageMonster, monsterStatsFromTable } from '../src/game/combat.ts' import { parseTable } from '../src/game/tables.ts' diff --git a/tests/challenger-m4-determinism-parity.test.ts b/tests/challenger-m4-determinism-parity.test.ts index 0b8b396..d6364d0 100644 --- a/tests/challenger-m4-determinism-parity.test.ts +++ b/tests/challenger-m4-determinism-parity.test.ts @@ -77,13 +77,13 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' expect(meta.clips.nu.group).toBe(16) }) - it('verifies full-spectrum asset parity across all 365 scenes in index.json', () => { + it('verifies full-spectrum asset parity across all 136 scenes in index.json', () => { expect(existsSync(ROOT_INDEX_PATH)).toBe(true) const index = JSON.parse(readFileSync(ROOT_INDEX_PATH, 'utf8')) expect(index.version).toBe(1) expect(Array.isArray(index.levels)).toBe(true) - expect(index.levels).toHaveLength(365) + expect(index.levels).toHaveLength(136) const kindsCount = { preset: 0, wilderness: 0, maze: 0 } let totalFloors = 0 @@ -147,15 +147,14 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity' } } - expect(kindsCount.preset).toBe(62) - expect(kindsCount.wilderness).toBe(93) - expect(kindsCount.maze).toBe(210) - // 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) + expect(kindsCount.preset).toBe(35) + expect(kindsCount.wilderness).toBe(31) + expect(kindsCount.maze).toBe(70) + // Measured on the canonical 136-scene bake (DRLG_ACT_VARIANTS = 1, GAME_SEED = 0x5eed_0100) + // where all 136 levels (35 preset + 31 wilderness + 70 maze = 136) come from the 1:1 D2Common DRLG port. + expect(totalFloors).toBe(344712) + expect(totalWalls).toBe(84137) + expect(totalPngs).toBe(529) }) it('verifies world graph integrity and edge consistency with index.json', () => { diff --git a/tests/d2-rng.test.ts b/tests/d2-rng.test.ts index dd8ebe4..e422a6f 100644 --- a/tests/d2-rng.test.ts +++ b/tests/d2-rng.test.ts @@ -285,12 +285,48 @@ describe("1.13c Diablo II PRNG and Per-Unit Seed (D2Common!6FD510B0 / D2Game!6FC expect(rng.hi).toBe(200) }) - test("next() advances state and returns low 32-bit register", () => { + test("nextUint32() advances state and returns low 32-bit register, while next() returns float in [0, 1)", () => { const rng = new D2Rng(1, 0) - const val = rng.next() + const val = rng.nextUint32() expect(val).toBe(1791398085) expect(rng.lo).toBe(1791398085) expect(rng.hi).toBe(0) + + const rngFloat = new D2Rng(1, 0) + const f = rngFloat.next() + expect(f).toBe(1791398085 / 4294967296) + expect(f).toBeGreaterThanOrEqual(0) + expect(f).toBeLessThan(1) + }) + + test("convenience methods (seed, low, high, int, range, pick, chance, fork) work deterministically", () => { + const rng = new D2Rng(0x1234, 0x5678) + expect(rng.seed).toBe(0x1234) + expect(rng.low).toBe(0x1234) + expect(rng.high).toBe(0x5678) + + for (let i = 0; i < 20; i++) { + const v = rng.int(5, 10) + expect(v).toBeGreaterThanOrEqual(5) + expect(v).toBeLessThanOrEqual(10) + } + expect(rng.int(7, 7)).toBe(7) + + const r = rng.range(10, 20) + expect(r).toBeGreaterThanOrEqual(10) + expect(r).toBeLessThan(20) + + expect(rng.pick([])).toBeUndefined() + expect(["a", "b", "c"]).toContain(rng.pick(["a", "b", "c"])) + + expect(rng.chance(0)).toBe(false) + expect(rng.chance(1)).toBe(true) + + const f1 = rng.fork("stream-a") + const f2 = rng.fork("stream-a") + const f3 = rng.fork("stream-b") + expect(f1.getSeed()).toEqual(f2.getSeed()) + expect(f1.getSeed()).not.toEqual(f3.getSeed()) }) }) }) diff --git a/tests/drlg-alias-guard.test.ts b/tests/drlg-alias-guard.test.ts index 2ad7c13..7c72300 100644 --- a/tests/drlg-alias-guard.test.ts +++ b/tests/drlg-alias-guard.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "vitest" import { readFileSync, existsSync } from "node:fs" import { resolve } from "node:path" -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" @@ -10,12 +9,17 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => { return Object.keys(mod).filter((name) => name.startsWith("DRLG")) } - test("legacy src/game/wilderness*.ts and world-placement.ts have been removed in favor of src/game/drlg", () => { + test("legacy src/game/wilderness*.ts, maze*.ts, preset.ts, rng.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) + expect(existsSync(resolve(__dirname, "../src/game/maze.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/preset.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/maze-special-passes.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/maze-objects.ts"))).toBe(false) + expect(existsSync(resolve(__dirname, "../src/game/rng.ts"))).toBe(false) }) test("src/game/level-links.ts does not export duplicate DRLGOUTDOORS_SpawnAct12Waypoint", () => { @@ -28,9 +32,8 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => { expect(typeof levelLinks.findWaypointSpot).toBe("function") }) - test("src/game/maze.ts and src/game/drlg/drlg-map.ts do not export fake Blizzard DRLG aliases", () => { + test("src/game/drlg/drlg-map.ts does not export fake Blizzard DRLG aliases", () => { const exports = [ - ...getDrlgExports(maze as Record), ...getDrlgExports(drlgMap as Record), ] expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask") @@ -39,7 +42,6 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => { test("Static source check: no fake DRLG export statements in source files", () => { const files = [ - resolve(__dirname, "../src/game/maze.ts"), resolve(__dirname, "../src/game/level-links.ts"), resolve(__dirname, "../src/game/drlg/drlg-map.ts"), ] diff --git a/tests/dungeon-wilderness-animations.test.ts b/tests/dungeon-wilderness-animations.test.ts index 5e0a1f8..5e4ab2d 100644 --- a/tests/dungeon-wilderness-animations.test.ts +++ b/tests/dungeon-wilderness-animations.test.ts @@ -233,8 +233,8 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 } }) - it('verifies town scene (towne1) preserves all animated objects', () => { - const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json') + it('verifies town scene (1-act-1-town-var1) preserves all animated objects', () => { + const townPath = join(packsDir, 'act1/1-act-1-town-var1/scene.json') expect(existsSync(townPath)).toBe(true) const scene = JSON.parse(readFileSync(townPath, 'utf8')) diff --git a/tests/e2e-automap-113c/tier1-feature-coverage.test.ts b/tests/e2e-automap-113c/tier1-feature-coverage.test.ts index 12d4d5e..2916ed8 100644 --- a/tests/e2e-automap-113c/tier1-feature-coverage.test.ts +++ b/tests/e2e-automap-113c/tier1-feature-coverage.test.ts @@ -558,7 +558,7 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () => it('F5.5: embeds fSaved=1, 2, 3 town extras into baked scene.json files for levels 40, 103, and 109', () => { for (const [levelId, expectedSlot, expectedLen] of [ - [40, 1, 15], + [40, 1, 14], [103, 2, 4], [109, 3, 6], ] as const) { @@ -767,8 +767,8 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () => expect(arcaneWpMap.objects.some(c => c.cellNo === 318)).toBe(true) }) - it('F7.5: bakes valid 4-layer automap data across all 365 scene.json variants in samples/d2-packs', () => { - expect(packIndex.levels.length).toBeGreaterThanOrEqual(365) + it('F7.5: bakes valid 4-layer automap data across all 136 scene.json variants in samples/d2-packs', () => { + expect(packIndex.levels.length).toBe(136) const levelIds = new Set() for (const entry of packIndex.levels) { levelIds.add(entry.levelId) diff --git a/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts b/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts index b3033c4..a865fe6 100644 --- a/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts +++ b/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts @@ -828,7 +828,7 @@ describe('Tier 4 — Real-World E2E Scenarios (12 end-to-end scenarios across Ac } } - expect(totalScenesVerified).toBe(365) + expect(totalScenesVerified).toBe(136) expect(totalCellsVerified).toBeGreaterThan(100000) }) diff --git a/tests/e2e-drop-parity/tier2-boundary-corner.test.ts b/tests/e2e-drop-parity/tier2-boundary-corner.test.ts index 60bcb13..f0fafaa 100644 --- a/tests/e2e-drop-parity/tier2-boundary-corner.test.ts +++ b/tests/e2e-drop-parity/tier2-boundary-corner.test.ts @@ -722,8 +722,11 @@ describe('Tier 2 — Boundary, Corner & Negative Cases (All 17 Features, Issues it('B15.4 integer RNG ensures cross-platform determinism without float divergence', () => { const rng = new D2Rng(42) - const val = rng.next() + const val = rng.nextUint32() expect(Number.isInteger(val)).toBe(true) + const floatVal = rng.next() + expect(floatVal).toBeGreaterThanOrEqual(0) + expect(floatVal).toBeLessThan(1) }) it('B15.5 multiple monsters killed on same tick produce identical lockstep drop arrays', () => { diff --git a/tests/firebolt-missile-113c.test.ts b/tests/firebolt-missile-113c.test.ts index 181982f..eb8920e 100644 --- a/tests/firebolt-missile-113c.test.ts +++ b/tests/firebolt-missile-113c.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { parseMissilesTxt, getMissileTxtData, diff --git a/tests/input-controls.test.ts b/tests/input-controls.test.ts index 881cfe1..3e403bc 100644 --- a/tests/input-controls.test.ts +++ b/tests/input-controls.test.ts @@ -1746,12 +1746,12 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label } }) - it('verifies all 365 baked map packs (495 SceneWarp and 166 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => { + it('verifies all 136 baked map packs (192 SceneWarp and 60 SceneEntrance entries) resolve valid Chinese destination badges, hitboxes, and arrival triggers', () => { const baseDir = path.resolve(process.cwd(), 'samples/d2-packs') const indexPath = path.join(baseDir, 'index.json') const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')) const packs: Array<{ path: string }> = index.levels ?? [] - expect(packs.length).toBe(365) + expect(packs.length).toBe(136) let warpCount = 0 let nonZeroBoxWarps = 0 @@ -1807,18 +1807,14 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label } } - // 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 / - // gAct1MonasteryDrlgLink put them in separate groups, joined only through - // the Underground Passage), so the 2 seam entrances the old generator - // invented in each of the 3 act copies are gone. - expect(entranceCount).toBe(166) + // 192 total warps (179 with LvlWarp.txt bounding boxes + 13 zero-box warps) + // across the 136 canonical DRLG scenes (DRLG_ACT_VARIANTS = 1, GAME_SEED = 0x5eed_0100). + expect(warpCount).toBe(192) + expect(nonZeroBoxWarps).toBe(179) + expect(zeroBoxWarps).toBe(13) + // 60 seamless entrances across the 136 canonical DRLG scenes (62 seamless edges in + // world-graph.json minus the 2 mutually-exclusive inactive Act 3 Jungle seam edges). + expect(entranceCount).toBe(60) }) it('ignores unresolved warps and entrances (toLevelId <= 0) for overhead badges, hover, left-click, and Telekinesis', () => { diff --git a/tests/item-struct.test.ts b/tests/item-struct.test.ts index 1f59ba4..5c6443c 100644 --- a/tests/item-struct.test.ts +++ b/tests/item-struct.test.ts @@ -23,7 +23,7 @@ import type { ItemRunewordData, ItemEarData, } from '../src/game/items.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' describe('Item Bit-Aligned Data Structure & Specifications (D2Common!6FD77180 / D2Common!6FD592C4)', () => { describe('Header Flags Bitmask & Encoders', () => { diff --git a/tests/items.test.ts b/tests/items.test.ts index 42269aa..08f4c08 100644 --- a/tests/items.test.ts +++ b/tests/items.test.ts @@ -15,7 +15,7 @@ import { rollAffix, totalStat, ItemQuality, } from '../src/game/items.ts' import type { Affix, ItemBase } from '../src/game/items.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { parseTable } from '../src/game/tables.ts' const isSkip = false && !fs.existsSync('samples/d2'); const _results: any[] = []; diff --git a/tests/killedge-113c.test.ts b/tests/killedge-113c.test.ts deleted file mode 100644 index 9be426f..0000000 --- a/tests/killedge-113c.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Ground-truth tests for the 1.13c room-seam and doorway rules in `stamp`. - * - * Both behaviours are transcribed from Diablo II v1.13c `D2Common.dll` - * (MD5 `ee1238806ef6d6d9801d12a09d128fe1`), via D2MOO: - * - * - `DRLGPRESET_InitPresetRoomGrids` (`DrlgPreset.cpp:1105`) derives - * `bKillEdgeX`/`bKillEdgeY` from `LvlPrest.KillEdge` and the room's position - * relative to the map's outer edge, and `DRLGROOMTILE_LoadInitRoomTiles` - * (`DrlgRoomTile.cpp:449`) turns them into - * `nTileCountX = nTileWidth + (bKillEdgeX == 0)`. - * - `DRLGROOMTILE_LoadInitRoomTiles` drops hidden door and exit walls - * (`DrlgRoomTile.cpp:466`, `479`) and `DRLGROOMTILE_LoadFloorWarpTiles` - * (`DrlgRoomTile.cpp:706`) backfills a 2×2 threshold patch at `(nX-1, nY-1)` - * using `gWarpTileOffsets_6FDD1320 = {0,0},{1,0},{0,1},{1,1}`. - */ - -import { describe, expect, test } from 'vitest' -import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../src/formats/ds1.ts' -import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze.ts' - -/** DS1 wall types 1.13c treats as markers rather than artwork. */ -const TYPE_LEFT_DOOR = 8 -const TYPE_LEFT_EXIT = 10 - -/** A floor slot that draws something. */ -function solidFloor(): Ds1Floor { - return { prop1: 1, sequence: 0, style: 5, unknown1: 0, unknown2: 0, hidden: false } -} - -/** The decoder's placeholder floor — what `floorIsBlank` looks for. */ -function blankFloor(): Ds1Floor { - return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -/** A plain wall slot. */ -function plainWall(): Ds1Wall { - return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false } -} - -/** Where a marker wall goes inside every piece, and what it looks like. */ -interface MarkerSpec { - readonly x: number - readonly y: number - readonly type: number - readonly style: number - readonly hidden: boolean - /** Blank the 2×2 threshold the backfill is expected to repair. */ - readonly blankThreshold?: boolean -} - -/** - * Build a piece DS1 of the given size, optionally carrying one marker wall. - * - * @param size - width and height in cells. - * @param marker - the marker wall to embed, if any. - * @returns the map. - */ -function pieceDs1(size: number, marker?: MarkerSpec): Ds1 { - const blanks = new Set() - if (marker?.blankThreshold === true) { - for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) { - blanks.add(`${String(marker.x - 1 + dx)},${String(marker.y - 1 + dy)}`) - } - } - const cells: Ds1Cell[][] = [] - for (let y = 0; y < size; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < size; x += 1) { - const isMarker = marker !== undefined && marker.x === x && marker.y === y - row.push({ - walls: [isMarker - ? { prop1: 0, sequence: 0, style: marker.style, type: marker.type, unknown1: 0, unknown2: 0, hidden: marker.hidden } - : plainWall()], - floors: [blanks.has(`${String(x)},${String(y)}`) ? blankFloor() : solidFloor()], - shadows: [blankFloor()], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, width: size, height: size, act: 1, substitutionType: 0, - wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, - } -} - -/** - * The 15 side shapes plus the entrance and warp pieces `generateMaze` needs. - * - * Every piece is the same size and carries the same marker, so whichever piece - * the generator picks the assertion below still has something to find. - * - * @param size - piece size in cells; one larger than the section stride. - * @param killEdge - the `LvlPrest.KillEdge` value to report. - * @param marker - the marker wall to embed in every piece. - * @returns the piece set. - */ -function pieceSet(size: number, killEdge: boolean, marker?: MarkerSpec): MazePiece[] { - const sides = ['N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW', 'NS', 'EW', 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW'] - const make = (name: string, kind: MazePieceKind, side: string): MazePiece => - ({ name, kind, sides: side, levels: [pieceDs1(size, marker)], killEdge }) - return [ - make('Act 1 - Cave Entrance', 'entrance', ''), - ...sides.map(side => make(`Act 1 - Cave ${side}`, 'room', side)), - ...['N', 'S', 'E', 'W'].flatMap(side => [ - make(`Act 1 - Cave Prev ${side}`, 'prev', side), - make(`Act 1 - Cave Next ${side}`, 'next', side), - make(`Act 1 - Cave Down ${side}`, 'down', side), - make(`Act 1 - Cave Theme ${side}`, 'theme', side), - ]), - ] -} - -/** A small deterministic Act 1 Cave request. */ -function request(pieces: readonly MazePiece[], overrides: Partial = {}): MazeRequest { - return { - levelId: 2, - levelName: 'Act 1 - Cave 1', - levelTypeName: 'Act 1 - Cave', - sectionSize: 7, - minRooms: 5, - merge: 0, - seed: 0x1337, - pieces, - ...overrides, - } -} - -/** Every wall slot in a generated map, flattened. */ -function allWalls(level: Ds1): Ds1Wall[] { - return level.cells.flatMap(row => row.flatMap(cell => [...cell.walls])) -} - -describe('1.13c bKillEdge room-seam suppression', () => { - test('KillEdge trims exactly one column and one row off the composite map', () => { - // A piece is one cell wider than the 7-cell stride, so without KillEdge the - // outer room contributes an overhang that has no neighbour to overwrite it. - const off = generateMaze(request(pieceSet(8, false))) - const on = generateMaze(request(pieceSet(8, true))) - - expect(on.level.width).toBe(off.level.width - 1) - expect(on.level.height).toBe(off.level.height - 1) - }) - - test('KillEdge is read off the pieces when the request does not override it', () => { - const derived = generateMaze(request(pieceSet(8, true))) - const overridden = generateMaze(request(pieceSet(8, true), { killEdge: false })) - const plain = generateMaze(request(pieceSet(8, false))) - - // The override wins over the piece rows, and matches the un-trimmed map. - expect(overridden.level.width).toBe(plain.level.width) - expect(derived.level.width).toBe(plain.level.width - 1) - - const notes = derived.stats.notes as string[] - expect(notes.some(note => note.startsWith('killEdge trimmed'))).toBe(true) - expect((plain.stats.notes as string[]).some(note => note.startsWith('killEdge trimmed'))).toBe(false) - }) - - test('trimming never drops interior seams, only the outer edge', () => { - // With KillEdge on, the map is exactly `(span of origins) + stride`, which - // is what `pDrlgCoord.nWidth` is in 1.13c. Every interior room therefore - // still stamps its full 8 cells and keeps overlapping its neighbour. - const on = generateMaze(request(pieceSet(8, true), { minRooms: 9 })) - const stride = 7 - expect((on.level.width - stride) % stride).toBe(0) - expect((on.level.height - stride) % stride).toBe(0) - }) -}) - -describe('1.13c hidden door and exit walls', () => { - test('a hidden door wall is never emitted as artwork', () => { - const hidden = generateMaze(request(pieceSet(8, false, { - x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true, - }))) - const shown = generateMaze(request(pieceSet(8, false, { - x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: false, - }))) - - expect(allWalls(hidden.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(false) - // The identical non-hidden door is ordinary artwork and must survive. - expect(allWalls(shown.level).some(wall => wall.type === TYPE_LEFT_DOOR)).toBe(true) - }) - - test('an exit tile above the Warp0..7 slot range is dropped outright', () => { - // `nTileStyle >= 8` is not a warp slot, so 1.13c skips the cell even though - // the tile is not flagged hidden. - const tooHigh = generateMaze(request(pieceSet(8, false, { - x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 8, hidden: false, - }))) - const inRange = generateMaze(request(pieceSet(8, false, { - x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 7, hidden: false, - }))) - - expect(allWalls(tooHigh.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(false) - expect(allWalls(inRange.level).some(wall => wall.type === TYPE_LEFT_EXIT)).toBe(true) - }) - - test('a hidden exit backfills the 2x2 threshold floor patch', () => { - const marker: MarkerSpec = { - x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true, - } - const patched = generateMaze(request(pieceSet(8, false, marker))) - - const notes = patched.stats.notes as string[] - const note = notes.find(entry => entry.startsWith('hid ')) - expect(note).toBeDefined() - - const filled = Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0') - expect(filled).toBeGreaterThan(0) - - // Every backfilled cell must draw a floor and be flagged hidden, the way - // `DRLGROOMTILE_LoadFloorWarpTiles` writes them. - let hiddenFloors = 0 - for (const row of patched.level.cells) { - for (const cell of row) { - const floor = cell.floors[0] - if (floor !== undefined && floor.hidden && floor.style === 5) hiddenFloors += 1 - } - } - expect(hiddenFloors).toBe(filled) - }) - - test('a hidden door suppresses its wall but gets no warp threshold patch', () => { - // `LoadFloorWarpTiles` is reached only from the exit branch; the door branch - // above it spawns a unit and `continue`s without touching the floor. - const door = generateMaze(request(pieceSet(8, false, { - x: 3, y: 3, type: TYPE_LEFT_DOOR, style: 0, hidden: true, blankThreshold: true, - }))) - const exit = generateMaze(request(pieceSet(8, false, { - x: 3, y: 3, type: TYPE_LEFT_EXIT, style: 2, hidden: true, blankThreshold: true, - }))) - - const backfilled = (result: typeof door): number => { - const note = (result.stats.notes as string[]).find(entry => entry.startsWith('hid ')) - return Number(/backfilled (\d+) threshold floor cells/.exec(note ?? '')?.[1] ?? '0') - } - - expect(backfilled(door)).toBe(0) - expect(backfilled(exit)).toBeGreaterThan(0) - }) - - test('a map with no markers reports no hidden walls and no backfill', () => { - const plain = generateMaze(request(pieceSet(8, false))) - expect((plain.stats.notes as string[]).some(note => note.startsWith('hid '))).toBe(false) - }) -}) - diff --git a/tests/m4.test.ts b/tests/m4.test.ts index b41feae..df7448e 100644 --- a/tests/m4.test.ts +++ b/tests/m4.test.ts @@ -20,7 +20,7 @@ import { parseTable, resolveText, textSourceOf } from '../src/game/tables.ts' import { castSkill, skillDamageAt, skillsFromTable, tickProjectiles } from '../src/game/skills.ts' import type { Projectile, ProjectileTarget, SkillDef } from '../src/game/skills.ts' import { QuestLog, npcDialog, npcsFromTable, questsFromTable } from '../src/game/quests.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' const isSkip = false && !fs.existsSync('samples/d2'); const _results: any[] = []; let suiteCompleted = false; diff --git a/tests/m5.test.ts b/tests/m5.test.ts index ea35a79..fc67386 100644 --- a/tests/m5.test.ts +++ b/tests/m5.test.ts @@ -21,7 +21,7 @@ import { Inventory, goldItem, createItem } from '../src/game/items.ts' import type { Affix, ItemBase } from '../src/game/items.ts' import { QuestLog } from '../src/game/quests.ts' import type { QuestDef } from '../src/game/quests.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { captureSnapshot, createD2s, d2sChecksum, parseSnapshot, readD2s, restoreSnapshot, serializeSnapshot, writeD2s, } from '../src/game/save.ts' @@ -168,7 +168,7 @@ function digest(game: Game): string { return JSON.stringify({ tick: game.world.tick, kills: game.world.kills, - rng: game.rng.seed, + rng: [game.rng.seed, game.rng.hi], player: { x: round(game.world.player.x), y: round(game.world.player.y), hp: game.world.player.hp, mana: game.world.player.mana, xp: game.world.player.xp, @@ -189,7 +189,7 @@ const snapshot = captureSnapshot(original) const text = serializeSnapshot(snapshot) const parsed = parseSnapshot(text) expect(parsed.version === snapshot.version, 'a serialized snapshot parses back') -expect(parsed.rngState === original.rng.seed, 'the random stream position is saved') +expect(parsed.rngState === original.rng.seed && parsed.rngHighState === original.rng.hi, 'the random stream position is saved') expect(parsed.inventory.placed.length === original.inventory.contents.length, 'every carried item is saved') expect(parsed.quests[0]?.kills === original.quests.all[0]?.kills, 'quest progress is saved') expect(parsed.ground.length === original.ground.length, 'items on the ground are saved') @@ -206,7 +206,7 @@ const restoredGame: Game = { events: [], monsters: restored.world.monsters.map(monster => ({ ...monster })), }, - rng: new Rng(restored.rngState), + rng: new Rng(restored.rngState, restored.rngHighState), inventory: restored.inventory, quests: restored.quests, } diff --git a/tests/maze-113c.test.ts b/tests/maze-113c.test.ts deleted file mode 100644 index 708fd2b..0000000 --- a/tests/maze-113c.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ACT5_NIHLATHAK_WINGS, - ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING, - LVLPREST_ACT2_TOMB_TAINTED_SUN_X, - LVLPREST_ACT3_SEWER_NE, - LVLPREST_ACT3_SEWER_NW, - LVLPREST_ACT3_SEWER_SE, - LVLPREST_ACT3_SEWER_SW, - LVLPREST_ACT5_ICE_POOL_A, - LVLPREST_ACT5_ICE_POOL_B, - LVLPREST_ACT5_ICE_RIVER_A, - LVLPREST_ACT5_ICE_RIVER_B, - LVLPREST_ACT5_TEMPLE_FINAL_ROOM, - MAZE_DOOR_EAST, - MAZE_DOOR_NORTH, - MAZE_DOOR_SOUTH, - MAZE_DOOR_WEST, - mazeDoorMask, - mazePresetDefIdForDoorMask, -} from '../src/game/maze-special-passes.ts' -import { - DIRECTION_EAST, - DIRECTION_NORTH, - DIRECTION_SOUTH, - DIRECTION_WEST, - classifyMazePieceName, - generateMaze, -} from '../src/game/maze.ts' -import type { MazePiece, MazePieceKind, Room } from '../src/game/maze.ts' -import { Rng } from '../src/game/rng.ts' -import type { Ds1 } from '../src/formats/ds1.ts' - -function mockDs1(width = 10, height = 10): Ds1 { - const cells = Array.from({ length: height }, () => - Array.from({ length: width }, () => ({ - floors: [{ prop1: 1, style: 1, sequence: 0, hidden: false, unknown1: 0, unknown2: 0 }], - walls: [], - shadows: [], - substitutions: [], - })), - ) - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -function mockPiece(name: string, kind: MazePieceKind, sides = ''): MazePiece { - return { name, kind, sides, levels: [mockDs1()] } -} - -function createStandardPieces(prefix: string, extra: MazePiece[] = []): MazePiece[] { - const SIDES = ['N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW', 'NS', 'EW', 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW'] - return [ - mockPiece(`${prefix} Entrance`, 'entrance', ''), - ...SIDES.map(s => mockPiece(`${prefix} ${s}`, 'room', s)), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Prev ${s}`, 'prev', s)), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Next ${s}`, 'next', s)), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Down ${s}`, 'down', s)), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${prefix} Theme ${s}`, 'theme', s)), - ...extra, - ] -} - -function makeRoom(id: number, orthDirs: number[]): Room { - const orths = orthDirs.map(direction => ({ - room: { id: 900 + direction, x: 0, y: 0, width: 8, height: 8, orths: [], piece: null, variant: 0, hasDs1: false, rng: new Rng(1) }, - direction, - })) - return { - id, - x: 0, - y: 0, - width: 8, - height: 8, - orths, - piece: null, - variant: 0, - hasDs1: false, - rng: new Rng(100 + id), - } -} - -describe('Blizzard D2 1.13c Maze LvlPrest Def ID & Special Layout Passes (Issue #73)', () => { - it('computes 1.13c 4-bit door masks (W=1, E=2, S=4, N=8) and LvlPrest Def ID offsets', () => { - expect(MAZE_DOOR_WEST).toBe(1) - expect(MAZE_DOOR_EAST).toBe(2) - expect(MAZE_DOOR_SOUTH).toBe(4) - expect(MAZE_DOOR_NORTH).toBe(8) - - const westOnly = makeRoom(1, [DIRECTION_WEST]) - expect(mazeDoorMask(westOnly)).toBe(1) - expect(mazePresetDefIdForDoorMask(39, westOnly)).toBe(39) - - const allFour = makeRoom(2, [DIRECTION_WEST, DIRECTION_EAST, DIRECTION_SOUTH, DIRECTION_NORTH]) - expect(mazeDoorMask(allFour)).toBe(15) - expect(mazePresetDefIdForDoorMask(39, allFour)).toBe(53) - }) - - it('places Claw Viper Temple Level 2 fixed Tainted Sun Altar chamber (Def 633)', () => { - const pieces = createStandardPieces('Act 2 - Tomb', [ - mockPiece('Act 2 - Tomb Tainted Sun X', 'quest', ''), - ]) - const result = generateMaze({ - levelId: 61, - levelName: 'Act 2 - Tomb', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 4242, - pieces, - }) - - const notes = result.stats.notes as string[] - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(notes.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT2_TOMB_TAINTED_SUN_X)}`))).toBe(true) - expect(notes.some(n => n.includes('Act 2 - Tomb Tainted Sun X'))).toBe(true) - expect(specials.some(r => r.kind === 'quest')).toBe(true) - }) - - it('places Kurast Sewers Level 1 (levelId 92) 4 corner entrance rooms (Def 585..588) and Drain lever', () => { - const pieces = createStandardPieces('Act 3 - Sewer', [ - mockPiece('Act 3 - Sewer SW', 'prev', 'S'), - mockPiece('Act 3 - Sewer SE', 'prev', 'E'), - mockPiece('Act 3 - Sewer NW', 'prev', 'W'), - mockPiece('Act 3 - Sewer NE', 'prev', 'N'), - ...['N', 'E', 'S', 'W'].map(s => mockPiece(`Act 3 - Sewer Drain ${s}`, 'next', s)), - ...['N', 'E', 'S', 'W'].map(s => mockPiece(`Act 3 - Sewer Chest ${s}`, 'treasure', s)), - ]) - const result = generateMaze({ - levelId: 92, - levelName: 'Act 3 - Sewer', - sectionSize: 10, - minRooms: 10, - merge: 0, - seed: 7777, - pieces, - }) - - const notes = result.stats.notes as string[] - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - for (const defId of [LVLPREST_ACT3_SEWER_SW, LVLPREST_ACT3_SEWER_SE, LVLPREST_ACT3_SEWER_NW, LVLPREST_ACT3_SEWER_NE]) { - expect(notes.some(n => n.includes(`lvlPrestDef=${String(defId)}`))).toBe(true) - } - expect(notes.some(n => n.includes('Act 3 - Sewer Drain'))).toBe(true) - expect(specials.some(r => r.kind === 'next')).toBe(true) - }) - - it('rolls Frozen River A/B (Def 1030/1031) and places Ice Pools (Def 1032/1033) in Act 5 Ice Caves', () => { - const pieces = createStandardPieces('Act 5 - Ice Caves', [ - mockPiece('Act 5 - Ice River A', 'quest', ''), - mockPiece('Act 5 - Ice River B', 'quest', ''), - mockPiece('Act 5 - Ice Pool A', 'quest', ''), - mockPiece('Act 5 - Ice Pool B', 'quest', ''), - ]) - - const chosenDefs = new Set() - for (let seed = 1; seed <= 12; seed += 1) { - const res = generateMaze({ - levelId: 114, - levelName: 'Act 5 - Ice Caves', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: seed * 313, - pieces, - }) - const notes = res.stats.notes as string[] - if (notes.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_RIVER_A)}`))) { - chosenDefs.add(LVLPREST_ACT5_ICE_RIVER_A) - } - if (notes.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_RIVER_B)}`))) { - chosenDefs.add(LVLPREST_ACT5_ICE_RIVER_B) - } - } - expect(chosenDefs.has(LVLPREST_ACT5_ICE_RIVER_A)).toBe(true) - expect(chosenDefs.has(LVLPREST_ACT5_ICE_RIVER_B)).toBe(true) - - const res116 = generateMaze({ - levelId: 116, - levelName: 'Act 5 - Ice Caves', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 100, - pieces, - }) - expect((res116.stats.notes as string[]).some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_A)}`))).toBe(true) - - const res119 = generateMaze({ - levelId: 119, - levelName: 'Act 5 - Ice Caves', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 100, - pieces, - }) - expect((res119.stats.notes as string[]).some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_ICE_POOL_B)}`))).toBe(true) - }) - - it('applies Act 5 Temple hardcoded remapping (Def 1044..1047) and rolls Nihlathak active wing (Def 1060..1063)', () => { - expect(ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING).toEqual(['SW', 'SE', 'NW', 'NE']) - expect(ACT5_NIHLATHAK_WINGS).toEqual(['NW', 'NE', 'SE', 'SW']) - - const pieces = createStandardPieces('Act 5 - Temple', [ - mockPiece('Act 5 - Temple Final Room', 'quest', ''), - mockPiece('Act 5 - Temple NE Down', 'down', 'NE'), - mockPiece('Act 5 - Temple NW Down', 'down', 'NW'), - mockPiece('Act 5 - Temple SW Down', 'down', 'SW'), - mockPiece('Act 5 - Temple SE up', 'prev', 'SE'), - ]) - - const res124 = generateMaze({ - levelId: 124, - levelName: 'Act 5 - Temple', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 8888, - pieces, - }) - const notes124 = res124.stats.notes as string[] - expect(notes124.some(n => n.includes(`lvlPrestDef=${String(LVLPREST_ACT5_TEMPLE_FINAL_ROOM)}`))).toBe(true) - expect(notes124.some(n => n.includes('nihlathakWing='))).toBe(true) - }) - - it('correctly classifies Act 4 - Lava X and standard directional rooms', () => { - expect(classifyMazePieceName('Act 4 - Lava X', 'Act 4 - Lava')).toEqual({ kind: 'room', sides: '' }) - expect(classifyMazePieceName('Act 4 - Lava W', 'Act 4 - Lava')).toEqual({ kind: 'room', sides: 'W' }) - expect(classifyMazePieceName('Act 4 - Lava NSEW', 'Act 4 - Lava')).toEqual({ kind: 'room', sides: 'NSEW' }) - }) -}) diff --git a/tests/maze-bounds.test.ts b/tests/maze-bounds.test.ts deleted file mode 100644 index 3a09970..0000000 --- a/tests/maze-bounds.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, test } from 'vitest' -import type { Ds1, Ds1Cell } from '../src/formats/ds1' -import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze' - -function makeMockDs1(width = 10, height = 10): 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: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 0, 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 makeMockPiece(name: string, kind: MazePieceKind, sides: string): MazePiece { - return { - name, - kind, - sides, - levels: [makeMockDs1(10, 10)], - } -} - -function makeCavePieceSet(): MazePiece[] { - const ds1 = makeMockDs1(10, 10) - const tokens = [ - 'NSEW', - 'NSE', 'NSW', 'NEW', 'SEW', - 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', - 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = tokens.map(token => ({ - name: `Act 1 - Cave ${token}`, - kind: 'room', - sides: token, - levels: [ds1], - })) - pieces.push({ - name: 'Act 1 - Cave Prev NS', - kind: 'prev', - sides: 'NS', - levels: [ds1], - }) - pieces.push({ - name: 'Act 1 - Cave Prev EW', - kind: 'prev', - sides: 'EW', - levels: [ds1], - }) - pieces.push({ - name: 'Act 1 - Cave Down NS', - kind: 'down', - sides: 'NS', - levels: [ds1], - }) - pieces.push({ - name: 'Act 1 - Cave Down EW', - kind: 'down', - sides: 'EW', - levels: [ds1], - }) - return pieces -} - -describe('Maze Global Bounding Box Constraints (Issue #48)', () => { - test('generateMaze respects maxSectionsX and maxSectionsY limits', () => { - const pieces = makeCavePieceSet() - const request: MazeRequest = { - levelId: 2, - levelName: 'Act 1 - Cave 1', - levelTypeName: 'Act 1 - Cave', - sectionSize: 10, - sectionHeight: 10, - minRooms: 9, - merge: 0, - seed: 12345, - pieces, - maxSectionsX: 3, - maxSectionsY: 3, - } - - const result = generateMaze(request) - expect(result.level.width).toBeLessThanOrEqual(30) - expect(result.level.height).toBeLessThanOrEqual(30) - expect(result.stats.maxCellsX).toBe(30) - expect(result.stats.maxCellsY).toBe(30) - expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9) - }) - - test('generateMaze respects maxCellsX and maxCellsY directly', () => { - const pieces = makeCavePieceSet() - const request: MazeRequest = { - levelId: 2, - levelName: 'Act 1 - Cave 1', - levelTypeName: 'Act 1 - Cave', - sectionSize: 10, - sectionHeight: 10, - minRooms: 8, - merge: 500, - seed: 99999, - pieces, - maxCellsX: 30, - maxCellsY: 30, - } - - const result = generateMaze(request) - expect(result.level.width).toBeLessThanOrEqual(30) - expect(result.level.height).toBeLessThanOrEqual(30) - expect(result.stats.maxCellsX).toBe(30) - expect(result.stats.maxCellsY).toBe(30) - }) - - test('forces maze to fold vertically when maxSectionsX is narrow (e.g. 2 sections wide)', () => { - const pieces = makeCavePieceSet() - const request: MazeRequest = { - levelId: 2, - levelName: 'Act 1 - Cave 1', - levelTypeName: 'Act 1 - Cave', - sectionSize: 10, - sectionHeight: 10, - minRooms: 8, - merge: 0, - seed: 42, - pieces, - maxSectionsX: 2, - maxSectionsY: 6, - } - - const result = generateMaze(request) - expect(result.level.width).toBeLessThanOrEqual(20) - expect(result.level.height).toBeLessThanOrEqual(60) - expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(8) - expect(Number(result.stats.boundsRejected)).toBeGreaterThanOrEqual(1) - }) - - test('records boundsRejected when room growth attempts cross the configured boundary', () => { - const pieces = makeCavePieceSet() - const request: MazeRequest = { - levelId: 2, - levelName: 'Act 1 - Cave 1', - levelTypeName: 'Act 1 - Cave', - sectionSize: 10, - sectionHeight: 10, - minRooms: 9, - merge: 800, - seed: 54321, - pieces, - maxSectionsX: 3, - maxSectionsY: 3, - } - - const result = generateMaze(request) - expect(result.stats.boundsRejected).toBeDefined() - expect(typeof result.stats.boundsRejected).toBe('number') - expect(result.level.width).toBeLessThanOrEqual(30) - expect(result.level.height).toBeLessThanOrEqual(30) - }) - - test('unconstrained maze allows wide sprawl when no bounds are specified', () => { - const pieces = makeCavePieceSet() - const request: MazeRequest = { - levelId: 2, - levelName: 'Act 1 - Cave 1', - levelTypeName: 'Act 1 - Cave', - sectionSize: 10, - sectionHeight: 10, - minRooms: 9, - merge: 0, - seed: 12345, - pieces, - } - - const result = generateMaze(request) - expect(result.stats.maxCellsX).toBeUndefined() - expect(result.stats.maxCellsY).toBeUndefined() - expect(result.stats.boundsRejected).toBe(0) - expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9) - }) -}) diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts deleted file mode 100644 index d370205..0000000 --- a/tests/maze-objects.test.ts +++ /dev/null @@ -1,643 +0,0 @@ -import { describe, expect, test } from 'vitest' -import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' -import { - populateDungeonObjects, - isOpenFloor, - CANONICAL_SHRINES, - CHEST_IDS_BY_ACT, - RACK_IDS_BY_ACT, - CONTAINER_IDS_BY_ACT, - resolveObjectsTxtId, - validateDungeonObjectPools, - type MazeRoomDescriptor, - type PlacedMazeObject, - type WallAlignment, -} from '../src/game/maze-objects.ts' -import { - generateMaze, - discoverMazePieceFamilies, - classifyMazePieceName, - type MazePiece, - type MazeRequest, -} from '../src/game/maze.ts' -import type { D2Table } from '../src/game/acts.ts' -import { Rng } from '../src/game/rng.ts' - -/** - * Creates a mock DS1 room with perimeter walls and an open interior. - * Optionally adds a doorway gap on specified sides ('N' | 'S' | 'E' | 'W'). - */ -function makeMockDs1( - width: number, - height: number, - doorways: readonly ('N' | 'S' | 'E' | 'W')[] = [], - staticObjects: readonly Ds1Object[] = [], -): Ds1 { - const cells: Ds1Cell[][] = [] - const midX = Math.floor(width / 2) - const midY = Math.floor(height / 2) - - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - const isNorthWall = y === 0 - const isSouthWall = y === height - 1 - const isWestWall = x === 0 - const isEastWall = x === width - 1 - let isWall = isNorthWall || isSouthWall || isWestWall || isEastWall - - // Open doorways in the middle of perimeter walls - if (isNorthWall && x === midX && doorways.includes('N')) isWall = false - if (isSouthWall && x === midX && doorways.includes('S')) isWall = false - if (isWestWall && y === midY && doorways.includes('W')) isWall = false - if (isEastWall && y === midY && doorways.includes('E')) isWall = false - - row.push({ - walls: isWall - ? [{ prop1: 1, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] - : [], - floors: [{ prop1: 1, 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: [...staticObjects], - npcPathOffset: null, - } -} - -/** - * Builds a suite of mock maze pieces for `generateMaze`. - */ -function makeTestMazePieces( - levelTypeName = 'Act 1 - Cave', - size = 11, - staticObjects: readonly Ds1Object[] = [], -): MazePiece[] { - const allSides = [ - 'N', 'S', 'E', 'W', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', - 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW', - ] - - const pieces: MazePiece[] = allSides.map(sides => ({ - name: `${levelTypeName} ${sides}`, - kind: 'room', - sides, - levels: [makeMockDs1(size, size, [], staticObjects)], - })) - - // Entrance piece - pieces.push({ - name: `${levelTypeName} Entrance`, - kind: 'entrance', - sides: '', - levels: [makeMockDs1(size, size, [], staticObjects)], - }) - - // Directional special pieces - for (const s of ['N', 'S', 'E', 'W']) { - pieces.push({ name: `${levelTypeName} Prev ${s}`, kind: 'prev', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) - pieces.push({ name: `${levelTypeName} Down ${s}`, kind: 'down', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) - pieces.push({ name: `${levelTypeName} Next ${s}`, kind: 'next', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) - pieces.push({ name: `${levelTypeName} Quest ${s}`, kind: 'quest', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) - pieces.push({ name: `${levelTypeName} Theme ${s}`, kind: 'theme', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) - } - - return pieces -} - -/** - * Creates a synthetic multi-room dungeon layout for fine-grained spot tests. - */ -function createSyntheticDungeon(): { - level: Ds1 - rooms: MazeRoomDescriptor[] -} { - // 3 rooms laid out horizontally: - // Room 0: dead-end (10x10 at [0, 0]), orths to Room 1 (east) - // Room 1: corridor (10x10 at [10, 0]), orths to Room 0 (west) and Room 2 (east) - // Room 2: dead-end (10x10 at [20, 0]), orths to Room 1 (west) - const width = 30 - const height = 10 - const cells: Ds1Cell[][] = [] - - for (let y = 0; y < height; y += 1) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x += 1) { - // Outer perimeter is wall, plus vertical dividers at x=10 and x=20 - const isOuterWall = y === 0 || y === height - 1 || x === 0 || x === width - 1 - const isDivider = (x === 10 || x === 20) && y !== 5 // doorway at y=5 - const isWall = isOuterWall || isDivider - - row.push({ - walls: isWall - ? [{ prop1: 1, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] - : [], - floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - - const level: Ds1 = { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } - - const room0: MazeRoomDescriptor = { - id: 0, - x: 0, - y: 0, - width: 10, - height: 10, - orths: [{ room: 1, direction: 1 /* East */ }], - } - - const room1: MazeRoomDescriptor = { - id: 1, - x: 10, - y: 0, - width: 10, - height: 10, - orths: [ - { room: 0, direction: 3 /* West */ }, - { room: 2, direction: 1 /* East */ }, - ], - } - - const room2: MazeRoomDescriptor = { - id: 2, - x: 20, - y: 0, - width: 10, - height: 10, - orths: [{ room: 1, direction: 3 /* West */ }], - } - - return { level, rooms: [room0, room1, room2] } -} - -describe('populateDungeonObjects pipeline', () => { - test('spawns shrines and super chests in dead-end rooms', () => { - const { level, rooms } = createSyntheticDungeon() - const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - density: 1.0, - objectSeed: 12345, - }) - - const shrines = result.placedObjects.filter(o => o.category === 'shrine') - const chests = result.placedObjects.filter(o => o.category === 'chest') - const superChests = chests.filter(o => o.subType === 'super_chest') - - expect(shrines.length).toBeGreaterThanOrEqual(1) - expect(superChests.length).toBeGreaterThanOrEqual(1) - - // Shrines in dead-ends must have isDeadEnd: true - for (const shrine of shrines) { - expect(shrine.type).toBe(2) - expect(shrine.category).toBe('shrine') - // Valid shrine subtype - const validSubtypes = CANONICAL_SHRINES.map(s => s.subType) - expect(validSubtypes).toContain(shrine.subType) - if (shrine.roomId === 0 || shrine.roomId === 2) { - expect(shrine.isDeadEnd).toBe(true) - } - } - - // Super chests placed in dead-end rooms - for (const chest of superChests) { - expect(chest.type).toBe(2) - expect(chest.category).toBe('chest') - expect(chest.isDeadEnd).toBe(true) - expect(CHEST_IDS_BY_ACT[1]!.super).toContain(chest.id) - } - }) - - test('aligns weapon and armor racks strictly against perimeter walls', () => { - const { level, rooms } = createSyntheticDungeon() - const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Barracks', 28, { - density: 1.0, - rackDensity: 2.0, // encourage rack spawning - objectSeed: 42, - }) - - const racks = result.placedObjects.filter(o => o.category === 'rack') - expect(racks.length).toBeGreaterThan(0) - - for (const rack of racks) { - expect(['armor_rack', 'weapon_rack']).toContain(rack.subType) - expect(['north', 'south', 'east', 'west']).toContain(rack.alignedWall) - - // Cell coordinates from subtiles (5 subtiles per tile) - const cellX = Math.floor(rack.x / 5) - const cellY = Math.floor(rack.y / 5) - - // The rack itself must stand on an open floor cell - const rackCell = level.cells[cellY]?.[cellX] - expect(rackCell).toBeDefined() - expect(rackCell!.floors.length).toBeGreaterThan(0) - expect(rackCell!.walls.length).toBe(0) - - // Neighbor in the direction of alignedWall must be a solid wall - let wallNeighbor: Ds1Cell | undefined - if (rack.alignedWall === 'north') wallNeighbor = level.cells[cellY - 1]?.[cellX] - else if (rack.alignedWall === 'south') wallNeighbor = level.cells[cellY + 1]?.[cellX] - else if (rack.alignedWall === 'west') wallNeighbor = level.cells[cellY]?.[cellX - 1] - else if (rack.alignedWall === 'east') wallNeighbor = level.cells[cellY]?.[cellX + 1] - - expect(wallNeighbor).toBeDefined() - expect(wallNeighbor!.walls.length).toBeGreaterThan(0) - } - }) - - test('strictly prefers corridor rooms (degree === 2) over dead-ends for rack candidates', () => { - const { level, rooms } = createSyntheticDungeon() - // Room 0 is dead-end ([0, 0]), Room 1 is corridor ([10, 0]), Room 2 is dead-end ([20, 0]) - // Using non-barracks level type so isBarracks is false - const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - shrineDensity: 0, - chestDensity: 0, - containerDensity: 0, - rackDensity: 2.0, - objectSeed: 123, - }) - const racks = result.placedObjects.filter(o => o.category === 'rack') - expect(racks.length).toBeGreaterThan(0) - for (const rack of racks) { - // Must be placed in corridor room (Room 1, cellX in [10, 19]) - const cellX = Math.floor(rack.x / 5) - expect(cellX).toBeGreaterThanOrEqual(10) - expect(cellX).toBeLessThan(20) - } - }) - - test('scales container spawning proportionally with containerDensity', () => { - const { level, rooms } = createSyntheticDungeon() - - const runZero = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - containerDensity: 0, - objectSeed: 100, - }) - const containersZero = runZero.placedObjects.filter(o => o.category === 'container') - expect(containersZero.length).toBe(0) - expect(runZero.stats.containersSpawned).toBe(0) - - const runHalf = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - containerDensity: 0.5, - objectSeed: 100, - }) - const containersHalf = runHalf.placedObjects.filter(o => o.category === 'container') - - const runNormal = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - containerDensity: 1.0, - objectSeed: 100, - }) - const containersNormal = runNormal.placedObjects.filter(o => o.category === 'container') - - const runDouble = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - containerDensity: 2.0, - objectSeed: 100, - }) - const containersDouble = runDouble.placedObjects.filter(o => o.category === 'container') - - expect(containersHalf.length).toBeLessThan(containersNormal.length) - expect(containersNormal.length).toBeLessThan(containersDouble.length) - }) - - test('guarantees deterministic object seeding given identical seeds', () => { - const { level, rooms } = createSyntheticDungeon() - - const seed = 987654321 - const runA = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, { - density: 1.0, - objectSeed: seed, - }) - const runB = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, { - density: 1.0, - objectSeed: seed, - }) - - expect(runA.placedObjects.length).toBe(runB.placedObjects.length) - expect(runA.stats).toEqual(runB.stats) - - for (let i = 0; i < runA.placedObjects.length; i += 1) { - const objA = runA.placedObjects[i]! - const objB = runB.placedObjects[i]! - expect(objA).toEqual(objB) - } - - // Different seed should diverge - const runC = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, { - density: 1.0, - objectSeed: 11111, - }) - const positionsA = runA.placedObjects.map(o => `${o.x},${o.y},${o.id}`).join(';') - const positionsC = runC.placedObjects.map(o => `${o.x},${o.y},${o.id}`).join(';') - expect(positionsA).not.toBe(positionsC) - }) - - test('supports authentic object IDs across different Acts', () => { - const { level, rooms } = createSyntheticDungeon() - - // Act 2 Tomb - const act2Result = populateDungeonObjects(rooms, 0, 0, level, 'Act 2 - Tomb', 66, { - density: 1.0, - objectSeed: 777, - }) - const act2Chests = act2Result.placedObjects.filter(o => o.category === 'chest') - expect(act2Chests.length).toBeGreaterThan(0) - for (const c of act2Chests) { - const allAct2Chests = [...CHEST_IDS_BY_ACT[2]!.super, ...CHEST_IDS_BY_ACT[2]!.normal] - expect(allAct2Chests).toContain(c.id) - } - - // Act 3 Kurast - const act3Result = populateDungeonObjects(rooms, 0, 0, level, 'Act 3 - Kurast', 80, { - density: 1.0, - objectSeed: 888, - }) - const act3Containers = act3Result.placedObjects.filter(o => o.category === 'container') - expect(act3Containers.length).toBeGreaterThan(0) - const act3Pool = CONTAINER_IDS_BY_ACT[3]! - const allAct3Containers = [ - ...act3Pool.barrels, - ...act3Pool.urns, - ...act3Pool.caskets, - ...act3Pool.crates, - ] - for (const cnt of act3Containers) { - expect(allAct3Containers).toContain(cnt.id) - } - }) - - test('respects category-specific density and disableDynamicObjects options', () => { - const { level, rooms } = createSyntheticDungeon() - - // Disable all dynamic objects - const disabledRun = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { - disableDynamicObjects: true, - }) - expect(disabledRun.placedObjects.length).toBe(0) - expect(disabledRun.stats.dynamicObjectsCount).toBe(0) - - // Suppress shrines and chests, but keep racks and containers - const racksOnly = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Barracks', 28, { - shrineDensity: 0, - chestDensity: 0, - rackDensity: 2.0, - containerDensity: 0, - objectSeed: 123, - }) - expect(racksOnly.stats.shrinesSpawned).toBe(0) - expect(racksOnly.stats.chestsSpawned).toBe(0) - expect(racksOnly.stats.containersSpawned).toBe(0) - expect(racksOnly.stats.racksSpawned).toBeGreaterThan(0) - expect(racksOnly.placedObjects.every(o => o.category === 'rack')).toBe(true) - }) -}) - -describe('generateMaze end-to-end dynamic object integration', () => { - test('populates result.objects and result.level.objects during maze generation', () => { - const pieces = makeTestMazePieces('Act 1 - Cave', 11) - const request: MazeRequest = { - levelId: 2, - levelName: 'Cave Level 1', - sectionSize: 10, - minRooms: 6, - merge: 0, - seed: 12345, - pieces, - } - - const result = generateMaze(request) - - expect(result.level).toBeDefined() - expect(result.objects).toBeDefined() - expect(result.objects.length).toBeGreaterThan(0) - - // result.stats must record dynamic object counters - expect(result.stats.dynamicObjectsCount).toBe(result.objects.length) - expect(result.stats.objects).toBe(result.level.objects.length) - expect(result.stats.dynamicObjects).toBe(result.objects) - - // Subtypes counts in stats must match objects in result.objects - const shrineCount = result.objects.filter(o => o.category === 'shrine').length - const chestCount = result.objects.filter(o => o.category === 'chest').length - const rackCount = result.objects.filter(o => o.category === 'rack').length - const containerCount = result.objects.filter(o => o.category === 'container').length - - expect(result.stats.shrinesSpawned).toBe(shrineCount) - expect(result.stats.chestsSpawned).toBe(chestCount) - expect(result.stats.racksSpawned).toBe(rackCount) - expect(result.stats.containersSpawned).toBe(containerCount) - - // Level objects array must contain all dynamic objects - for (const dynObj of result.objects) { - expect(result.level.objects).toContain(dynObj) - } - }) - - test('disables dynamic objects when objectDensity: 0 is specified', () => { - const pieces = makeTestMazePieces('Act 1 - Cave', 11) - const request: MazeRequest = { - levelId: 2, - levelName: 'Cave Level 1', - sectionSize: 10, - minRooms: 5, - merge: 0, - seed: 54321, - pieces, - objectDensity: 0, - } - - const result = generateMaze(request) - expect(result.objects.length).toBe(0) - expect(result.stats.dynamicObjectsCount).toBe(0) - expect(result.level.objects.length).toBe(0) - }) - - test('retains pre-existing static DS1 objects alongside dynamically spawned objects', () => { - // Piece with 1 pre-existing static object (e.g. static torch or decoration) - const staticObject: Ds1Object = { - type: 2, - id: 99, - x: 25, - y: 25, - flags: 0, - } - const pieces = makeTestMazePieces('Act 1 - Cave', 11, [staticObject]) - - const request: MazeRequest = { - levelId: 2, - levelName: 'Cave Level 1', - sectionSize: 10, - minRooms: 5, - merge: 0, - seed: 9999, - pieces, - } - - const result = generateMaze(request) - const dynamicCount = result.objects.length - expect(dynamicCount).toBeGreaterThan(0) - expect(result.level.objects.length).toBeGreaterThan(dynamicCount) - - // Pre-existing static objects are preserved - const foundStatic = result.level.objects.some(o => o.id === 99) - expect(foundStatic).toBe(true) - }) -}) - -/** - * Regression cover for the `|| true` that used to close `isOpenFloor`'s content - * test (issue #58 item 2). With it, every `Ds1Floor` entry counted as floor art, - * so objects were seeded on cells the level never paints. - */ -describe('isOpenFloor content check', () => { - /** One cell grid with the given floor entries and no walls. */ - function grid(floors: Ds1Cell['floors']): Ds1Cell[][] { - return [[{ walls: [], floors, shadows: [], substitutions: [] }]] - } - - const painted = { prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } - const empty = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } - - test('a painted floor cell is open floor', () => { - expect(isOpenFloor(grid([painted]), 0, 0)).toBe(true) - }) - - test('a cell whose only floor entry is all-zero is not open floor', () => { - expect(isOpenFloor(grid([empty]), 0, 0)).toBe(false) - }) - - test('style or sequence alone still counts as art', () => { - expect(isOpenFloor(grid([{ ...empty, style: 3 }]), 0, 0)).toBe(true) - expect(isOpenFloor(grid([{ ...empty, sequence: 7 }]), 0, 0)).toBe(true) - }) - - test('a hidden painted floor does not rescue an otherwise empty cell', () => { - expect(isOpenFloor(grid([{ ...painted, hidden: true }]), 0, 0)).toBe(false) - }) - - test('a multi-layer cell is open floor when any layer carries art', () => { - expect(isOpenFloor(grid([empty, painted]), 0, 0)).toBe(true) - expect(isOpenFloor(grid([empty, empty]), 0, 0)).toBe(false) - }) - - test('objects are not seeded into a level whose floors are all empty slots', () => { - const { level, rooms } = createSyntheticDungeon() - for (const row of level.cells) { - for (const cell of row) { - ;(cell.floors as Ds1Cell['floors'][number][]).splice( - 0, - cell.floors.length, - { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }, - ) - } - } - const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { density: 1.0 }, new Rng(7)) - expect(result.placedObjects.length).toBe(0) - }) -}) - -describe('Issue #61 Step 4: Objects.txt mapping validation and LvlPrest piece families', () => { - describe('validateDungeonObjectPools', () => { - test('validates all canonical dungeon object pools against embedded data without errors', () => { - const result = validateDungeonObjectPools() - expect(result.valid).toBe(true) - expect(result.errors).toEqual([]) - expect(result.totalChecked).toBe(223) - }) - - test('detects invalid or missing object IDs when an incomplete objectsTable is provided', () => { - const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1'], ['2']] } - const result = validateDungeonObjectPools(mockObjectsTable) - expect(result.valid).toBe(false) - expect(result.errors.length).toBeGreaterThan(0) - expect(result.errors[0]).toContain('missing in Objects.txt') - }) - }) - - describe('resolveObjectsTxtId', () => { - test('resolves known DS1 Type 2 IDs to Objects.txt row IDs across Acts', () => { - expect(resolveObjectsTxtId(1, 2)).toBe(39) // Refilling Shrine -> Objects.txt ID 39 - expect(resolveObjectsTxtId(1, 5)).toBe(5) // Chest C1 -> Objects.txt ID 5 - expect(resolveObjectsTxtId(1, 99999)).toBeNull() - }) - - test('resolves with explicit objectsTable filter', () => { - const mockObjectsTable: D2Table = { header: ['Id'], rows: [['39'], ['140']] } - expect(resolveObjectsTxtId(1, 2, mockObjectsTable)).toBe(39) - expect(resolveObjectsTxtId(1, 6, mockObjectsTable)).toBeNull() - }) - }) - - describe('discoverMazePieceFamilies', () => { - test('returns static families when lvlPrestTable is omitted', () => { - expect(discoverMazePieceFamilies('Act 2 - Harem')).toEqual(['Act 2 - Corrupt Harem', 'Act 2 - Harem']) - expect(discoverMazePieceFamilies('Act 3 - Kurast')).toEqual(['Act 3 - Mephisto', 'Act 3 - Kurast']) - expect(discoverMazePieceFamilies('Act 3 - Dungeon')).toEqual(['Act 3 - Dungeon', 'Act 3 - Temple']) - expect(discoverMazePieceFamilies('Act 4 - Lava')).toEqual(['Act 4 - Lava', 'Act 4 - Bridge']) - expect(discoverMazePieceFamilies('Act 5 - Ice Caves')).toEqual(['Act 5 - Ice Caves', 'Act 5 - Ice']) - expect(discoverMazePieceFamilies('Act 5 - Baal')).toEqual(['Act 5 - Baal', 'Act 5 - ThroneRoom']) - expect(discoverMazePieceFamilies('Unknown Theme')).toEqual(['Unknown Theme']) - }) - - test('filters to families present in lvlPrestTable when provided', () => { - const mockLvlPrest: D2Table = { - header: ['Def', 'Name'], - rows: [ - ['100', 'Act 2 - Harem NSEW'], - ['101', 'Act 2 - Harem N'], - ], - } - expect(discoverMazePieceFamilies('Act 2 - Harem', mockLvlPrest)).toEqual(['Act 2 - Harem']) - }) - - test('falls back to static families when table has no matching rows', () => { - const emptyLvlPrest: D2Table = { header: ['Def', 'Name'], rows: [] } - expect(discoverMazePieceFamilies('Act 2 - Harem', emptyLvlPrest)).toEqual([ - 'Act 2 - Corrupt Harem', - 'Act 2 - Harem', - ]) - }) - }) - - describe('classifyMazePieceName with optional lvlPrestTable', () => { - test('classifies piece names with and without lvlPrestTable', () => { - const withoutTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem') - expect(withoutTable).toEqual({ kind: 'room', sides: 'NSEW' }) - - const mockLvlPrest: D2Table = { - header: ['Def', 'Name'], - rows: [['100', 'Act 2 - Harem NSEW']], - } - const withTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem', mockLvlPrest) - expect(withTable).toEqual({ kind: 'room', sides: 'NSEW' }) - }) - }) -}) - - diff --git a/tests/maze-special-passes.test.ts b/tests/maze-special-passes.test.ts deleted file mode 100644 index cc0bcab..0000000 --- a/tests/maze-special-passes.test.ts +++ /dev/null @@ -1,878 +0,0 @@ -/** - * Unit tests for the 11 Diablo II DRLG Maze Special Passes. - * - * Covers: - * 1. DRLGMAZE_PlaceAct2TombStuff (Tal Rasha True/False Tombs, Kaa, Cube, Leatherarm, Waypoint) - * 2. DRLGMAZE_PlaceAct2LairStuff (Maggot Lair Coldworm Tight Spot & Staff of Kings Treasure) - * 3. DRLGMAZE_PlaceAct3DungeonStuff (Flayer Dungeon Brain chest, Kurast Temples Lam Esen's Tome) - * 4. DRLGMAZE_PlaceAct3SewerStuff (Kurast Sewers Drain lever descent & Chest) - * 5. DRLGMAZE_PlaceAct3MephistoStuff (Durance of Hate 3 Mephisto Complex, Durance 2 Waypoint) - * 6. DRLGMAZE_PlaceAct5TempleStuff (Halls of Vaught Nihlathak Final Room, Halls of Pain Waypoint) - * 7. DRLGMAZE_PlaceAct5BaalStuff (Throne of Destruction Baal Entrance/ThroneRoom, Worldstone Waypoint) - * 8. DRLGMAZE_PlaceAct5IceStuff (Frozen River Anya platform, Drifter Cavern / Icy Cellar Pools, Waypoints) - * 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect) - * 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers) - * 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint) - * 12. Full generateMaze integration tests verifying specialPassesApplied and the - * per-level-type `unimplementedPasses` gap list. - */ - -import { describe, expect, test } from 'vitest' -import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' -import { - generateMaze, - classifyMazePieceName, - MAZE_LEVEL_TYPE_PROFILES, - UNIMPLEMENTED_PASSES, - unimplementedPassesFor, - type MazePiece, - type MazePieceKind, - type MazeRequest, -} from '../src/game/maze.ts' -import { - runMazeSpecialPasses, - type SpecialPassName, -} from '../src/game/maze-special-passes.ts' - -/** - * Construct a synthetic minimal Ds1 map. - */ -function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y++) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x++) { - row.push({ - walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects, - npcPathOffset: null, - } -} - -/** - * Construct a synthetic MazePiece. - */ -function mockPiece( - name: string, - kind: MazePieceKind, - sides = '', - objects: Ds1Object[] = [], - width = 10, - height = 10, -): MazePiece { - return { - name, - kind, - sides, - levels: [mockDs1(width, height, objects)], - } -} - -/** - * Create a full standard piece set for a level type so maze generation has - * all 15 basic room shapes plus entrance and common directions. - */ -function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[] = []): MazePiece[] { - const SIDES = [ - 'N', 'S', 'E', 'W', - 'NE', 'NW', 'SE', 'SW', - 'NS', 'EW', - 'NSE', 'NSW', 'NEW', 'SEW', - 'NSEW', - ] - const pieces: MazePiece[] = [ - mockPiece(`${levelTypeName} Entrance`, 'entrance', ''), - ...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }])), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }])), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Down ${s}`, 'down', s, [{ type: 2, id: 3, x: 25, y: 25, flags: 0 }])), - ...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Theme ${s}`, 'theme', s)), - ...extraPieces, - ] - return pieces -} - -describe('Diablo II DRLG Maze Special Passes', () => { - test('UNIMPLEMENTED_PASSES is empty when all maze profiles define specials', () => { - const emptySpecials = MAZE_LEVEL_TYPE_PROFILES - .filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined && profile.specialPass === undefined) - .map(profile => profile.levelTypeName) - expect(emptySpecials).toEqual([]) - expect(UNIMPLEMENTED_PASSES).toEqual([]) - }) - - test('unimplementedPassesFor is empty for a fully transcribed level type', () => { - expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([]) - expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([]) - expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([]) - expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([]) - expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([]) - expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([]) - }) - - test('unimplementedPassesFor reports a level type that has no profile at all', () => { - expect(unimplementedPassesFor('Act 9 - Nowhere')) - .toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)']) - }) - - test('generateMaze reports empty unimplementedPasses for transcribed levels', () => { - const spider = generateMaze({ - levelId: 84, - levelName: 'Spider Cavern', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 8484, - pieces: createStandardPieceSet('Act 3 - Spider'), - }) - expect(spider.stats.unimplementedPasses).toEqual([]) - // The other two diagnostic channels must travel with it. - expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true) - expect(Array.isArray(spider.stats.notes)).toBe(true) - - const cave = generateMaze({ - levelId: 8, - levelName: 'Den of Evil', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 8484, - pieces: createStandardPieceSet('Act 1 - Cave'), - }) - expect(cave.stats.unimplementedPasses).toEqual([]) - }) - - - /* ----------------------------------------------------------------------- * - * 1. Act 2 - Tomb - * ----------------------------------------------------------------------- */ - describe('Pass 1: DRLGMAZE_PlaceAct2TombStuff', () => { - const tombExtraPieces = [ - mockPiece('Act 2 - Tomb Talrasha N', 'quest', 'N', [{ type: 2, id: 152, x: 25, y: 25, flags: 0 }]), // Horadric Staff Orifice - mockPiece('Act 2 - Tomb Chest N', 'treasure', 'N', [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]), // Golden chest - mockPiece('Act 2 - Tomb Kaa N', 'quest', 'N', [{ type: 1, id: 250, x: 25, y: 25, flags: 0 }]), // Ancient Kaa - mockPiece('Act 2 - Tomb Cube N', 'treasure', 'N', [{ type: 2, id: 354, x: 25, y: 25, flags: 0 }]), // Horadric Cube - mockPiece('Act 2 - Tomb Leatherarm N', 'quest', 'N'), - mockPiece('Act 2 - Tomb Treasure N', 'treasure', 'N'), - mockPiece('Act 2 - Tomb Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 2 - Tomb Prev NSW', 'prev', 'NSW'), - mockPiece('Act 2 - Tomb Prev NEW', 'prev', 'NEW'), - mockPiece('Act 2 - Tomb Prev NSE', 'prev', 'NSE'), - mockPiece('Act 2 - Tomb Prev SEW', 'prev', 'SEW'), - ] - - test('True Tomb (staffTombLevelId): places Talrasha Horadric Staff orifice', () => { - const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces) - const res = generateMaze({ - levelId: 66, - levelName: 'Tal Rashas Tomb 1', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 12345, - pieces, - staffTombLevelId: 66, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const hasTalrasha = specials.some(s => s.kind === 'quest') - expect(hasTalrasha).toBe(true) - // Level size is tripled for staff tomb - expect(res.stats.targetRooms).toBe(15) - }) - - test('False Tombs (67..72): places golden chest room', () => { - const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces) - const res = generateMaze({ - levelId: 67, - levelName: 'Tal Rashas Tomb 2', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 54321, - pieces, - staffTombLevelId: 66, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'treasure')).toBe(true) - }) - - test('Halls of the Dead 2 (level 59): places Waypoint', () => { - const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces) - const res = generateMaze({ - levelId: 59, - levelName: 'Halls of the Dead 2', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 99999, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(true) - }) - - test('Halls of the Dead 3 (level 60): places Horadric Cube chest', () => { - const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces) - const res = generateMaze({ - levelId: 60, - levelName: 'Halls of the Dead 3', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 88888, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest' || s.kind === 'treasure')).toBe(true) - }) - - test('Boss Tomb: places Ancient Kaa the Soulless', () => { - const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces) - const res = generateMaze({ - levelId: 68, - levelName: 'Tal Rashas Tomb 3', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 77777, - pieces, - staffTombLevelId: 66, - bossTombLevelId: 68, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff') - expect(res.stats.targetRooms).toBe(10) // Doubled for boss tomb - }) - }) - - /* ----------------------------------------------------------------------- * - * 2. Act 2 - Lair - * ----------------------------------------------------------------------- */ - describe('Pass 2: DRLGMAZE_PlaceAct2LairStuff', () => { - const lairExtraPieces = [ - mockPiece('Act 2 - Lair Tight Spot S', 'quest', 'S', [{ type: 1, id: 284, x: 25, y: 25, flags: 0 }]), // Coldworm - mockPiece('Act 2 - Lair Treasure W', 'treasure', 'W', [{ type: 2, id: 356, x: 25, y: 25, flags: 0 }]), // Staff of Kings chest - ] - - test('Maggot Lair 3 (level 64): places Coldworm Tight Spot and Staff of Kings chest', () => { - const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces) - const res = generateMaze({ - levelId: 64, - levelName: 'Maggot Lair 3', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 42, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(specials.some(s => s.kind === 'treasure')).toBe(true) - expect(res.level.objects.some(o => o.type === 1 && o.id === 284)).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 356)).toBe(true) - }) - - test('Maggot Lair 1 (level 62): places regular next and prev', () => { - const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces) - const res = generateMaze({ - levelId: 62, - levelName: 'Maggot Lair 1', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 101, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'next')).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 3. Act 3 - Dungeon - * ----------------------------------------------------------------------- */ - describe('Pass 3: DRLGMAZE_PlaceAct3DungeonStuff', () => { - const dungeonExtraPieces = [ - mockPiece('Act 3 - Dungeon Treasure 2', 'treasure', '', [{ type: 2, id: 406, x: 25, y: 25, flags: 0 }]), // Khalim's Brain chest - mockPiece('Act 3 - Dungeon Treasure 1', 'treasure', ''), - mockPiece('Act 3 - Temple 1', 'quest', '', [{ type: 2, id: 193, x: 25, y: 25, flags: 0 }]), // Lam Esen's Tome altar - ] - - test("Flayer Dungeon 3 (level 91): places Khalim's Brain chest (Treasure 2)", () => { - const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces) - const res = generateMaze({ - levelId: 91, - levelName: 'Flayer Dungeon 3', - levelTypeName: 'Act 3 - Dungeon', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 31415, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'treasure')).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 406)).toBe(true) - }) - - test("Ruined Temple (level 94): places Lam Esen's Tome altar (Temple 1)", () => { - const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces) - const res = generateMaze({ - levelId: 94, - levelName: 'Ruined Temple', - levelTypeName: 'Act 3 - Dungeon', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 27182, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 193)).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 4. Act 3 - Sewer - * ----------------------------------------------------------------------- */ - describe('Pass 4: DRLGMAZE_PlaceAct3SewerStuff', () => { - const sewerExtraPieces = [ - mockPiece('Act 3 - Sewer Drain N', 'next', 'N', [{ type: 2, id: 366, x: 25, y: 25, flags: 0 }]), // Drain lever to Level 2 - mockPiece('Act 3 - Sewer Chest S', 'treasure', 'S'), - mockPiece('Act 3 - Sewer Treasure X', 'treasure', ''), - mockPiece('Act 3 - Sewer NW', 'room', 'NW'), - mockPiece('Act 3 - Sewer NE', 'room', 'NE'), - mockPiece('Act 3 - Sewer SW', 'room', 'SW'), - mockPiece('Act 3 - Sewer SE', 'room', 'SE'), - mockPiece('Act 3 - Sewer Prev NW', 'prev', 'NW'), - mockPiece('Act 3 - Sewer Prev NE', 'prev', 'NE'), - mockPiece('Act 3 - Sewer Prev SW', 'prev', 'SW'), - mockPiece('Act 3 - Sewer Prev SE', 'prev', 'SE'), - ] - - test('Kurast Sewers 1 (level 92): places Drain lever and Chest', () => { - const pieces = createStandardPieceSet('Act 3 - Sewer', sewerExtraPieces) - const res = generateMaze({ - levelId: 92, - levelName: 'Kurast Sewers 1', - sectionSize: 10, - minRooms: 18, - merge: 500, - seed: 777, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3SewerStuff') - expect(res.level.objects.some(o => o.type === 2 && o.id === 366)).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 5. Act 3 - Mephisto - * ----------------------------------------------------------------------- */ - describe('Pass 5: DRLGMAZE_PlaceAct3MephistoStuff', () => { - const mephistoExtraPieces = [ - mockPiece('Act 3 - Mephisto Complex', 'quest', '', [{ type: 2, id: 251, x: 50, y: 50, flags: 0 }]), // Red portal to Act 4 - mockPiece('Act 3 - Mephisto Waypoint N', 'waypoint', 'N', [{ type: 2, id: 237, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 3 - Mephisto Prev N', 'prev', 'N'), - mockPiece('Act 3 - Mephisto Next N', 'next', 'N'), - ] - - test('Durance of Hate 3 (level 102): places Mephisto Complex', () => { - const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces) - const res = generateMaze({ - levelId: 102, - levelName: 'Durance of Hate 3', - levelTypeName: 'Act 3 - Kurast', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 666, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 251)).toBe(true) - }) - - test('Durance of Hate 2 (level 101): places Waypoint', () => { - const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces) - const res = generateMaze({ - levelId: 101, - levelName: 'Durance of Hate 2', - levelTypeName: 'Act 3 - Kurast', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 10101, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 6. Act 5 - Temple - * ----------------------------------------------------------------------- */ - describe('Pass 6: DRLGMAZE_PlaceAct5TempleStuff', () => { - const templeExtraPieces = [ - mockPiece('Act 5 - Temple Final Room', 'quest', '', [{ type: 1, id: 526, x: 25, y: 25, flags: 0 }]), // Nihlathak - mockPiece('Act 5 - Temple NE Waypoint', 'waypoint', 'NE'), - mockPiece('Act 5 - Temple NW Waypoint', 'waypoint', 'NW'), - mockPiece('Act 5 - Temple SW Waypoint', 'waypoint', 'SW'), - mockPiece('Act 5 - Temple SE Waypoint', 'waypoint', 'SE'), - mockPiece('Act 5 - Temple NE Down', 'down', 'NE'), - mockPiece('Act 5 - Temple NW Down', 'down', 'NW'), - mockPiece('Act 5 - Temple SW Down', 'down', 'SW'), - mockPiece('Act 5 - Temple SE up', 'prev', 'SE'), - mockPiece('Act 5 - Temple SE', 'room', 'SE'), - mockPiece('Act 5 - Temple NE', 'room', 'NE'), - mockPiece('Act 5 - Temple NW', 'room', 'NW'), - mockPiece('Act 5 - Temple SW', 'room', 'SW'), - ] - - test('Halls of Vaught (level 124): places Nihlathak Final Room altar', () => { - const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces) - const res = generateMaze({ - levelId: 124, - levelName: 'Halls of Vaught', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 124124, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(res.level.objects.some(o => o.type === 1 && o.id === 526)).toBe(true) - }) - - test('Halls of Pain (level 123): places Waypoint and stairs', () => { - const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces) - const res = generateMaze({ - levelId: 123, - levelName: 'Halls of Pain', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 123123, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(true) - }) - - test('Halls of Pain (level 123): generates all 4 waypoint directions including NE across seeds', () => { - const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces) - const observedDirs = new Set() - - for (let seed = 1; seed <= 50; seed++) { - const res = generateMaze({ - levelId: 123, - levelName: 'Halls of Pain', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed, - pieces, - }) - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const wp = specials.find(s => s.kind === 'waypoint') - if (wp !== undefined) { - observedDirs.add(wp.sides) - } - } - - expect(observedDirs).toContain('NE') - expect(observedDirs).toContain('NW') - 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) - } - }) - }) - - /* ----------------------------------------------------------------------- * - * 7. Act 5 - Baal - * ----------------------------------------------------------------------- */ - describe('Pass 7: DRLGMAZE_PlaceAct5BaalStuff', () => { - const baalExtraPieces = [ - mockPiece('Act 5 - ThroneRoom', 'quest', '', [{ type: 2, id: 563, x: 25, y: 25, flags: 0 }]), // Baal Throne - mockPiece('Act 5 - Baal Waypoint N', 'waypoint', 'N'), - mockPiece('Act 5 - Baal Prev NSW', 'prev', 'NSW'), - mockPiece('Act 5 - Baal Prev NEW', 'prev', 'NEW'), - mockPiece('Act 5 - Baal Prev NSE', 'prev', 'NSE'), - mockPiece('Act 5 - Baal Prev SEW', 'prev', 'SEW'), - ] - - test('Throne of Destruction (level 131): places ThroneRoom', () => { - const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces) - const res = generateMaze({ - levelId: 131, - levelName: 'Throne of Destruction', - levelTypeName: 'Act 5 - Baal', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 131131, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 563)).toBe(true) - }) - - test('Worldstone Keep 2 (level 129): places Waypoint', () => { - const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces) - const res = generateMaze({ - levelId: 129, - levelName: 'Worldstone Keep 2', - levelTypeName: 'Act 5 - Baal', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 129129, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 8. Act 5 - Ice - * ----------------------------------------------------------------------- */ - describe('Pass 8: DRLGMAZE_PlaceAct5IceStuff', () => { - const iceExtraPieces = [ - mockPiece('Act 5 - Ice River A', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]), // Frozen Anya - mockPiece('Act 5 - Ice River B', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 5 - Ice Pool A', 'quest', ''), - mockPiece('Act 5 - Ice Pool B', 'quest', ''), - mockPiece('Act 5 - Ice waypoint N', 'waypoint', 'N'), - ] - - test('Frozen River (level 114): places Anya platform', () => { - const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces) - const res = generateMaze({ - levelId: 114, - levelName: 'Frozen River', - levelTypeName: 'Act 5 - Ice Caves', - sectionSize: 10, - minRooms: 1, // Single-room preset in profile - merge: 500, - seed: 114114, - pieces, - }) - - // Level 114 resolves single room preset Act 5 - Ice River A - expect(res.level.objects.some(o => o.type === 2 && o.id === 460)).toBe(true) - }) - - test('Glacial Trail (level 115): places Waypoint', () => { - const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces) - const res = generateMaze({ - levelId: 115, - levelName: 'Glacial Trail', - levelTypeName: 'Act 5 - Ice Caves', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 115115, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5IceStuff') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 9. Act 1 - Barracks - * ----------------------------------------------------------------------- */ - describe('Pass 9: DRLGMAZE_PlaceAct1Barracks', () => { - const barracksExtraPieces = [ - mockPiece('Act 1 - Barracks Forge N', 'quest', 'N', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), // Charsi's Malus forge - mockPiece('Act 1 - Barracks Forge S', 'quest', 'S', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 1 - Barracks Forge E', 'quest', 'E', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 1 - Barracks Forge W', 'quest', 'W', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 1 - Barracks Court Connect', 'prev', '', [{ type: 2, id: 1, x: 10, y: 10, flags: 0 }]), // Entrance from Cloister - ] - - test('Monastery Barracks (level 28): places Court Connect and Smith Forge', () => { - const pieces = createStandardPieceSet('Act 1 - Barracks', barracksExtraPieces) - const res = generateMaze({ - levelId: 28, - levelName: 'Barracks', - sectionSize: 10, - sectionHeight: 14, - minRooms: 6, - merge: 500, - seed: 2828, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct1Barracks') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(specials.some(s => s.kind === 'prev')).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 108)).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 10. Act 4 - Lava - * ----------------------------------------------------------------------- */ - describe('Pass 10: DRLGMAZE_PlaceAct4Lava', () => { - const lavaExtraPieces = [ - mockPiece('Act 4 - Lava Warp N', 'prev', 'N', [{ type: 2, id: 255, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 4 - Bridge 1', 'next', '', [{ type: 2, id: 10, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 4 - Bridge 2', 'next', '', [{ type: 2, id: 11, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 4 - Lava Forge W', 'quest', 'W', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]), // Hellforge - mockPiece('Act 4 - Lava Forge E', 'quest', 'E', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 4 - Lava X', 'room', ''), // Filler - ] - - test('River of Flame (level 107): places Hellforge, Bridges, Warp and Lava X filler', () => { - const pieces = createStandardPieceSet('Act 4 - Lava', lavaExtraPieces) - const res = generateMaze({ - levelId: 107, - levelName: 'River of Flame', - levelTypeName: 'Act 4 - Lava', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 107107, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct4Lava') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(res.level.objects.some(o => o.type === 2 && o.id === 376)).toBe(true) - // Exactly 1 Warp N (never duplicate) - expect(specials.filter(s => s.kind === 'prev').length).toBe(1) - // 3 Bridge rooms: 1 of Bridge 1, 2 of Bridge 2 - expect(res.stats.notes).toContain('piece "Act 4 - Bridge 1" used 1 time(s)') - expect(res.stats.notes).toContain('piece "Act 4 - Bridge 2" used 2 time(s)') - // Confirm Lava X filler spaces were placed - expect(specials.some(s => s.sides === 'filler')).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * 11. Act 2 - Sewers - * ----------------------------------------------------------------------- */ - describe('Pass 11: DRLGMAZE_ScanReplaceSpecialAct2SewersPresets', () => { - const sewer2ExtraPieces = [ - mockPiece("Act 2 - Sewer Radament's Lair N", 'quest', 'N', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), // Radament - mockPiece("Act 2 - Sewer Radament's Lair S", 'quest', 'S', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), - mockPiece("Act 2 - Sewer Radament's Lair E", 'quest', 'E', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), - mockPiece("Act 2 - Sewer Radament's Lair W", 'quest', 'W', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 2 - Sewer Prev E', 'prev', 'E'), // City entrance - mockPiece('Act 2 - Sewer Prev NS', 'prev', 'NS'), // Dock entrance - mockPiece('Act 2 - Sewer Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]), - mockPiece('Act 2 - Sewer Chest N', 'treasure', 'N', [{ type: 2, id: 355, x: 25, y: 25, flags: 0 }]), - ] - - test("Sewers Level 3 (level 49): places Radament's Lair", () => { - const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces) - const res = generateMaze({ - levelId: 49, - levelName: 'Sewers Level 3', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 4949, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'quest')).toBe(true) - expect(res.level.objects.some(o => o.type === 1 && o.id === 229)).toBe(true) - }) - - test('Sewers Level 1 (level 47): places dock and city entrances', () => { - const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces) - const res = generateMaze({ - levelId: 47, - levelName: 'Sewers Level 1', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 4747, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const prevs = specials.filter(s => s.kind === 'prev') - expect(prevs.length).toBeGreaterThanOrEqual(1) - }) - - test('Sewers Level 2 (level 48): places Waypoint', () => { - const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces) - const res = generateMaze({ - levelId: 48, - levelName: 'Sewers Level 2', - sectionSize: 10, - minRooms: 6, - merge: 500, - seed: 4848, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(true) - }) - - test('Ancient Tunnels (level 50): places golden chest', () => { - const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces) - const res = generateMaze({ - levelId: 50, - levelName: 'Ancient Tunnels', - sectionSize: 10, - minRooms: 5, - merge: 500, - seed: 5050, - pieces, - }) - - expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets') - const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[] - expect(specials.some(s => s.kind === 'treasure')).toBe(true) - }) - }) - - /* ----------------------------------------------------------------------- * - * Classification tests - * ----------------------------------------------------------------------- */ - describe('Piece Classification for Special Presets', () => { - test('classifies waypoint pieces', () => { - expect(classifyMazePieceName('Act 2 - Tomb Waypoint N', 'Act 2 - Tomb')).toEqual({ - kind: 'waypoint', - sides: 'N', - }) - expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({ - kind: 'waypoint', - sides: 'NE', - }) - expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({ - kind: 'waypoint', - sides: 'N', - }) - }) - - test('classifies quest and special pieces', () => { - expect(classifyMazePieceName('Act 2 - Tomb Talrasha N', 'Act 2 - Tomb')).toEqual({ - kind: 'quest', - sides: 'N', - }) - expect(classifyMazePieceName("Act 2 - Sewer Radament's Lair S", 'Act 2 - Sewer')).toEqual({ - kind: 'quest', - sides: 'S', - }) - expect(classifyMazePieceName('Act 2 - Lair Tight Spot S', 'Act 2 - Lair')).toEqual({ - kind: 'quest', - sides: 'S', - }) - expect(classifyMazePieceName('Act 1 - Barracks Forge W', 'Act 1 - Barracks')).toEqual({ - kind: 'quest', - sides: 'W', - }) - expect(classifyMazePieceName('Act 4 - Bridge 1', 'Act 4 - Lava')).toEqual({ - kind: 'next', - sides: '', - }) - expect(classifyMazePieceName('Act 3 - Mephisto Complex', 'Act 3 - Kurast')).toEqual({ - kind: 'quest', - sides: '', - }) - expect(classifyMazePieceName('Act 5 - Temple Final Room', 'Act 5 - Temple')).toEqual({ - kind: 'quest', - sides: '', - }) - expect(classifyMazePieceName('Act 5 - ThroneRoom Entrance', 'Act 5 - Baal')).toEqual({ - kind: 'quest', - sides: '', - }) - }) - }) -}) diff --git a/tests/maze-specials.test.ts b/tests/maze-specials.test.ts deleted file mode 100644 index 7f4ccfe..0000000 --- a/tests/maze-specials.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { - generateMaze, - unimplementedPassesFor, - UNIMPLEMENTED_PASSES, - MAZE_LEVEL_TYPE_PROFILES, - type MazePiece, - type MazePieceKind, - type MazeWarp, - type MazeWaypoint, -} from '../src/game/maze.ts' -import { - LVLPREST_ACT2_CORRUPT_HAREM_NE, - LVLPREST_ACT2_CORRUPT_HAREM_SE, - LVLPREST_ACT2_CORRUPT_HAREM_SW, - LVLPREST_ACT2_CORRUPT_HAREM_NW, - LVLPREST_ACT2_BASEMENT_NE, - LVLPREST_ACT2_BASEMENT_SE, - LVLPREST_ACT2_BASEMENT_SW, - LVLPREST_ACT2_BASEMENT_NW, - LVLPREST_ACT3_SPIDER_SW, - LVLPREST_ACT3_SPIDER_SE, - LVLPREST_ACT3_SPIDER_NW, - LVLPREST_ACT3_SPIDER_NE, - LVLPREST_ACT3_SPIDER_CHEST_NW, - LVLPREST_ACT3_SPIDER_CHEST_NE, - LVLPREST_ACT5_LAVA_N, - LVLPREST_ACT5_LAVA_S, - LVLPREST_ACT5_LAVA_E, - LVLPREST_ACT5_LAVA_W, - LVLPREST_ACT5_LAVA_EW, - LVLPREST_ACT5_LAVA_NS, - LVLPREST_MAZE_DEF_REGISTRY, -} from '../src/game/maze-special-passes.ts' -import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' - -function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 { - const cells: Ds1Cell[][] = [] - for (let y = 0; y < height; y++) { - const row: Ds1Cell[] = [] - for (let x = 0; x < width; x++) { - row.push({ - walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 1, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects, - npcPathOffset: null, - } -} - -function mockPiece( - name: string, - kind: MazePieceKind, - sides = '', - objects: Ds1Object[] = [], - width = 10, - height = 10, -): MazePiece { - return { - name, - kind, - sides, - levels: [mockDs1(width, height, objects)], - } -} - -const SIDES = ['W', 'E', 'EW', 'S', 'SW', 'SE', 'SEW', 'N', 'NW', 'NE', 'NEW', 'NS', 'NSW', 'NSE', 'NSEW'] - -function createPieceSetForType( - levelTypeName: string, - extraKinds: { kind: MazePieceKind; nameToken: string; objects?: Ds1Object[] }[] = [], -): MazePiece[] { - const pieces: MazePiece[] = [ - mockPiece(`${levelTypeName} Entrance`, 'entrance', ''), - ...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)), - ...['N', 'S', 'E', 'W'].map(s => - mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }]), - ), - ...['N', 'S', 'E', 'W'].map(s => - mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }]), - ), - ...['N', 'S', 'E', 'W'].map(s => - mockPiece(`${levelTypeName} Waypoint ${s}`, 'waypoint', s, [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]), - ), - ...['N', 'S', 'E', 'W'].map(s => - mockPiece(`${levelTypeName} Treasure ${s}`, 'treasure', s, [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]), - ), - ] - for (const extra of extraKinds) { - for (const s of ['N', 'S', 'E', 'W', 'NW', 'NE', 'SW', 'SE']) { - pieces.push(mockPiece(`${levelTypeName} ${extra.nameToken} ${s}`, extra.kind, s, extra.objects ?? [])) - } - } - return pieces -} - -describe('Maze Specials Profiles (Issue #84)', () => { - describe('Profile completeness and unimplementedPasses check', () => { - test('all 4 target level types report 0 unimplemented passes', () => { - expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([]) - expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([]) - expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([]) - expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([]) - }) - - test('UNIMPLEMENTED_PASSES global array is completely empty', () => { - expect(UNIMPLEMENTED_PASSES).toEqual([]) - }) - - test('profile table contains correctly configured special tables for all four types', () => { - const harem = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 2 - Harem')! - expect(harem).toBeDefined() - expect(harem.ring).toBe(2) - expect(harem.grow).toBe(false) - expect(harem.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ - { kind: 'prev', levelIds: [51] }, - { kind: 'next', levelIds: [51] }, - ]) - - const basement = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 2 - Basement')! - expect(basement).toBeDefined() - expect(basement.ring).toBe(2) - expect(basement.grow).toBe(false) - expect(basement.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ - { kind: 'prev', levelIds: [52, 53, 54] }, - { kind: 'waypoint', levelIds: [52] }, - { kind: 'next', levelIds: [52, 53] }, - ]) - - const spider = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 3 - Spider')! - expect(spider).toBeDefined() - expect(spider.ring).toBe(2) - expect(spider.grow).toBe(false) - expect(spider.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ - { kind: 'prev', levelIds: [84, 85] }, - { kind: 'treasure', levelIds: [84, 85] }, - ]) - - const lava = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 5 - Lava')! - expect(lava).toBeDefined() - expect(lava.grow).toBe(false) - expect(lava.lavaPairs).toBe(true) - expect(lava.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ - { kind: 'prev', levelIds: [125, 126, 127] }, - { kind: 'treasure', levelIds: [125, 126, 127] }, - ]) - }) - }) - - describe('LvlPrest constants & def registry', () => { - test('defines canonical LvlPrest Def IDs', () => { - expect(LVLPREST_ACT2_CORRUPT_HAREM_NE).toBe(354) - expect(LVLPREST_ACT2_CORRUPT_HAREM_SE).toBe(355) - expect(LVLPREST_ACT2_CORRUPT_HAREM_SW).toBe(356) - expect(LVLPREST_ACT2_CORRUPT_HAREM_NW).toBe(357) - - expect(LVLPREST_ACT2_BASEMENT_NE).toBe(358) - expect(LVLPREST_ACT2_BASEMENT_SE).toBe(359) - expect(LVLPREST_ACT2_BASEMENT_SW).toBe(360) - expect(LVLPREST_ACT2_BASEMENT_NW).toBe(361) - - expect(LVLPREST_ACT3_SPIDER_SW).toBe(659) - expect(LVLPREST_ACT3_SPIDER_SE).toBe(660) - expect(LVLPREST_ACT3_SPIDER_NW).toBe(661) - expect(LVLPREST_ACT3_SPIDER_NE).toBe(662) - expect(LVLPREST_ACT3_SPIDER_CHEST_NW).toBe(663) - expect(LVLPREST_ACT3_SPIDER_CHEST_NE).toBe(664) - - expect(LVLPREST_ACT5_LAVA_N).toBe(1053) - expect(LVLPREST_ACT5_LAVA_S).toBe(1054) - expect(LVLPREST_ACT5_LAVA_E).toBe(1055) - expect(LVLPREST_ACT5_LAVA_W).toBe(1056) - expect(LVLPREST_ACT5_LAVA_EW).toBe(1057) - expect(LVLPREST_ACT5_LAVA_NS).toBe(1058) - }) - - test('registers Spider chests in LVLPREST_MAZE_DEF_REGISTRY', () => { - expect(LVLPREST_MAZE_DEF_REGISTRY[LVLPREST_ACT3_SPIDER_CHEST_NW]).toEqual({ - name: 'Act 3 - Spider Chest NW', - kind: 'treasure', - sides: 'NW', - }) - expect(LVLPREST_MAZE_DEF_REGISTRY[LVLPREST_ACT3_SPIDER_CHEST_NE]).toEqual({ - name: 'Act 3 - Spider Chest NE', - kind: 'treasure', - sides: 'NE', - }) - }) - }) - - describe('generateMaze execution with specials', () => { - test('Act 2 - Harem (Level 51) places prev and next specials', () => { - const pieces = createPieceSetForType('Act 2 - Harem') - const result = generateMaze({ - levelId: 51, - levelName: 'Harem Level 2', - levelTypeName: 'Act 2 - Harem', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 51001, - pieces, - }) - - expect(result.stats.unimplementedPasses).toEqual([]) - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const kinds = specials.map(s => s.kind) - expect(kinds).toContain('prev') - expect(kinds).toContain('next') - const warps = result.stats.warps as MazeWarp[] - expect(warps.length).toBeGreaterThanOrEqual(2) - expect(warps.some(w => w.direction === 'up')).toBe(true) - expect(warps.some(w => w.direction === 'down')).toBe(true) - }) - - test('Act 2 - Basement (Level 52) places prev, waypoint, and next specials', () => { - const pieces = createPieceSetForType('Act 2 - Basement') - const result = generateMaze({ - levelId: 52, - levelName: 'Palace Cellar Level 1', - levelTypeName: 'Act 2 - Basement', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 52001, - pieces, - hasWaypoint: true, - }) - - expect(result.stats.unimplementedPasses).toEqual([]) - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const kinds = specials.map(s => s.kind) - expect(kinds).toContain('prev') - expect(kinds).toContain('waypoint') - expect(kinds).toContain('next') - const waypoints = result.stats.waypoints as MazeWaypoint[] - expect(waypoints.length).toBe(1) - const warps = result.stats.warps as MazeWarp[] - expect(warps.length).toBeGreaterThanOrEqual(2) - }) - - test('Act 2 - Basement (Level 53) places prev and next without waypoint', () => { - const pieces = createPieceSetForType('Act 2 - Basement') - const result = generateMaze({ - levelId: 53, - levelName: 'Palace Cellar Level 2', - levelTypeName: 'Act 2 - Basement', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 53001, - pieces, - }) - - expect(result.stats.unimplementedPasses).toEqual([]) - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const kinds = specials.map(s => s.kind) - expect(kinds).toContain('prev') - expect(kinds).toContain('next') - expect(kinds).not.toContain('waypoint') - const waypoints = result.stats.waypoints as MazeWaypoint[] - expect(waypoints.length).toBe(0) - }) - - test('Act 2 - Basement (Level 54) places prev only', () => { - const pieces = createPieceSetForType('Act 2 - Basement') - const result = generateMaze({ - levelId: 54, - levelName: 'Palace Cellar Level 3', - levelTypeName: 'Act 2 - Basement', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: 54001, - pieces, - }) - - expect(result.stats.unimplementedPasses).toEqual([]) - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const kinds = specials.map(s => s.kind) - expect(kinds).toContain('prev') - expect(kinds).not.toContain('next') - expect(kinds).not.toContain('waypoint') - }) - - test('Act 3 - Spider (Level 84 & 85) places prev and treasure', () => { - for (const levelId of [84, 85]) { - const pieces = createPieceSetForType('Act 3 - Spider') - const result = generateMaze({ - levelId, - levelName: levelId === 84 ? 'Spider Cave' : 'Spider Cavern', - levelTypeName: 'Act 3 - Spider', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: levelId * 1000 + 7, - pieces, - }) - - expect(result.stats.unimplementedPasses).toEqual([]) - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const kinds = specials.map(s => s.kind) - expect(kinds).toContain('prev') - expect(kinds).toContain('treasure') - const warps = result.stats.warps as MazeWarp[] - expect(warps.some(w => w.direction === 'up')).toBe(true) - } - }) - - test('Act 5 - Lava (Levels 125, 126, 127) places prev and treasure', () => { - for (const levelId of [125, 126, 127]) { - const pieces = createPieceSetForType('Act 5 - Lava') - const result = generateMaze({ - levelId, - levelName: `Lava Pit ${levelId}`, - levelTypeName: 'Act 5 - Lava', - sectionSize: 10, - minRooms: 4, - merge: 0, - seed: levelId * 1000 + 9, - pieces, - }) - - expect(result.stats.unimplementedPasses).toEqual([]) - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const kinds = specials.map(s => s.kind) - expect(kinds).toContain('prev') - expect(kinds).toContain('treasure') - const warps = result.stats.warps as MazeWarp[] - expect(warps.some(w => w.direction === 'up')).toBe(true) - } - }) - }) -}) diff --git a/tests/maze-waypoint.test.ts b/tests/maze-waypoint.test.ts deleted file mode 100644 index 5259f15..0000000 --- a/tests/maze-waypoint.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { describe, expect, test } from 'vitest' -import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' -import { - classifyMazePieceName, - generateMaze, - SUB_TILES_PER_TILE, -} from '../src/game/maze.ts' -import type { MazePiece, MazeRequest } from '../src/game/maze.ts' - -function createMockDs1(width = 8, height = 8, objects: Ds1Object[] = []): 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 createPiece( - name: string, - levelTypeName: string, - ds1Objects: Ds1Object[] = [], - width = 8, - height = 8, -): MazePiece { - const classified = classifyMazePieceName(name, levelTypeName) - if (!classified) throw new Error(`Could not classify ${name} for ${levelTypeName}`) - return { - name, - kind: classified.kind, - sides: classified.sides, - levels: [createMockDs1(width, height, ds1Objects)], - } -} - -function createCatacombsPieces(wpObjects: Ds1Object[] = []): MazePiece[] { - const lt = 'Act 1 - Catacombs' - const sided = [ - 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = [] - for (const s of sided) { - pieces.push(createPiece(`${lt} ${s}`, lt)) - } - pieces.push(createPiece(`${lt} Prev EW`, lt)) - pieces.push(createPiece(`${lt} Prev NS`, lt)) - for (const s of ['N', 'E', 'S', 'W']) { - pieces.push(createPiece(`${lt} Next ${s}`, lt)) - pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects)) - } - return pieces -} - -function createJailPieces(wpObjects: Ds1Object[] = []): MazePiece[] { - const lt = 'Act 1 - Jail' - const sided = [ - 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = [] - for (const s of sided) { - pieces.push(createPiece(`${lt} ${s}`, lt)) - } - for (const s of ['N', 'E', 'S', 'W']) { - pieces.push(createPiece(`${lt} Prev ${s}`, lt)) - pieces.push(createPiece(`${lt} Next ${s}`, lt)) - pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects)) - } - return pieces -} - -function createKurastPieces(): MazePiece[] { - const lt = 'Act 3 - Kurast' - const sided = [ - 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = [] - for (const s of sided) { - pieces.push(createPiece(`${lt} ${s}`, lt)) - } - for (const s of ['N', 'E', 'S', 'W']) { - pieces.push(createPiece(`Act 3 - Mephisto Prev ${s}`, lt)) - pieces.push(createPiece(`Act 3 - Mephisto Next ${s}`, lt)) - pieces.push(createPiece(`Act 3 - Mephisto Waypoint ${s}`, lt)) - } - return pieces -} - -function createBaalPieces(): MazePiece[] { - const lt = 'Act 5 - Baal' - const sided = [ - 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = [] - for (const s of sided) { - pieces.push(createPiece(`${lt} ${s}`, lt)) - } - // prevChain requires Prev NSE, SEW, NSW, NEW - for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) { - pieces.push(createPiece(`${lt} Prev ${s}`, lt)) - } - for (const s of ['N', 'E', 'S', 'W']) { - pieces.push(createPiece(`${lt} Next ${s}`, lt)) - pieces.push(createPiece(`${lt} Waypoint ${s}`, lt)) - } - return pieces -} - -function createSewerPieces(): MazePiece[] { - const lt = 'Act 2 - Sewer' - const sided = [ - 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = [] - for (const s of sided) { - pieces.push(createPiece(`${lt} ${s}`, lt)) - } - for (const s of ['N', 'E', 'S', 'W']) { - pieces.push(createPiece(`${lt} Prev ${s}`, lt)) - pieces.push(createPiece(`${lt} Next ${s}`, lt)) - pieces.push(createPiece(`${lt} Waypoint ${s}`, lt)) - } - return pieces -} - -function createTombPieces(): MazePiece[] { - const lt = 'Act 2 - Tomb' - const sided = [ - 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', - ] - const pieces: MazePiece[] = [] - for (const s of sided) { - pieces.push(createPiece(`${lt} ${s}`, lt)) - } - for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) { - pieces.push(createPiece(`${lt} Prev ${s}`, lt)) - } - for (const s of ['N', 'E', 'S', 'W']) { - pieces.push(createPiece(`${lt} Next ${s}`, lt)) - pieces.push(createPiece(`${lt} Waypoint ${s}`, lt)) - } - return pieces -} - -describe('Issue #47: Maze Waypoint preset replacement pass', () => { - describe('classifyMazePieceName', () => { - test('classifies waypoint pieces for all acts correctly', () => { - expect(classifyMazePieceName('Act 1 - Jail Waypoint W', 'Act 1 - Jail')).toEqual({ - kind: 'waypoint', - sides: 'W', - }) - expect(classifyMazePieceName('Act 1 - Catacombs Waypoint N', 'Act 1 - Catacombs')).toEqual({ - kind: 'waypoint', - sides: 'N', - }) - expect(classifyMazePieceName('Act 2 - Sewer Waypoint S', 'Act 2 - Sewer')).toEqual({ - kind: 'waypoint', - sides: 'S', - }) - expect(classifyMazePieceName('Act 2 - Tomb Waypoint E', 'Act 2 - Tomb')).toEqual({ - kind: 'waypoint', - sides: 'E', - }) - expect(classifyMazePieceName('Act 3 - Mephisto Waypoint W', 'Act 3 - Kurast')).toEqual({ - kind: 'waypoint', - sides: 'W', - }) - expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({ - kind: 'waypoint', - sides: 'N', - }) - expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({ - kind: 'waypoint', - sides: 'NE', - }) - expect(classifyMazePieceName('Act 5 - Temple SW Waypoint', 'Act 5 - Temple')).toEqual({ - kind: 'waypoint', - sides: 'SW', - }) - expect(classifyMazePieceName('Act 5 - Baal Waypoint N', 'Act 5 - Baal')).toEqual({ - kind: 'waypoint', - sides: 'N', - }) - }) - - test('preserves classification of other piece kinds', () => { - expect(classifyMazePieceName('Act 1 - Jail Prev W', 'Act 1 - Jail')).toEqual({ - kind: 'prev', - sides: 'W', - }) - expect(classifyMazePieceName('Act 1 - Catacombs Next S', 'Act 1 - Catacombs')).toEqual({ - kind: 'next', - sides: 'S', - }) - expect(classifyMazePieceName('Act 1 - Cave NSEW', 'Act 1 - Cave')).toEqual({ - kind: 'room', - sides: 'NSEW', - }) - }) - }) - - describe('generateMaze waypoint placement', () => { - test('places waypoint room in Catacombs 2 (level 35) and records stats', () => { - const pieces = createCatacombsPieces() - const req: MazeRequest = { - levelId: 35, - levelName: 'Catacombs Level 2', - levelTypeName: 'Act 1 - Catacombs', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 42, - pieces, - } - - const result = generateMaze(req) - const waypoints = result.stats.waypoints as unknown[] - expect(waypoints).toHaveLength(1) - - const wp = result.stats.waypoint as { - room: number - x: number - y: number - width: number - height: number - centreX: number - centreY: number - entityX: number - entityY: number - } - expect(wp).not.toBeNull() - expect(result.stats.waypointTile).toEqual({ - x: Math.floor(wp.entityX / SUB_TILES_PER_TILE), - y: Math.floor(wp.entityY / SUB_TILES_PER_TILE), - }) - - const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] - const wpSpecial = specials.find(s => s.kind === 'waypoint') - expect(wpSpecial).toBeDefined() - expect(wpSpecial?.room).toBe(wp.room) - - // Verify synthesized waypoint entity is present in result.level.objects - const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 119) - expect(wpObj).toBeDefined() - expect(wpObj?.x).toBe(wp.entityX) - expect(wpObj?.y).toBe(wp.entityY) - }) - - test('does NOT place waypoint room in Catacombs 1 (level 34)', () => { - const pieces = createCatacombsPieces() - const req: MazeRequest = { - levelId: 34, - levelName: 'Catacombs Level 1', - levelTypeName: 'Act 1 - Catacombs', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 42, - pieces, - } - - const result = generateMaze(req) - expect(result.stats.waypoints).toEqual([]) - expect(result.stats.waypoint).toBeNull() - expect(result.stats.waypointTile).toBeNull() - - const specials = result.stats.specialsApplied as { kind: string }[] - expect(specials.some(s => s.kind === 'waypoint')).toBe(false) - }) - - test('places waypoint room in Jail 1 (level 29)', () => { - const pieces = createJailPieces() - const req: MazeRequest = { - levelId: 29, - levelName: 'Jail Level 1', - levelTypeName: 'Act 1 - Jail', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 101, - pieces, - } - - const result = generateMaze(req) - expect(result.stats.waypoints).toHaveLength(1) - expect(result.stats.waypoint).not.toBeNull() - }) - - test('honors hasWaypoint override: forces waypoint when true on level without default waypoint', () => { - const pieces = createCatacombsPieces() - const req: MazeRequest = { - levelId: 34, - levelName: 'Catacombs Level 1', - levelTypeName: 'Act 1 - Catacombs', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 42, - pieces, - hasWaypoint: true, - } - - const result = generateMaze(req) - expect(result.stats.waypoints).toHaveLength(1) - expect(result.stats.waypoint).not.toBeNull() - }) - - test('honors hasWaypoint override: disables waypoint when false on level with default waypoint', () => { - const pieces = createCatacombsPieces() - const req: MazeRequest = { - levelId: 35, - levelName: 'Catacombs Level 2', - levelTypeName: 'Act 1 - Catacombs', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 42, - pieces, - hasWaypoint: false, - } - - const result = generateMaze(req) - expect(result.stats.waypoints).toEqual([]) - expect(result.stats.waypoint).toBeNull() - }) - - test('uses authored DS1 object entity coordinates when present', () => { - const authoredObj: Ds1Object = { - type: 2, - id: 119, - x: 18, - y: 22, - flags: 0, - } - const pieces = createCatacombsPieces([authoredObj]) - const req: MazeRequest = { - levelId: 35, - levelName: 'Catacombs Level 2', - levelTypeName: 'Act 1 - Catacombs', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 88, - pieces, - } - - const result = generateMaze(req) - const wp = result.stats.waypoint as { - x: number - y: number - entityX: number - entityY: number - } - expect(wp.entityX).toBe(wp.x * SUB_TILES_PER_TILE + 18) - expect(wp.entityY).toBe(wp.y * SUB_TILES_PER_TILE + 22) - - const objInLevel = result.level.objects.find(o => o.type === 2 && o.id === 119) - expect(objInLevel).toBeDefined() - expect(objInLevel?.x).toBe(wp.entityX) - expect(objInLevel?.y).toBe(wp.entityY) - }) - - test('places waypoint in Durance of Hate 2 (101) but not Durance 1 (100)', () => { - const pieces = createKurastPieces() - const req101: MazeRequest = { - levelId: 101, - levelName: 'Durance of Hate Level 2', - levelTypeName: 'Act 3 - Kurast', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 777, - pieces, - } - const res101 = generateMaze(req101) - expect(res101.stats.waypoints).toHaveLength(1) - expect(res101.stats.waypoint).not.toBeNull() - - const req100: MazeRequest = { - levelId: 100, - levelName: 'Durance of Hate Level 1', - levelTypeName: 'Act 3 - Kurast', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 777, - pieces, - } - const res100 = generateMaze(req100) - expect(res100.stats.waypoints).toEqual([]) - expect(res100.stats.waypoint).toBeNull() - }) - - test('places waypoint in Worldstone Keep 2 (129) but not Worldstone 1 (128)', () => { - const pieces = createBaalPieces() - const req129: MazeRequest = { - levelId: 129, - levelName: 'Worldstone Keep Level 2', - levelTypeName: 'Act 5 - Baal', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 999, - pieces, - } - const res129 = generateMaze(req129) - expect(res129.stats.waypoints).toHaveLength(1) - expect(res129.stats.waypoint).not.toBeNull() - - const req128: MazeRequest = { - levelId: 128, - levelName: 'Worldstone Keep Level 1', - levelTypeName: 'Act 5 - Baal', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 999, - pieces, - } - const res128 = generateMaze(req128) - expect(res128.stats.waypoints).toEqual([]) - expect(res128.stats.waypoint).toBeNull() - }) - - test('places waypoint in Lut Gholein Sewer 2 (48) but not Sewer 1 (47)', () => { - const pieces = createSewerPieces() - const req48: MazeRequest = { - levelId: 48, - levelName: 'Sewers Level 2', - levelTypeName: 'Act 2 - Sewer', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 333, - pieces, - } - const res48 = generateMaze(req48) - expect(res48.stats.waypoints).toHaveLength(1) - expect(res48.stats.waypoint).not.toBeNull() - - const req47: MazeRequest = { - levelId: 47, - levelName: 'Sewers Level 1', - levelTypeName: 'Act 2 - Sewer', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 333, - pieces, - } - const res47 = generateMaze(req47) - expect(res47.stats.waypoints).toEqual([]) - expect(res47.stats.waypoint).toBeNull() - }) - - test('places waypoint in Halls of the Dead 2 (57) but not Tomb 1 (56)', () => { - const pieces = createTombPieces() - const req57: MazeRequest = { - levelId: 57, - levelName: 'Halls of the Dead Level 2', - levelTypeName: 'Act 2 - Tomb', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 444, - pieces, - } - const res57 = generateMaze(req57) - expect(res57.stats.waypoints).toHaveLength(1) - expect(res57.stats.waypoint).not.toBeNull() - - const req56: MazeRequest = { - levelId: 56, - levelName: 'Halls of the Dead Level 1', - levelTypeName: 'Act 2 - Tomb', - sectionSize: 8, - minRooms: 10, - merge: 100, - seed: 444, - pieces, - } - const res56 = generateMaze(req56) - expect(res56.stats.waypoints).toEqual([]) - expect(res56.stats.waypoint).toBeNull() - }) - - test('places waypoint at a distance from the entrance (isExit distance sorting)', () => { - const pieces = createCatacombsPieces() - const req: MazeRequest = { - levelId: 35, - levelName: 'Catacombs Level 2', - levelTypeName: 'Act 1 - Catacombs', - sectionSize: 8, - minRooms: 12, - merge: 0, - seed: 12345, - pieces, - } - - const result = generateMaze(req) - const wp = result.stats.waypoint as { room: number } - const specials = result.stats.specialsApplied as { room: number; kind: string }[] - const prevSpecial = specials.find(s => s.kind === 'prev') - if (prevSpecial) { - // Waypoint room should not be placed in the entrance room - expect(wp.room).not.toBe(prevSpecial.room) - } - }) - }) -}) diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index 6f819a0..d93995c 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -362,22 +362,26 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S const tcName = getMonsterTreasureClass(kind, 'normal', 1) expect(tcName).toBe('Act 1 H2H A') - const engine = createTestEngine({ lootSeed: 100 }) - const monster = createTestMonster(0, id, 520, 500, 10, 'normal') - engine.world.monsters.push(monster) + let dropped = 0 + for (let seed = 100; seed < 120 && dropped === 0; seed += 1) { + const engine = createTestEngine({ lootSeed: seed }) + const monster = createTestMonster(0, id, 520, 500, 10, 'normal') + engine.world.monsters.push(monster) - engine.tick({ - movement: { x: 0, y: 0 }, - attacking: true, - pickingUp: false, - talking: false, - saving: false, - loading: false, - digits: [], - }) + engine.tick({ + movement: { x: 0, y: 0 }, + attacking: true, + pickingUp: false, + talking: false, + saving: false, + loading: false, + digits: [], + }) - expect(engine.metrics.dropsRolled).toBe(1) - expect(engine.groundItems.count).toBeGreaterThan(0) + expect(engine.metrics.dropsRolled).toBe(1) + dropped = engine.groundItems.count + } + expect(dropped).toBeGreaterThan(0) } }) diff --git a/tests/monster-target-bar.test.ts b/tests/monster-target-bar.test.ts index 19f0b3c..142bb51 100644 --- a/tests/monster-target-bar.test.ts +++ b/tests/monster-target-bar.test.ts @@ -38,7 +38,7 @@ import { applyEliteModifiers, enrichMonsterPacks, } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { parseTable } from '../src/game/acts.ts' import { RAW_MONSTATS, RAW_SUPERUNIQUES } from '../src/data/canonical-drop-data.ts' diff --git a/tests/monster-treasure-class.test.ts b/tests/monster-treasure-class.test.ts index b3d7c78..caa4276 100644 --- a/tests/monster-treasure-class.test.ts +++ b/tests/monster-treasure-class.test.ts @@ -16,8 +16,7 @@ import { resolveTreasureClassGroup } from '../src/game/treasure-class.ts' import { effectiveMF } from '../src/game/item-ratio.ts' import { GameEngine } from '../src/game/engine.ts' import { createItem, type ItemBase } from '../src/game/items.ts' -import { D2Rng } from '../src/game/d2-rng.ts' -import { Rng } from '../src/game/rng.ts' +import { D2Rng, Rng } from '../src/game/d2-rng.ts' describe('Monster & SuperUnique TreasureClass Resolution (Issue #405)', () => { const dropTables = getEmbeddedDropTables() diff --git a/tests/monsters-136-spectrum.test.ts b/tests/monsters-136-spectrum.test.ts index 65a553c..2c2529c 100644 --- a/tests/monsters-136-spectrum.test.ts +++ b/tests/monsters-136-spectrum.test.ts @@ -34,7 +34,7 @@ import { type Difficulty, type MonsterKind, } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' describe('136-Level Monster Spectrum & Density Ground Truth (Levels.txt / 1.13c)', () => { let tables: { diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 503e641..d5afec3 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -33,7 +33,7 @@ import { UNSCALED, } from '../src/game/monsters.ts' import type { MonsterKind } from '../src/game/monsters.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' /** * Build a table from a header and rows. diff --git a/tests/net.test.ts b/tests/net.test.ts index 9aa28ac..d4cd781 100644 --- a/tests/net.test.ts +++ b/tests/net.test.ts @@ -17,7 +17,7 @@ import * as fs from 'fs' */ import { addPlayer, createWorld, damageMonster, spawnMonsters, tickCombat, tickCombatMulti } from '../src/game/combat.ts' import type { CombatOptions, CombatWorld, MonsterStats } from '../src/game/combat.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { LockstepSession } from '../src/net/lockstep.ts' import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts' import { NetplaySession } from '../src/net/netplay.ts' diff --git a/tests/npc-placeholder.test.ts b/tests/npc-placeholder.test.ts index f041c9b..8ea72af 100644 --- a/tests/npc-placeholder.test.ts +++ b/tests/npc-placeholder.test.ts @@ -72,9 +72,9 @@ test.skipIf(noPack)('every packed NPC is classified into exactly one render pass expect(withSprite).toBe(total) }) -test.skipIf(noPack)('act 1 town quadrants carry their full NPC roster', () => { +test.skipIf(noPack)('act 1 town carries its full NPC roster', () => { const townLevels = readLevels().filter(l => l.label.includes('act-1-town')) - expect(townLevels.length).toBe(4) + expect(townLevels.length).toBe(1) const tokensSeen = new Set() for (const level of townLevels) { diff --git a/tests/object-animation-113c.test.ts b/tests/object-animation-113c.test.ts index 9b88c48..60302df 100644 --- a/tests/object-animation-113c.test.ts +++ b/tests/object-animation-113c.test.ts @@ -166,9 +166,9 @@ describe('Diablo II 1.13c Map Object Animations (Issue #387)', () => { }) describe('Ground truth 1.13c baked pack validation', () => { - const sceneJsonPath = join(process.cwd(), 'samples/d2-packs/act1/1-act-1-town-towne1/scene.json') + const sceneJsonPath = join(process.cwd(), 'samples/d2-packs/act1/1-act-1-town-var1/scene.json') - it('has baked animatedFrames and 1.13c timing in towne1 pack', () => { + it('has baked animatedFrames and 1.13c timing in Act 1 town pack', () => { if (!existsSync(sceneJsonPath)) { console.warn(`Skipping pack validation test: ${sceneJsonPath} does not exist`) return diff --git a/tests/packed-entity-art.test.ts b/tests/packed-entity-art.test.ts index cce9e44..7045761 100644 --- a/tests/packed-entity-art.test.ts +++ b/tests/packed-entity-art.test.ts @@ -15,10 +15,10 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3 expect(MONSTER_ART_MAP.skeleton).toEqual({ token: 'SK', weapon: 'hth' }) }) - it.skipIf(!hasPacks)('includes all 5 acts and 350+ map variants in samples/d2-packs/index.json (Issue #33)', () => { + it.skipIf(!hasPacks)('includes all 5 acts and 136+ map scenes in samples/d2-packs/index.json (Issue #33)', () => { const index = JSON.parse(readFileSync(indexJsonPath, 'utf8')) expect(Array.isArray(index.levels)).toBe(true) - expect(index.levels.length).toBeGreaterThanOrEqual(350) + expect(index.levels.length).toBeGreaterThanOrEqual(136) const actsPresent = new Set(index.levels.map((l: { act: number }) => l.act)) expect(actsPresent.has(1)).toBe(true) diff --git a/tests/preset.test.ts b/tests/preset.test.ts deleted file mode 100644 index 8a27080..0000000 --- a/tests/preset.test.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { describe, expect, test } from "vitest" -import { - generatePreset, - generateChaosSanctuary, - CHAOS_SANCTUARY_LEVEL_ID, - CHAOS_SANCTUARY_SIZE_X, - CHAOS_SANCTUARY_SIZE_Y, - CHAOS_SANCTUARY_PLACEMENTS, - PRESET_LEVEL_IDS, - PRESET_LEVEL_ID_SET, - ALL_PRESET_LEVEL_IDS, - isPresetLevel, - slicePresetRooms, - createD2Seed, - rollD2Random, - rollD2LimitedRandom, - resolvePickedFileIndex, - resolvePickedFile, -} from "../src/game/preset.ts" -import type { PresetPiece } from "../src/game/preset.ts" -import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" - -function makeMockDs1(width: number, height: number, style = 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: [{ prop1: 2, sequence: 0, style, type: 0, unknown1: 0, unknown2: 0, hidden: false }], - floors: [{ prop1: 2, sequence: 0, style, unknown1: 0, unknown2: 0, hidden: false }], - shadows: [], - substitutions: [], - }) - } - cells.push(row) - } - return { - version: 18, - width, - height, - act: 4, - substitutionType: 0, - wallLayers: 1, - floorLayers: 1, - cells, - objects: [], - npcPathOffset: null, - } -} - -describe("Preset Map Pipeline (DrlgType 2)", () => { - test("generatePreset generates Level 108 Chaos Sanctuary stitching 7 cross-wing presets on 120x120 canvas", () => { - 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: PresetPiece[] = names.map((name, idx) => { - const baseDs1 = makeMockDs1(24, 24, 10 + idx) - const ds1: Ds1 = { - ...baseDs1, - objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }], - } - return { name, levels: [ds1] } - }) - - const result = generatePreset({ - levelId: CHAOS_SANCTUARY_LEVEL_ID, - levelName: "Chaos Sanctuary", - levelTypeName: "Act 4 - Lava", - sizeX: CHAOS_SANCTUARY_SIZE_X, - sizeY: CHAOS_SANCTUARY_SIZE_Y, - seed: 108, - pieces, - }) - - expect(result.level.width).toBe(120) - expect(result.level.height).toBe(120) - expect(result.level.act).toBe(4) - expect(result.stats.substitutedClusters).toBe(7) - expect(result.stats.substitutions.length).toBe(7) - expect(result.level.objects.length).toBe(7) - expect(result.stats.groundCells).toBeGreaterThan(0) - - // Verify object placement offsets (5 sub-tiles per cell) - // Arm N at (48, 0): object at (48*5 + 5, 0*5 + 5) = (245, 5) - const armNObj = result.level.objects.find(o => o.id === 200) - expect(armNObj).toBeDefined() - expect(armNObj?.x).toBe(48 * 5 + 5) - expect(armNObj?.y).toBe(0 * 5 + 5) - - // Arm W at (24, 24): object at (24*5 + 5, 24*5 + 5) = (125, 125) - const armWObj = result.level.objects.find(o => o.id === 201) - expect(armWObj).toBeDefined() - expect(armWObj?.x).toBe(24 * 5 + 5) - expect(armWObj?.y).toBe(24 * 5 + 5) - - // Heart at (48, 24): object at (48*5 + 5, 24*5 + 5) = (245, 125) - const heartObj = result.level.objects.find(o => o.id === 202) - expect(heartObj).toBeDefined() - expect(heartObj?.x).toBe(48 * 5 + 5) - expect(heartObj?.y).toBe(24 * 5 + 5) - - // Bridge 2 at (48, 96): object at (48*5 + 5, 96*5 + 5) = (245, 485) - const bridgeObj = result.level.objects.find(o => o.id === 206) - expect(bridgeObj).toBeDefined() - expect(bridgeObj?.x).toBe(48 * 5 + 5) - expect(bridgeObj?.y).toBe(96 * 5 + 5) - - // Verify tile cell content stamped at origins - expect(result.level.cells[0]?.[48]?.floors[0]?.style).toBe(10) // Arm N style - expect(result.level.cells[24]?.[24]?.floors[0]?.style).toBe(11) // Arm W style - expect(result.level.cells[24]?.[48]?.floors[0]?.style).toBe(12) // Heart style - expect(result.level.cells[96]?.[48]?.floors[0]?.style).toBe(16) // Bridge style - }) - - test("generatePreset dispatches by levelName when levelId is not 108", () => { - const pieces: PresetPiece[] = CHAOS_SANCTUARY_PLACEMENTS.map((item, idx) => ({ - name: item.name, - levels: [makeMockDs1(24, 24, 5)], - })) - - const result = generatePreset({ - levelId: 0, - levelName: "Act 4 - Chaos Sanctuary", - pieces, - }) - - expect(result.level.width).toBe(120) - expect(result.level.height).toBe(120) - expect(result.stats.substitutedClusters).toBe(7) - }) - - test("generateChaosSanctuary matches piece names flexibly (tokens, stripped prefixes, filenames)", () => { - // Pieces with alternative naming styles (e.g. without 'Act 4 - ', or file paths) - const pieces: PresetPiece[] = [ - { name: "Diablo Arm N", levels: [makeMockDs1(24, 24, 1)] }, - { name: "data\\global\\tiles\\act4\\diablo\\ArmW.ds1", levels: [makeMockDs1(24, 24, 2)] }, - { name: "Act 4 - Diablo Heart", levels: [makeMockDs1(24, 24, 3)] }, - { name: "Diablo Arm E", levels: [makeMockDs1(24, 24, 4)] }, - { name: "Arm S", levels: [makeMockDs1(24, 24, 5)] }, - { name: "Diablo Entry", levels: [makeMockDs1(24, 24, 6)] }, - { name: "Bridge 2", levels: [makeMockDs1(24, 24, 7)] }, - ] - - const result = generateChaosSanctuary({ - levelId: 108, - pieces, - }) - - expect(result.stats.substitutedClusters).toBe(7) - expect(result.level.width).toBe(120) - expect(result.level.height).toBe(120) - }) - - test("generatePreset handles standalone single-DS1 preset levels", () => { - const baseDs1 = makeMockDs1(40, 40, 7) - const mockDs1: Ds1 = { - ...baseDs1, - objects: [{ type: 2, id: 99, x: 10, y: 10, flags: 0 }], - } - const pieces: PresetPiece[] = [ - { - name: "Tristram", - levels: [mockDs1], - }, - ] - - const result = generatePreset({ - levelId: 38, - levelName: "Tristram", - sizeX: 40, - sizeY: 40, - pieces, - }) - - expect(result.level.width).toBe(40) - expect(result.level.height).toBe(40) - expect(result.level.objects.length).toBe(1) - expect(result.stats.substitutedClusters).toBe(1) - }) - - test("generatePreset supports custom piece placements", () => { - const pieces: PresetPiece[] = [ - { name: "RoomA", levels: [makeMockDs1(20, 20, 1)] }, - { name: "RoomB", levels: [makeMockDs1(20, 20, 2)] }, - ] - - const result = generatePreset({ - levelId: 999, - sizeX: 60, - sizeY: 60, - pieces, - placements: [ - { name: "RoomA", x: 0, y: 0 }, - { name: "RoomB", x: 30, y: 30 }, - ], - }) - - expect(result.level.width).toBe(60) - expect(result.level.height).toBe(60) - expect(result.stats.substitutedClusters).toBe(2) - expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(1) - expect(result.level.cells[30]?.[30]?.floors[0]?.style).toBe(2) - }) -}) - -describe("Canonical Preset Levels (Levels.txt DrlgType == 2)", () => { - test("PRESET_LEVEL_IDS contains all 35 canonical DrlgType == 2 levels across Acts 1-5", () => { - expect(PRESET_LEVEL_IDS.length).toBe(35) - // Act 1 (13) - for (const id of [1, 13, 14, 15, 16, 20, 25, 26, 27, 32, 33, 37, 38]) { - expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true) - } - // Act 2 (3) - for (const id of [40, 50, 73]) { - expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true) - } - // Act 3 (11) - for (const id of [75, 90, 91, 93, 94, 95, 96, 97, 98, 99, 102]) { - expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true) - } - // Act 4 (1) - expect(PRESET_LEVEL_ID_SET.has(103)).toBe(true) - // Act 5 (7) - for (const id of [109, 120, 121, 124, 131, 132, 136]) { - expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true) - } - }) - - test("ALL_PRESET_LEVEL_IDS includes the 35 canonical levels plus Level 108 Chaos Sanctuary", () => { - expect(ALL_PRESET_LEVEL_IDS.length).toBe(36) - expect(ALL_PRESET_LEVEL_IDS).toContain(108) - for (const id of PRESET_LEVEL_IDS) { - expect(ALL_PRESET_LEVEL_IDS).toContain(id) - } - }) - - test("isPresetLevel correctly classifies preset vs non-preset level IDs", () => { - // 35 canonical preset levels - for (const id of PRESET_LEVEL_IDS) { - expect(isPresetLevel(id)).toBe(true) - } - // Level 108 Chaos Sanctuary (stitched preset level) - expect(isPresetLevel(108)).toBe(true) - - // Non-preset levels - expect(isPresetLevel(2)).toBe(false) // Blood Moor (outdoor) - expect(isPresetLevel(3)).toBe(false) // Cold Plains (outdoor) - expect(isPresetLevel(8)).toBe(false) // Catacombs Level 1 (maze) - expect(isPresetLevel(39)).toBe(false) // Moo Moo Farm (outdoor) - expect(isPresetLevel(0)).toBe(false) - expect(isPresetLevel(-1)).toBe(false) - expect(isPresetLevel(999)).toBe(false) - }) -}) - -describe("8x8 Room Geometry Assembly and Slicing (DRLGPRESET_BuildArea)", () => { - test("slices 16x16 canvas into 4 exact 8x8 rooms", () => { - const rooms = slicePresetRooms(16, 16) - expect(rooms.length).toBe(4) - - expect(rooms[0]).toMatchObject({ - id: "preset_room_0_0", - x: 0, - y: 0, - width: 8, - height: 8, - gridX: 0, - gridY: 0, - cellX: 0, - cellY: 0, - cellsX: 8, - cellsY: 8, - objects: [], - }) - expect(rooms[1]).toMatchObject({ - id: "preset_room_1_0", - x: 8, - y: 0, - width: 8, - height: 8, - gridX: 1, - gridY: 0, - }) - expect(rooms[2]).toMatchObject({ - id: "preset_room_0_1", - x: 0, - y: 8, - width: 8, - height: 8, - gridX: 0, - gridY: 1, - }) - expect(rooms[3]).toMatchObject({ - id: "preset_room_1_1", - x: 8, - y: 8, - width: 8, - height: 8, - gridX: 1, - gridY: 1, - }) - }) - - test("clamps non-multiple-of-8 canvas boundaries (20x20 canvas -> 9 rooms)", () => { - const rooms = slicePresetRooms(20, 20) - // 20 = 8 + 8 + 4 -> 3 x 3 = 9 rooms - expect(rooms.length).toBe(9) - - // Check bottom-right clamped room - const cornerRoom = rooms.find(r => r.gridX === 2 && r.gridY === 2) - expect(cornerRoom).toBeDefined() - expect(cornerRoom?.x).toBe(16) - expect(cornerRoom?.y).toBe(16) - expect(cornerRoom?.width).toBe(4) - expect(cornerRoom?.height).toBe(4) - expect(cornerRoom?.cellsX).toBe(4) - expect(cornerRoom?.cellsY).toBe(4) - - // Check edge rooms with 8x4 or 4x8 - const topEdgeClamped = rooms.find(r => r.gridX === 2 && r.gridY === 0) - expect(topEdgeClamped?.width).toBe(4) - expect(topEdgeClamped?.height).toBe(8) - - const leftEdgeClamped = rooms.find(r => r.gridX === 0 && r.gridY === 2) - expect(leftEdgeClamped?.width).toBe(8) - expect(leftEdgeClamped?.height).toBe(4) - }) - - test("handles asymmetric canvas (25x13 -> 8 rooms)", () => { - // Width 25: 8 + 8 + 8 + 1 -> 4 columns - // Height 13: 8 + 5 -> 2 rows - // Total: 4 * 2 = 8 rooms - const rooms = slicePresetRooms(25, 13) - expect(rooms.length).toBe(8) - - const lastColTopRow = rooms.find(r => r.gridX === 3 && r.gridY === 0) - expect(lastColTopRow?.width).toBe(1) - expect(lastColTopRow?.height).toBe(8) - - const lastColBottomRow = rooms.find(r => r.gridX === 3 && r.gridY === 1) - expect(lastColBottomRow?.width).toBe(1) - expect(lastColBottomRow?.height).toBe(5) - }) - - test("returns empty array for zero or negative dimensions", () => { - expect(slicePresetRooms(0, 0)).toEqual([]) - expect(slicePresetRooms(-10, 20)).toEqual([]) - expect(slicePresetRooms(20, -5)).toEqual([]) - }) - - test("partitions objects by room boundaries using sub-tile coordinates (5 sub-tiles per cell)", () => { - const objects = [ - // In room (0, 0): cell (2, 3) -> sub-tiles (10, 15) - { type: 2, id: 1, x: 10, y: 15, flags: 0 }, - // In room (1, 0): cell (9, 2) -> sub-tiles (45, 10) - { type: 2, id: 2, x: 45, y: 10, flags: 0 }, - // In room (0, 1): cell (1, 10) -> sub-tiles (5, 50) - { type: 2, id: 3, x: 5, y: 50, flags: 0 }, - // In room (1, 1): cell (15, 15) -> sub-tiles (75, 75) - { type: 2, id: 4, x: 75, y: 75, flags: 0 }, - // Outside canvas: cell (25, 25) -> sub-tiles (125, 125) - { type: 2, id: 5, x: 125, y: 125, flags: 0 }, - ] - - const rooms = slicePresetRooms(16, 16, objects) - expect(rooms.length).toBe(4) - - const room00 = rooms.find(r => r.gridX === 0 && r.gridY === 0) - expect(room00?.objects.map(o => o.id)).toEqual([1]) - - const room10 = rooms.find(r => r.gridX === 1 && r.gridY === 0) - expect(room10?.objects.map(o => o.id)).toEqual([2]) - - const room01 = rooms.find(r => r.gridX === 0 && r.gridY === 1) - expect(room01?.objects.map(o => o.id)).toEqual([3]) - - const room11 = rooms.find(r => r.gridX === 1 && r.gridY === 1) - expect(room11?.objects.map(o => o.id)).toEqual([4]) - }) - - test("supports direct cell coordinate objects when cellCoords option is enabled", () => { - const objects = [ - { type: 2, id: 10, x: 4, y: 4, flags: 0 }, - { type: 2, id: 20, x: 12, y: 4, flags: 0 }, - ] - - const rooms = slicePresetRooms(16, 16, objects, { cellCoords: true }) - const r00 = rooms.find(r => r.gridX === 0 && r.gridY === 0) - const r10 = rooms.find(r => r.gridX === 1 && r.gridY === 0) - - expect(r00?.objects.map(o => o.id)).toEqual([10]) - expect(r10?.objects.map(o => o.id)).toEqual([20]) - }) -}) - -describe("1.13c D2Seed PRNG and Multi-File Variant Selection (DRLGPRESET_AllocDrlgMap)", () => { - test("createD2Seed initializes low and default high = 666 matching 1.13c SEED_InitLowSeed", () => { - const seed = createD2Seed(12345) - expect(seed.low).toBe(12345) - expect(seed.high).toBe(666) - }) - - test("rollD2Random advances seed deterministically with 0x6AC690C5 multiplier", () => { - const seed = createD2Seed(100) - const roll1 = rollD2Random(seed) - // 666 + 0x6AC690C5 * 100 = 179139809166 - // low 32 bits = 179139809166 % 2^32 = 3046150030 - // high 32 bits = 179139809166 >> 32 = 41 - expect(roll1).toBe(3046150030) - expect(seed.low).toBe(3046150030) - expect(seed.high).toBe(41) - - // Second roll - const roll2 = rollD2Random(seed) - expect(typeof roll2).toBe("number") - expect(roll2).not.toBe(roll1) - }) - - test("rollD2LimitedRandom implements power-of-2 bitmask and non-power-of-2 modulo branches", () => { - // Power of 2 (max = 4): uses roll & 3 - const p2Roll = rollD2LimitedRandom(100, 4) - expect(p2Roll).toBeGreaterThanOrEqual(0) - expect(p2Roll).toBeLessThan(4) - - // Non power of 2 (max = 3): uses roll % 3 - const np2Roll = rollD2LimitedRandom(100, 3) - expect(np2Roll).toBeGreaterThanOrEqual(0) - expect(np2Roll).toBeLessThan(3) - - // max <= 0 returns 0 - expect(rollD2LimitedRandom(100, 0)).toBe(0) - expect(rollD2LimitedRandom(100, -5)).toBe(0) - // max = 1 returns 0 - expect(rollD2LimitedRandom(100, 1)).toBe(0) - }) - - test("resolvePickedFileIndex picks deterministic variant indices", () => { - const idxA1 = resolvePickedFileIndex(4, 42) - const idxA2 = resolvePickedFileIndex(4, 42) - expect(idxA1).toBe(idxA2) - - const idxB = resolvePickedFileIndex(4, 99999) - expect(idxA1).toBeGreaterThanOrEqual(0) - expect(idxA1).toBeLessThan(4) - expect(idxB).toBeGreaterThanOrEqual(0) - expect(idxB).toBeLessThan(4) - }) - - test("resolvePickedFile returns matching element or undefined for empty array", () => { - const variants = ["fileA.ds1", "fileB.ds1", "fileC.ds1"] - const picked = resolvePickedFile(variants, 12345) - expect(variants).toContain(picked) - expect(resolvePickedFile([], 12345)).toBeUndefined() - }) -}) - -describe("Preset Generation Room Integration and Variant Resolution", () => { - test("generateChaosSanctuary attaches 225 sliced 8x8 rooms and partitions wing objects", () => { - 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", - "Bridge 2", - ] - const pieces: PresetPiece[] = names.map((name, idx) => { - const baseDs1 = makeMockDs1(24, 24, 10 + idx) - const ds1: Ds1 = { - ...baseDs1, - objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }], - } - return { name, levels: [ds1] } - }) - - const result = generateChaosSanctuary({ - levelId: CHAOS_SANCTUARY_LEVEL_ID, - pieces, - }) - - // 120 / 8 = 15 -> 15 x 15 = 225 rooms - expect(result.rooms?.length).toBe(225) - expect(result.stats.rooms).toBe(225) - - // Arm N object at (48, 0) in cells -> cell (49, 1) -> room (gridX: 6, gridY: 0) - const armNRoom = result.rooms?.find(r => r.gridX === 6 && r.gridY === 0) - expect(armNRoom?.objects.some(o => o.id === 300)).toBe(true) - }) - - test("generatePreset resolves multi-level variants and records nPickedFile", () => { - const variant0 = makeMockDs1(20, 20, 1) - const variant1 = makeMockDs1(20, 20, 2) - const variant2 = makeMockDs1(20, 20, 3) - - const pieces: PresetPiece[] = [ - { - name: "MultiVariantLevel", - levels: [variant0, variant1, variant2], - }, - ] - - const seed = 54321 - const expectedIndex = resolvePickedFileIndex(3, seed) - - const result = generatePreset({ - levelId: 38, - levelName: "MultiVariantLevel", - seed, - pieces, - }) - - expect(result.stats.nPickedFile).toBe(expectedIndex) - expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(expectedIndex + 1) - expect(result.rooms).toBeDefined() - expect(result.rooms?.length).toBe(9) // 20x20 -> 3x3 = 9 rooms - expect(result.stats.rooms).toBe(9) - }) - - test("generatePreset resolves multi-piece candidates and records nPickedFile", () => { - const pieces: PresetPiece[] = [ - { name: "VariantPiece_0", levels: [makeMockDs1(16, 16, 1)] }, - { name: "VariantPiece_1", levels: [makeMockDs1(16, 16, 2)] }, - { name: "VariantPiece_2", levels: [makeMockDs1(16, 16, 3)] }, - { name: "VariantPiece_3", levels: [makeMockDs1(16, 16, 4)] }, - ] - - const seed = 777 - const expectedIndex = resolvePickedFileIndex(4, seed) - - const result = generatePreset({ - levelId: 14, - levelName: "CountessTower", - seed, - pieces, - }) - - expect(result.stats.nPickedFile).toBe(expectedIndex) - expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(expectedIndex + 1) - expect(result.rooms?.length).toBe(4) // 16x16 -> 2x2 = 4 rooms - }) - - test("generatePreset stamps onto canvas when requested size differs from DS1", () => { - const ds1 = makeMockDs1(16, 16, 1) - const pieces: PresetPiece[] = [{ name: "SmallPiece", levels: [ds1] }] - - const result = generatePreset({ - levelId: 33, - sizeX: 32, - sizeY: 24, - pieces, - }) - - expect(result.level.width).toBe(32) - expect(result.level.height).toBe(24) - // 32/8 = 4 columns, 24/8 = 3 rows -> 12 rooms - expect(result.rooms?.length).toBe(12) - expect(result.stats.rooms).toBe(12) - }) -}) diff --git a/tests/runeword-system.test.ts b/tests/runeword-system.test.ts index 7ef4406..ef711be 100644 --- a/tests/runeword-system.test.ts +++ b/tests/runeword-system.test.ts @@ -15,7 +15,7 @@ import { } from '../src/game/runeword-socket.ts' import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' import { ItemFlag, ItemQuality, type ItemBase } from '../src/game/items.ts' -import { Rng } from '../src/game/rng.ts' +import { Rng } from '../src/game/d2-rng.ts' import { formatItemTooltip } from '../src/game/item-tooltip.ts' import { itemToUiInventoryItem } from '../src/ui/item-bridge.ts' import { InventoryPanel } from '../src/ui/inventory.ts' diff --git a/tests/waypoint-fidelity-regression.test.ts b/tests/waypoint-fidelity-regression.test.ts index 0c2b14f..383619f 100644 --- a/tests/waypoint-fidelity-regression.test.ts +++ b/tests/waypoint-fidelity-regression.test.ts @@ -10,12 +10,13 @@ import { } from '../src/game/object-lookup.ts' describe('Waypoint Fidelity & Act-Aware DS1 Resolution (Issue #409)', () => { - test('WAYPOINT_OBJECTS_TXT_IDS contains all 17 canonical waypoint rows', () => { - expect(WAYPOINT_OBJECTS_TXT_IDS.size).toBe(17) - const expectedRows = [38, 119, 145, 156, 157, 237, 238, 288, 323, 324, 398, 402, 429, 494, 496, 511, 539] + test('WAYPOINT_OBJECTS_TXT_IDS contains all 16 canonical waypoint rows', () => { + expect(WAYPOINT_OBJECTS_TXT_IDS.size).toBe(16) + const expectedRows = [119, 145, 156, 157, 237, 238, 288, 323, 324, 398, 402, 429, 494, 496, 511, 539] for (const row of expectedRows) { expect(isWaypointObjectsTxtId(row)).toBe(true) } + expect(isWaypointObjectsTxtId(38)).toBe(false) expect(isWaypointObjectsTxtId(0)).toBe(false) expect(isWaypointObjectsTxtId(1)).toBe(false) expect(isWaypointObjectsTxtId(null)).toBe(false) @@ -43,13 +44,14 @@ describe('Waypoint Fidelity & Act-Aware DS1 Resolution (Issue #409)', () => { // Act 4: id=0 is Mesa Waypoint expect(isWaypointDs1Object(4, 2, 0)).toBe(true) - // Act 5: id=12 (Objects.txt row 38, WT) is Wilderness Waypoint - expect(isWaypointDs1Object(5, 2, 12)).toBe(true) + // Act 5: id=12 (Objects.txt row 38, WT) is Wall Torch (NOT waypoint); id=103 (row 511, 5O) is Ice Cave Waypoint + expect(isWaypointDs1Object(5, 2, 12)).toBe(false) + expect(isWaypointDs1Object(5, 2, 103)).toBe(true) // Act 5 Temple: id=120 (Objects.txt row 496, 5A) is Temple Waypoint expect(isWaypointDs1Object(5, 2, 120)).toBe(true) }) - test('All 39 waypoint levels across all 109 variants in samples/d2-packs render waypoints with source="object"', async () => { + test('All 39 waypoint levels in samples/d2-packs render waypoints with source="object"', async () => { const packDir = 'samples/d2-packs' const graphPath = join(packDir, 'world-graph.json') if (!existsSync(graphPath)) { @@ -104,6 +106,6 @@ describe('Waypoint Fidelity & Act-Aware DS1 Resolution (Issue #409)', () => { expect(levelVariantCount, `Level ${levelId} should have at least 1 variant`).toBeGreaterThanOrEqual(1) } - expect(totalVariantsAudited).toBe(109) + expect(totalVariantsAudited).toBe(39) }) })