feat(drlg,rng): unify 64-bit D2Rng and 136-level 1:1 DRLG port (DRLG_ACT_VARIANTS=1, seed 0x5eed0100)
This commit is contained in:
parent
e475c2e6d8
commit
8ddfbd318a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<number, number>()
|
||||
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<number, number>()
|
||||
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<string, Ds1>()
|
||||
|
||||
async function loadDs1(member: string): Promise<Ds1> {
|
||||
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<Ds1[]> {
|
||||
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<string, Promise<MazePiece[]>>()
|
||||
function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<MazePiece[]> {
|
||||
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<string, Uint8Array>()
|
||||
async function getPalette(paletteName: string): Promise<Uint8Array> {
|
||||
const cached = paletteCache.get(paletteName)
|
||||
|
|
@ -1317,14 +1123,13 @@ async function getPalette(paletteName: string): Promise<Uint8Array> {
|
|||
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<SceneResult> {
|
||||
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<SceneResult> {
|
|||
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<string, Dt1>()
|
||||
|
|
@ -1963,8 +1682,8 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
|
|||
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<void> {
|
|||
// 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<number, number[]> {
|
||||
const links = new Map<number, number[]>()
|
||||
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<number, number[]>,
|
||||
): Set<number> {
|
||||
const seen = new Set<number>([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<number>()
|
||||
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<string, Ds1>()
|
||||
|
||||
/**
|
||||
* 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<Ds1> {
|
||||
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<Dt1[]> {
|
||||
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<Ds1[]> {
|
||||
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<MazePiece[]> {
|
||||
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')
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)})`,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<T>(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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<s
|
|||
}
|
||||
|
||||
/**
|
||||
* Map one generated Act I outdoor level.
|
||||
* Map one generated level (outdoor, maze, or preset).
|
||||
*
|
||||
* @param input - the level, from the DRLG dump.
|
||||
* @param ctx - tables, DT1 libraries and names.
|
||||
|
|
@ -351,7 +376,6 @@ function libraryOrder(tables: DrlgTables, levelType: number, used: ReadonlySet<s
|
|||
*/
|
||||
export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): DrlgLevelMap {
|
||||
const { levelId, coord } = input
|
||||
if (input.drlgType !== DRLGTYPE_OUTDOOR) throw new Error(`level ${levelId}: not an outdoor level (DrlgType ${input.drlgType})`)
|
||||
const nAct = DRLG_GetActNoFromLevelId(levelId)
|
||||
const cellsX = coord.width + 1
|
||||
const cellsY = coord.height + 1
|
||||
|
|
@ -521,6 +545,22 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
|
|||
}
|
||||
monIdOf.set(unit, i) // lowest DS1 id wins (duplicates carry the same Place)
|
||||
}
|
||||
const crossActMonOf = new Map<number, { readonly act: number; readonly id: number }>()
|
||||
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<number, { gx: number; gy: number }[]>()
|
||||
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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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<number, number>
|
||||
}
|
||||
|
||||
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<number, { super: readonly number[]; normal: readonly number[] }> = {
|
||||
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<number>()
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
2785
src/game/maze.ts
2785
src/game/maze.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<number> = 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<string> = 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',
|
||||
])
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number> = 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<T>(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<Record<string, number>> | 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<string>()
|
||||
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)
|
||||
}
|
||||
124
src/game/rng.ts
124
src/game/rng.ts
|
|
@ -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<T>(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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>),
|
||||
...getDrlgExports(drlgMap as Record<string, unknown>),
|
||||
]
|
||||
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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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<number>()
|
||||
for (const entry of packIndex.levels) {
|
||||
levelIds.add(entry.levelId)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
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> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number>()
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -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<string>()
|
||||
|
||||
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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
for (const level of townLevels) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue