Merge branch 'feat/drlg-496-499' into main (Fixes #496, #497, #498, #499, #500, #501, #502)

This commit is contained in:
troytt 2026-09-28 17:44:56 +00:00
commit 9077b5889d
96 changed files with 6833 additions and 28499 deletions

View File

@ -31,9 +31,9 @@
- 地图未放置有效地面瓦片的纯黑虚空区域,必须在子网格(Sub-tile)上严格打满 `blocked = 1` 与 `COLLIDE_MASK_INVALID`(包含 `COLLIDE_BLANK = 0x0001` 与 `COLLIDE_WALL = 0x0004`)。
- 玩家出生点搜寻(`findIsoSpawn`)**绝对严禁**直接取地图几何中心点 `(width/2, height/2)`。在非矩形或中空地图(如遗忘之塔外景、皇宫、秘密避难所等),中心点极可能是纯黑虚空。
- 出生点必须严格落在“物理连通、有非隐藏有效地面瓦片且 `blocked === 0`”的最大可行走连通分量上,优先靠近传送门(Warp)或小站(Waypoint)。
3. **消除平铺条纹:严禁在空间离散中使用简单线性算术**:
- 地块变体选择(`pickVariant`)严禁使用 `((x * A) ^ (y * B)) % count` 这类简单线性同余公式。在二维离散规则网格上,线性乘积会导致明显的 45 度斜向周期性机械几何平铺条纹。
- 变体散列必须使用高熵伪随机空间哈希(如 SplitMix64 坐标散列),并通过空间方差统计单测确保视觉离散度。
3. **DRLG 精确瓦片绑定与预设 DS1 消除平铺条纹准则**:
- **DRLG 生成关卡(Exact Tile Pinning)**:凡经由 1.13c DRLG 管线(`DRLGROOMTILE_GetTileCache` -> `D2CMP_10088_SelectTileData`)生成的地图,必须在 `Ds1Floor` / `Ds1Wall` 中直接固定(Pin)DRLG 选出的精确 `dt1Library` 与 `dt1Tile`(含 `type 3` 顶角墙伴随的 `type 4` 左半角 `companionDt1Library` / `companionDt1Tile`),渲染器直接按该索引取帧,严禁二次重抽。
- **预设 DS1 关卡(SplitMix64 Variant Fallback)**:仅在不携带 `pTile` 绑定的原始预设 `.ds1` 关卡中,地块变体选择(`pickVariant`)使用高熵伪随机空间哈希(SplitMix64 坐标散列),严禁使用 `((x * A) ^ (y * B)) % count` 这类简单线性同余公式,并通过空间方差统计单测确保视觉离散度。
---

View File

@ -318,7 +318,7 @@ class Browser {
this.child.kill('SIGKILL')
await exited
}
rmSync(this.profile, { recursive: true, force: true })
rmSync(this.profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
}
}
@ -694,10 +694,20 @@ function parseIds(spec: string): Set<number> {
function parseArgs(argv: readonly string[]): Map<string, string> {
const known = new Set(['pack', 'out', 'jobs', 'act', 'levels', 'shots', 'timeout', 'chrome'])
const args = new Map<string, string>()
for (const arg of argv) {
const match = /^--([a-z]+)=(.+)$/.exec(arg)
if (match === null || !known.has(match[1]!)) throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`)
args.set(match[1]!, match[2]!)
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!
const eqMatch = /^--([a-z]+)=(.+)$/.exec(arg)
if (eqMatch !== null && known.has(eqMatch[1]!)) {
args.set(eqMatch[1]!, eqMatch[2]!)
continue
}
const flagMatch = /^--([a-z]+)$/.exec(arg)
if (flagMatch !== null && known.has(flagMatch[1]!) && i + 1 < argv.length) {
args.set(flagMatch[1]!, argv[i + 1]!)
i += 1
continue
}
throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`)
}
return args
}

View File

@ -26,6 +26,7 @@ import { DrlgTraceWriter, dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-
import { firstDiff, firstTraceDivergence, formatDiff, fsDrlgSource, ORACLE_DATA, runOracle } from './lib/drlg-oracle.ts'
interface Args {
act: number | undefined
levels: number[]
seeds: number[]
difficulty: number
@ -39,6 +40,14 @@ interface Args {
traceCheck: boolean
}
const ACT_DEFAULT_OUTDOOR_LEVELS: Record<number, number[]> = {
0: [2, 3, 4, 5, 6, 7, 17, 26, 27, 28, 32, 33, 39],
1: [41, 42, 43, 44, 45, 46],
2: [76, 77, 78, 79, 80, 81, 82, 83],
3: [104, 105, 106, 108],
4: [110, 111, 112, 117, 134],
}
function parseLevels(spec: string): number[] {
const out: number[] = []
for (const part of spec.split(',')) {
@ -54,8 +63,9 @@ function parseLevels(spec: string): number[] {
}
function parseArgs(argv: string[]): Args {
const args: Args = { levels: [], seeds: [], difficulty: 0, isolated: false, dataDir: ORACLE_DATA, stopOnFirst: false, report: null, noActivation: false, traceCheck: false }
const args: Args = { act: undefined, levels: [], seeds: [], difficulty: 0, isolated: false, dataDir: ORACLE_DATA, stopOnFirst: false, report: null, noActivation: false, traceCheck: false }
let nRandom = 0
const positionals: string[] = []
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!
const next = (): string => {
@ -63,7 +73,8 @@ function parseArgs(argv: string[]): Args {
if (v === undefined) throw new Error(`missing value for ${a}`)
return v
}
if (a === '--levels') args.levels = parseLevels(next())
if (a === '--act') args.act = Number(next())
else if (a === '--levels') args.levels = parseLevels(next())
else if (a === '--seeds') nRandom = Number(next())
else if (a === '--seed') args.seeds.push(...next().split(',').filter(Boolean).map(s => Number(s) >>> 0))
else if (a === '--difficulty') args.difficulty = Number(next())
@ -73,8 +84,22 @@ function parseArgs(argv: string[]): Args {
else if (a === '--report') args.report = next()
else if (a === '--no-activation') args.noActivation = true
else if (a === '--trace-check') args.traceCheck = true
else if (!a.startsWith('-')) positionals.push(a)
else throw new Error(`unknown argument ${a}`)
}
if (positionals.length > 0) {
const posAct = Number(positionals[0])
const actIdx = posAct >= 1 && posAct <= 5 ? posAct - 1 : posAct
if (!Number.isInteger(actIdx) || actIdx < 0 || actIdx > 4) throw new Error(`bad positional act "${positionals[0]}"`)
args.act = actIdx
if (args.levels.length === 0) args.levels = [...ACT_DEFAULT_OUTDOOR_LEVELS[actIdx]!]
if (positionals.length >= 2 && nRandom === 0 && args.seeds.length === 0) {
nRandom = Number(positionals[1])
}
}
if (args.act !== undefined && (!Number.isInteger(args.act) || args.act < 0 || args.act > 4)) {
throw new Error('--act must be 0..4')
}
if (!Number.isInteger(nRandom) || nRandom < 0) throw new Error('--seeds must be a non-negative integer')
if (nRandom > 0) args.seeds.push(...webcrypto.getRandomValues(new Uint32Array(nRandom)))
if (args.seeds.length === 0) throw new Error('give --seeds N or --seed X')
@ -119,12 +144,18 @@ function main(): void {
const args = parseArgs(process.argv.slice(2))
const source = fsDrlgSource(args.dataDir)
const tables = loadDrlgTables(source)
const nonOutdoor = args.levels.filter(id => tables.levelDefs[id]?.dwDrlgType !== 3)
if (nonOutdoor.length > 0) {
console.log(`drlg-diff: skipping non-outdoor (dwDrlgType != 3) levels [${nonOutdoor.join(',')}]`)
args.levels = args.levels.filter(id => tables.levelDefs[id]?.dwDrlgType === 3)
}
const results: SeedResult[] = []
const t0 = Date.now()
console.log(`drlg-diff: ${args.seeds.length} seed(s), levels [${args.levels.join(',') || 'act only'}], difficulty ${args.difficulty}${args.isolated ? ', isolated' : ''}${args.noActivation ? ', no activation' : ''}${args.traceCheck ? ', trace check' : ''}`)
const runPort = (seed: number, trace: DrlgTraceWriter | undefined): DrlgDump =>
dumpAct1(createDrlgEnv(source, tables), seed, args.levels, {
...(args.act !== undefined ? { act: args.act } : {}),
difficulty: args.difficulty,
isolated: args.isolated,
actOnly: args.levels.length === 0,
@ -134,7 +165,14 @@ function main(): void {
for (const seed of args.seeds) {
const seedHex = `0x${seed.toString(16).padStart(8, '0')}`
const runOpts = { seed, levels: args.levels, difficulty: args.difficulty, isolated: args.isolated, dataDir: args.dataDir }
const runOpts = {
seed,
levels: args.levels,
...(args.act !== undefined ? { act: args.act } : {}),
difficulty: args.difficulty,
isolated: args.isolated,
dataDir: args.dataDir,
}
let result: SeedResult
try {
const oracleRun = runOracle({ ...runOpts, trace: args.traceCheck })

View File

@ -64,9 +64,17 @@ export function drlgActDt1Files(t: DrlgTables, act1Based: number): string[] {
const key = normalizeDrlgPath(path)
if (!out.has(key)) out.set(key, path)
}
const extraLevelTypes = new Set<number>()
if (act1Based === 5) {
for (let id = 109; id < t.levelDefs.length; id += 1) {
const def = t.levelDefs[id]
if (def && def.dwLevelType > 0) extraLevelTypes.add(def.dwLevelType)
}
}
let rows = 0
for (const rec of t.lvlTypes) {
if (rec.nAct !== act1Based) continue
for (let typeId = 0; typeId < t.lvlTypes.length; typeId += 1) {
const rec = t.lvlTypes[typeId]!
if (rec.nAct !== act1Based && !extraLevelTypes.has(typeId)) continue
rows += 1
// DATATBLS_LoadLevelTypesTxt leaves names of one character or less ("0", "") unprefixed: they are
// unused slots that DRLGROOMTILE_LoadDT1FilesForRoom never opens.

View File

@ -39,6 +39,7 @@ export interface OracleRunOptions {
readonly seed: number
/** Empty: act stage only. */
readonly levels: readonly number[]
readonly act?: number
readonly difficulty?: number
readonly isolated?: boolean
readonly trace?: boolean
@ -58,6 +59,7 @@ export function runOracle(opts: OracleRunOptions): OracleRunResult {
const tracePath = join(dir, 'trace.txt')
const args = ['--data', opts.dataDir ?? ORACLE_DATA, '--seed', `0x${(opts.seed >>> 0).toString(16)}`, '--out', out]
if (opts.levels.length > 0) args.push('--levels', opts.levels.join(','))
if (opts.act !== undefined) args.push('--act', String(opts.act))
if (opts.difficulty !== undefined) args.push('--difficulty', String(opts.difficulty))
if (opts.isolated) args.push('--isolated')
if (opts.trace) args.push('--trace', tracePath)

View File

@ -54,11 +54,7 @@ import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts'
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind, MazeWaypoint } from '../src/game/maze.ts'
import { generateWilderness } from '../src/game/wilderness.ts'
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { generatePreset } from '../src/game/preset.ts'
import type { PresetPiece } from '../src/game/preset.ts'
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { outdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import type { DrlgEntrance, DrlgLevelInput } from '../src/game/drlg/drlg-map.ts'
import { dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
@ -68,10 +64,10 @@ import type { ActLayout, VariantEntry, VariantIndex } from '../src/game/act-vari
import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/drlg-mpq-source.ts'
/**
* An entrance a generator reports: the wilderness generator's, or the DRLG adapter's (seams carry a
* span) with the display label the bake gives it.
* An entrance a generator reports: the DRLG adapter's (seams carry a span) with the display label
* the bake gives it.
*/
type GeneratorEntrance = WildernessEntrance | (DrlgEntrance & { readonly label: string })
type GeneratorEntrance = DrlgEntrance & { readonly label: string }
export interface MazeWarp {
readonly room: number
readonly direction: 'up' | 'down'
@ -81,13 +77,13 @@ export interface MazeWarp {
readonly centreY: number
}
import {
assignGateSides, buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp,
buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp,
parseLevelRows, parseWarpGeometry, SIDES,
} from '../src/game/world-graph.ts'
import type { Side, WorldEdge, WorldGraph } from '../src/game/world-graph.ts'
import {
cellToSubTile, findBorderOpening, findWaypointSpot, largestWalkableRegion, nearestWalkable,
seamArrivalSpot, triggerableFrom, WARP_TRIGGER_SUBTILES,
triggerableFrom, WARP_TRIGGER_SUBTILES,
} from '../src/game/level-links.ts'
import type {
BorderOpening, LinkGrid, SceneEntrance, SceneLinks, SceneWarp, SceneWaypoint, WalkableRegion,
@ -412,10 +408,7 @@ for (const row of monstats2Table.rows) {
*/
const ACT_LAYOUT_SEED = 0x5eed_2000
const worldGraph: WorldGraph = assignGateSides(
buildWorldGraph(parseLevelRows(tables.levels)),
ACT_LAYOUT_SEED,
)
const worldGraph: WorldGraph = buildWorldGraph(parseLevelRows(tables.levels))
const warpGeometry = parseWarpGeometry(tables.lvlwarp)
/**
@ -464,7 +457,7 @@ function levelNameOf(levelId: number): string {
}
/* ------------------------------------------------------------------------- *
* DRLG port (Act I outdoor levels)
* DRLG port (Acts I–V outdoor levels)
* ------------------------------------------------------------------------- */
/** Copies the bake makes of every act the DRLG port generates, one game seed each. */
@ -487,15 +480,14 @@ function actGameSeed(act: number, variant: number): number {
}
/**
* The levels of an act the DRLG port generates: Act I's outdoor levels (Levels.txt DrlgType 3). The
* other acts still bake through the legacy generators.
* The levels of an act the DRLG port generates: all outdoor levels (Levels.txt DrlgType 3) of that act.
*
* @param t - the DRLG tables.
* @param act - act number (1..5).
* @returns level ids, ascending.
*/
function drlgLevelsOf(t: DrlgTables, act: number): readonly number[] {
return act === 1 ? act1OutdoorLevelIds(t) : []
return outdoorLevelIds(t, act - 1)
}
/** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */
@ -537,7 +529,7 @@ async function generateDrlgActs(wanted: (act: number, levelId: number) => boolea
const copies: DrlgActCopy[] = []
for (let variant = 0; variant < DRLG_ACT_VARIANTS; variant += 1) {
const gameSeed = actGameSeed(act, variant)
const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels)
const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels, { act: act - 1 })
const townFile = dump.act.find(level => level.id === town)?.preset?.map?.file
if (townFile === undefined || townFile === '') {
throw new Error(`act ${String(act)} seed 0x${gameSeed.toString(16)}: the generated act picked no DS1 for its town (level ${String(town)})`)
@ -585,28 +577,6 @@ function validateActLayouts(index: VariantIndex<VariantEntry>): void {
}
}
/**
* The border seams an outdoor level must cut, as the generator wants them.
*
* @param levelId - the level being generated.
* @returns one gate per seamless edge leaving it.
*/
function gatesFor(levelId: number): PlannedGate[] {
const gates: PlannedGate[] = []
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
gates.push({
side: edge.sideFrom,
toLevelId: edge.to,
label: `${levelNameOf(edge.to)} Seam`,
// The Burial Grounds seam is the one place the border art has a
// dedicated opening: variant 4 is the graveyard gate, 3 the plain road.
variant: (levelId === 3 && edge.to === 17) || (levelId === 17 && edge.to === 3) ? 4 : 3,
})
}
return gates
}
/**
* Turn a graph edge and a place into a baked warp.
*
@ -771,6 +741,29 @@ function buildSceneLinks(
for (const slot of edge.warpSlots) warpEdgeBySlot.set(slot, edge)
}
/**
* Maximum number of simultaneous warp instances from `levelId` to `toLevelId`:
* in Levels.txt, 4-orientation cave mouths use 4 distinct `Warp` IDs (so only 1
* is placed per seed), whereas levels with multiple coexisting entrances to the
* same destination (such as Kurast Bazaar 80 -> Sewers 92 and Upper Kurast 81 ->
* Sewers 92) repeat the same `(Vis, Warp)` pair across multiple slots.
*/
const levelRow = worldGraph.levels.get(levelId)
const placedWarpCounts = new Map<number, number>()
const maxWarpsTo = (toLevelId: number): number => {
if (levelRow === undefined) return 1
const byWarpId = new Map<number, number>()
let maxCount = 1
for (let slot = 0; slot < 8; slot += 1) {
if ((levelRow.vis[slot] ?? 0) !== toLevelId) continue
const warpId = levelRow.warp[slot] ?? -1
const next = (byWarpId.get(warpId) ?? 0) + 1
byWarpId.set(warpId, next)
if (next > maxCount) maxCount = next
}
return maxCount
}
/** Which way a crossing leads, for the runtime's benefit. */
const here = townDistance.get(levelId) ?? Number.MAX_SAFE_INTEGER
const directionTo = (toLevelId: number): SceneWarp['direction'] =>
@ -822,49 +815,44 @@ function buildSceneLinks(
placedSeams.add(anchor.toLevelId)
continue
}
if (exactLinks && anchor.kind === 'gate') {
if (anchor.kind === 'gate') {
throw new Error(`level ${String(levelId)}: seam to level ${String(anchor.toLevelId)} has no side and span`)
}
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) {
// The anchor the generator hands over is the border *piece*, and the
// centre of that cell is wall; on some outdoor variants the border
// block's gap is separated from the main play area by up to ~40
// sub-tiles of rock. Snapping to `region` with a 64-sub-tile radius
// places the seam trigger on the outer boundary of the main play area,
// and `seamArrivalSpot` steps inward outside the trigger radius.
const gate = nearestWalkable(grid, cellToSubTile(anchor.x), cellToSubTile(anchor.y), 64, region)
?? { x: cellToSubTile(anchor.x), y: cellToSubTile(anchor.y) }
const arrive = seamArrivalSpot(grid, gate.x, gate.y, anchor.side, region)
outEntrances.push({
toLevelId: anchor.toLevelId,
side: anchor.side,
label: anchor.label,
x: gate.x,
y: gate.y,
arriveX: arrive.x,
arriveY: arrive.y,
})
placedSeams.add(anchor.toLevelId)
continue
}
// A preset's mouth: the cave, tower or crypt that opens off this level.
const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId)
const warp = 'warp' in anchor ? anchor.warp : undefined
const currentWarpCount = edge === undefined ? 0 : (placedWarpCounts.get(edge.to) ?? 0)
const allowedWarps = edge === undefined ? 1 : maxWarpsTo(edge.to)
if (exactLinks) {
if (edge === undefined) {
throw new Error(`level ${String(levelId)}: the generator has a warp to level ${String(anchor.toLevelId)}, the world graph does not`)
}
if (placedWarps.has(edge.to)) {
throw new Error(`level ${String(levelId)}: two warps lead to level ${String(anchor.toLevelId)}`)
if (currentWarpCount >= allowedWarps) {
throw new Error(`level ${String(levelId)}: more than ${String(allowedWarps)} warp(s) lead to level ${String(anchor.toLevelId)}`)
}
if (warp === undefined) {
throw new Error(`level ${String(levelId)}: warp to level ${String(anchor.toLevelId)} has no warp unit position`)
}
}
if (edge === undefined || placedWarps.has(edge.to)) continue
if (edge === undefined || currentWarpCount >= allowedWarps) continue
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region,
warp === undefined ? undefined : { x: warp.subX, y: warp.subY }))
placedWarps.add(anchor.toLevelId)
placedWarpCounts.set(edge.to, currentWarpCount + 1)
}
/**
* In Act 3 Jungle, DRLGOUTPLACE_InitAct3OutdoorLevel connects Flayer Jungle (78) to either
* Great Marsh (77, linear: 76<->77<->78) or directly to Spider Forest (76, branching: 76<->77
* dead-end and 76<->78). Whichever of those two alternative seams is not active on this seed
* is not expected on this copy.
*/
const isInactiveJungleSeam = (toLevelId: number): boolean => {
if (levelId === 76 && toLevelId === 78) return placedSeams.has(77)
if (levelId === 77 && toLevelId === 78) return placedSeams.has(76)
if (levelId === 78 && toLevelId === 76) return placedSeams.has(77)
if (levelId === 78 && toLevelId === 77) return placedSeams.has(76)
return false
}
if (exactLinks) {
@ -872,7 +860,7 @@ function buildSceneLinks(
// Levels.txt rows (Vis0..7 / Warp0..7); any difference is a bug in one of
// them, never something to paper over with a guessed opening.
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !isInactiveJungleSeam(edge.to)) {
throw new Error(`level ${String(levelId)}: the world graph has a seam to level ${String(edge.to)}, the generator does not`)
}
if (edge.kind === 'warp' && !placedWarps.has(edge.to)) {
@ -907,8 +895,7 @@ function buildSceneLinks(
// Presets always land here, because their artwork is fixed and nobody
// wrote down where the gate is. So do the two mazes with a seamless edge
// (the Barracks opens onto the Courtyard, Lava 1 onto the Chaos
// Sanctuary), and outdoor levels whose grid is too small for the planner
// to run, like the 6x2 Kurast docks.
// Sanctuary).
//
// The graph's side is usually right, but not always. The Rogue Encampment
// ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which
@ -926,16 +913,16 @@ function buildSceneLinks(
// than one seam there is no way to tell which border contact belongs to
// which neighbour, so the graph's sides stand.
const loneSeams = [...edgesFrom(worldGraph, levelId)]
.filter(edge => edge.kind === 'seamless' && edge.sideFrom !== null && !placedSeams.has(edge.to))
.filter(edge => edge.kind === 'seamless' && !placedSeams.has(edge.to) && !isInactiveJungleSeam(edge.to))
for (const edge of loneSeams) {
const wanted = edge.sideFrom!
const wanted = edge.sideFrom
const measured = new Map<Side, BorderOpening>()
for (const side of SIDES) {
const found = findBorderOpening(grid, side, region)
if (found !== null) measured.set(side, found)
}
const asked = measured.get(wanted)
const asked = wanted === null ? undefined : measured.get(wanted)
let side: Side | null = asked === undefined ? null : wanted
let opening = asked
@ -1018,7 +1005,7 @@ function buildSceneLinks(
// twice: once with the real reason and once with this generic one.
const reported = new Set(unplaced.map(hole => hole.toLevelId))
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to) && !isInactiveJungleSeam(edge.to)) {
unplaced.push({ toLevelId: edge.to, reason: 'seam not cut by the generator' })
}
}
@ -1031,7 +1018,10 @@ function buildSceneLinks(
// an approximate position.
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
const waypoints: SceneWaypoint[] = []
if (exactLinks && waypointId === 255 && waypointCells.length > 0) {
// Level 134 (LEVEL_PANDEMONIUMRUN2, Forgotten Sands) stamps LVLPREST_ACT2_DESERT_VALLEY_WARP
// in DRLGOUTDESR_InitAct2OutdoorLevel (D2Common.0x6FDD58A0), which carries the Canyon of the
// Magi waypoint DS1 object even though Forgotten Sands has Waypoint=255 in Levels.txt.
if (exactLinks && waypointId === 255 && waypointCells.length > 0 && levelId !== 134) {
throw new Error(`level ${String(levelId)}: the generator placed a waypoint, Levels.txt has none`)
}
if (waypointId !== 255) {
@ -1071,7 +1061,7 @@ const allNames = await archives.listFiles()
const drlg = Number(cell(tables.levels, row, 'DrlgType'))
const act = Number(cell(tables.levels, row, 'Act')) + 1
let kind: LevelKind | null = null
if (drlg === 2 || levelId === 108) kind = 'preset'
if (drlg === 2) kind = 'preset'
else if (drlg === 1) kind = 'maze'
else if (drlg === 3) kind = 'wilderness'
if (kind === null) continue
@ -1249,7 +1239,6 @@ async function isObjectCofAdditive(token: string, modeIndex: number, baseIsMonst
}
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
/** The level type row for a level row. */
function levelType(levelRow: readonly string[]): { id: string; name: string } {
@ -1284,26 +1273,6 @@ async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
return levels
}
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
/**
* The LvlPrest families the legacy outdoor generator draws its pieces from, by level type. Act I is not
* here: its outdoor levels bake only through the DRLG port ('drlg' jobs, see generateDrlgActs).
*/
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo', 'Act 4 - Bridge'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
const mazePiecesCache = new Map<string, Promise<MazePiece[]>>()
function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<MazePiece[]> {
void levelTypeId
@ -1336,86 +1305,6 @@ function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<Maze
return promise
}
const BROKEN_ACT5_BARRICADE_PRESETS = new Set([
'Act 5 - Barricade Cliff Border 1',
'Act 5 - Barricade Cliff Border 4',
'Act 5 - Barricade Cliff Border 5',
'Act 5 - Barricade Ravine Border 3',
'Act 5 - Barricade Ravine Border 12',
'Act 5 - Barricade Cliff Border 4 Snow',
'Act 5 - Barricade Cliff Border 5 Snow',
'Act 5 - Barricade Cliff Border 9 Snow',
'Act 5 - Barricade Cliff Border 10 Snow',
'Act 5 - Barricade Cliff Border 12 Snow',
'Act 5 - Barricade Ravine Border 2 Snow',
'Act 5 - Barricade Ravine Border 3 Snow',
'Act 5 - Barricade Ravine Border 6 Snow',
'Act 5 - Barricade Ravine Border 7 Snow',
'Act 5 - Barricade Ravine Border 9 Snow',
'Act 5 - Barricade Ravine Border 10 Snow',
'Act 5 - Barricade Ravine Border 12 Snow',
])
const wildernessPiecesCache = new Map<string, Promise<WildernessPiece[]>>()
function getWildernessPieces(levelTypeName: string, subType?: number, levelName?: string): Promise<WildernessPiece[]> {
const isSnow = subType === 11 || (levelName !== undefined && /snow/i.test(levelName))
const cacheKey = `${levelTypeName}:${isSnow ? 'snow' : 'dirt'}`
const cached = wildernessPiecesCache.get(cacheKey)
if (cached !== undefined) return cached
const promise = (async () => {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade') {
if (BROKEN_ACT5_BARRICADE_PRESETS.has(name)) continue
if (isSnow && !/snow/i.test(name)) continue
if (!isSnow && /snow/i.test(name)) continue
}
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff|ravine/i.test(name)
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) })
}
return pieces
})()
wildernessPiecesCache.set(cacheKey, promise)
return promise
}
const substitutionsCache = new Map<number, Promise<WildernessSubstitution[]>>()
function getSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return Promise.resolve([])
const cached = substitutionsCache.get(type)
if (cached !== undefined) return cached
const promise = (async () => {
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(tileMemberPath(file))]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: themeValues(lvlsub, row, 'Prob'),
trials: themeValues(lvlsub, row, 'Trials'),
max: themeValues(lvlsub, row, 'Max'),
levels,
})
}
return rows
})()
substitutionsCache.set(type, promise)
return promise
}
const paletteCache = new Map<string, Uint8Array>()
async function getPalette(paletteName: string): Promise<Uint8Array> {
const cached = paletteCache.get(paletteName)
@ -1428,9 +1317,9 @@ async function getPalette(paletteName: string): Promise<Uint8Array> {
interface SceneJob {
readonly jobId: number
readonly entry: LevelJob
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' | 'drlg'
readonly type: 'preset-ds1' | 'maze' | 'drlg'
readonly ds1Name?: string
/** Copy number: 1-based for the legacy generators, the 0-based act copy (`actVariant`) for 'drlg'. */
/** Copy number: 1-based for the maze generator, the 0-based act copy (`actVariant`) for 'drlg'. */
readonly variant?: number
readonly label: string
readonly seed: number
@ -1478,11 +1367,12 @@ async function bakeDs1Variant(
/** The generator reports every seam and warp exactly (see buildSceneLinks). */
readonly exactLinks?: boolean
/**
* `'layout'`: every room, tile (style, sequence, type, DT1 library) and preset unit is D2's own
* (the DRLG port); only the variant within a tile's (style, sequence) is the renderer's pick.
* `'exact'`: every room, tile (style, sequence, type, DT1 library, and variant) and preset unit
* is D2's own (the DRLG port).
* `'layout'`: only the variant within a tile's (style, sequence) is the renderer's pick.
* Unset: the generator approximates the level.
*/
readonly fidelity?: 'layout'
readonly fidelity?: 'exact' | 'layout'
} = { entrances: [], mazeWarps: [] },
animSpeedOverride?: number,
): Promise<SceneResult> {
@ -1510,9 +1400,6 @@ async function bakeDs1Variant(
/**
* Waypoint pedestals found in the artwork, in sub-tiles.
*
* Only presets have one: the outdoor and maze generators do not run the pass
* that spawns waypoints, so their levels fall back to a placed position.
*/
const waypointCells: { x: number; y: number }[] = []
for (const object of level.objects) {
@ -1544,37 +1431,7 @@ async function bakeDs1Variant(
)
if (sceneLinks.waypoints.some(wp => wp.source !== 'object')) {
const defaultWpDs1Id = entry.act === 1 ? 37 : entry.act === 2 ? 10 : entry.act === 3 ? 1 : entry.act === 4 ? 0 : 120
const nextObjects = [...level.objects]
const nextWaypoints: SceneWaypoint[] = []
for (const wp of sceneLinks.waypoints) {
if (wp.source !== 'object') {
nextObjects.push({
type: 2,
id: defaultWpDs1Id,
x: wp.x,
y: wp.y,
flags: 0,
})
nextWaypoints.push({ ...wp, source: 'object' })
} else {
nextWaypoints.push(wp)
}
}
level = { ...level, objects: nextObjects }
;(sceneLinks as { waypoints: readonly SceneWaypoint[] }).waypoints = nextWaypoints
;(scene as { automap?: unknown }).automap = buildLevelAutomap(level, {
automapTable: tables.automap,
levelTypeId,
levelId: entry.levelId,
act: entry.act,
layerId,
ds1Name,
objectsTable: objectsTableTyped,
monstersTable,
monstats2ById,
seed,
})
throw new Error(`${label} (level ${String(entry.levelId)}): waypoint resolved to fallback source instead of authentic DS1 object`)
}
const spawn = findIsoSpawn(scene, { warps: sceneLinks.warps, entrances: sceneLinks.entrances })
@ -1875,9 +1732,10 @@ async function bakeDs1Variant(
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]
}
const fidelity = entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation'
const sceneJson = {
version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation',
fidelity,
act: entry.act,
levelId: entry.levelId,
levelName,
@ -1965,6 +1823,7 @@ async function bakeDs1Variant(
act: entry.act,
levelId: entry.levelId,
kind: entry.kind,
fidelity,
slug: entry.slug,
label,
levelName,
@ -2033,44 +1892,6 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
)
}
if (job.type === 'preset-chaos') {
const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
const pieces: PresetPiece[] = []
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
const prestRow = tables.lvlprest.rows.find(r => {
for (let file = 1; file <= 6; file += 1) {
const f = cell(tables.lvlprest, r, `File${String(file)}`)
if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true
}
return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase())
})
const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) })
}
const result = generatePreset({
levelId: job.entry.levelId,
levelName: info.levelName,
sizeX: info.sizeX > 0 ? info.sizeX : 120,
sizeY: info.sizeY > 0 ? info.sizeY : 120,
seed: job.seed,
pieces,
})
return await bakeDs1Variant(
job.jobId,
job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
result.level, `preset:${job.label}`, job.label, job.seed,
{ entrances: [], mazeWarps: [] },
(result.stats.animSpeed as number | undefined) ?? info.animSpeed,
)
}
if (job.type === 'maze') {
const type = levelType(levelRow)
const row = mazeRow(job.entry.levelId, job.entry.name)
@ -2145,7 +1966,7 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
mazeWarps: [],
landmarks: map.landmarks,
exactLinks: true,
fidelity: 'layout',
fidelity: 'exact',
notes: [
`DRLG port: act ${String(job.entry.act)} copy ${String(job.variant)}, game seed 0x${drlg.gameSeed.toString(16)}`,
`DRLG tiles: ${String(s.walls)} walls, ${String(s.floors)} floors, ${String(s.shadows)} shadows, `
@ -2156,55 +1977,6 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
return { ...result, indexEntry: { ...result.indexEntry, actVariant: job.variant } }
}
if (job.type === 'wilderness') {
const type = levelType(levelRow)
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await getWildernessPieces(type.name, subType, job.entry.name)
if (pieces.length === 0) {
throw new Error(`skipping wilderness ${job.entry.name}: no wilderness pieces`)
}
const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
const resolvedLibraries: Dt1[] = []
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
const gates = gatesFor(job.entry.levelId)
const result = generateWilderness({
levelId: job.entry.levelId,
levelName: job.entry.name,
levelTypeName: type.name,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed: job.seed,
pieces,
substitutions: rows,
shrineSubstitutions: shrineRows,
gates,
dt1Libraries: resolvedLibraries,
})
return await bakeDs1Variant(
job.jobId,
job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
result.level, `generated:${job.label}`, job.label, job.seed,
{
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
mazeWarps: [],
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
...(Array.isArray(result.stats.landmarkEntities)
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
: {}),
...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}),
},
)
}
throw new Error(`Unknown job type: ${(job as { type: string }).type}`)
}
@ -2214,7 +1986,7 @@ async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
* @param filterLevels - optional filter set of level IDs.
* @param filterAct - optional filter for a specific act.
* @param drlgActs - the DRLG-generated act copies ({@link generateDrlgActs}); their levels bake one
* 'drlg' job per copy instead of going through the legacy wilderness generator.
* 'drlg' job per copy.
* @returns ordered array of scene jobs with sequential jobIds (0..N-1).
*/
async function collectSceneJobs(
@ -2233,28 +2005,15 @@ async function collectSceneJobs(
if (entry.kind === 'preset') {
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
if (entry.levelId === 108) {
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-chaos',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_1000 + entry.levelId * 10 + v,
})
}
} else {
for (const ds1Name of info.ds1Names) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-ds1',
ds1Name,
label: `${entry.slug}-${slugOf(ds1Name)}`,
seed: levelSeed(ds1Name),
})
}
for (const ds1Name of info.ds1Names) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-ds1',
ds1Name,
label: `${entry.slug}-${slugOf(ds1Name)}`,
seed: levelSeed(ds1Name),
})
}
} else if (entry.kind === 'maze') {
const type = levelType(levelRow)
@ -2280,39 +2039,22 @@ async function collectSceneJobs(
}
} else if (entry.kind === 'wilderness') {
const copies = drlgActs.get(entry.act)
if (copies !== undefined && copies.some(copy => copy.layout.levels.includes(entry.levelId))) {
for (const copy of copies) {
const input = copy.inputs.get(entry.levelId)
if (input === undefined) {
throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`)
}
jobs.push({
jobId: jobId++,
entry,
type: 'drlg',
variant: copy.layout.variant,
label: `${entry.slug}-var${String(copy.layout.variant + 1)}`,
seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1,
drlg: { gameSeed: copy.layout.gameSeed, input },
})
if (copies === undefined || !copies.some(copy => copy.layout.levels.includes(entry.levelId))) {
throw new Error(`outdoor level ${String(entry.levelId)} (${entry.name}) was not generated by DRLG for act ${String(entry.act)}`)
}
for (const copy of copies) {
const input = copy.inputs.get(entry.levelId)
if (input === undefined) {
throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`)
}
continue
}
const type = levelType(levelRow)
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const pieces = await getWildernessPieces(type.name, subType, entry.name)
if (pieces.length === 0) {
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
continue
}
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'wilderness',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_1000 + entry.levelId * 10 + v,
type: 'drlg',
variant: copy.layout.variant,
label: `${entry.slug}-var${String(copy.layout.variant + 1)}`,
seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1,
drlg: { gameSeed: copy.layout.gameSeed, input },
})
}
}

View File

@ -279,7 +279,7 @@ function buildTokenJoin(
for (const row of monstats.rows) {
const token = cell(monstats, row, 'Code').trim().toUpperCase()
const ex = cell(monstats, row, 'MonStatsEx').trim()
if (token.length === 0 || token === 'XX') continue
if (token.length === 0) continue
if (ex.length === 0 || !rows.has(ex)) continue
const ids = join.get(token) ?? []
if (!ids.includes(ex)) ids.push(ex)
@ -301,7 +301,7 @@ function bakedMonsterTokens(): Set<string> {
const tokens = new Set<string>()
for (let act = 1; act <= 5; act += 1) {
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
if (!spec.token) continue
tokens.add(spec.token.toUpperCase())
}
}
@ -314,7 +314,7 @@ function bakedMonsterTokens(): Set<string> {
}
for (const id of monsterIds) {
const spec = resolveMonsterArtSpec(id)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') tokens.add(spec.token.toUpperCase())
if (spec && spec.token) tokens.add(spec.token.toUpperCase())
}
return tokens
}

View File

@ -735,7 +735,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
if (!spec.token) continue
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -762,7 +762,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (const monsterId of allSuperUniqueMonsterIds) {
const spec = resolveMonsterArtSpec(monsterId)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
if (spec && spec.token) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -773,7 +773,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
// Include special summon and minion entities (Valkyrie, Decoy/Dopplezon)
for (const summonId of ['valkyrie', 'dopplezon']) {
const spec = resolveMonsterArtSpec(summonId)
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
if (spec && spec.token) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
@ -804,7 +804,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
for (let act = 1; act <= 5; act += 1) {
const specs = ACT_MONSTER_SPECS[act] ?? []
for (const spec of specs) {
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
if (!spec.token) continue
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
const list = monsterActsMap.get(key) ?? []
if (!list.includes(act)) list.push(act)

View File

@ -35,14 +35,15 @@ import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { decodePal } from '../src/formats/pal.ts'
import { cell, parseTable } from '../src/game/acts.ts'
import { buildIsoMapScene, type IsoDraw, type IsoMapScene } from '../src/game/d2map.ts'
import { DRLG_GetActNoFromLevelId } from '../src/game/drlg/drlg-drlg.ts'
import { dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
import { act1OutdoorLevelIds, auditDrlgDt1Ambiguity, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgDt1Ambiguity, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
import { act1OutdoorLevelIds, auditDrlgDt1Ambiguity, buildDrlgLevelMap, drlgLevelInputFromDump, outdoorLevelIds, type DrlgDt1Ambiguity, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from './lib/drlg-mpq-source.ts'
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables, type DrlgMpqData } from './lib/drlg-mpq-source.ts'
import { firstDiff, formatDiff, ORACLE_BIN, runOracle } from './lib/drlg-oracle.ts'
import { encodeIndexedPng, encodeRgbaPng } from './png.ts'
@ -51,7 +52,6 @@ const ARCHIVE_DIR = join(ROOT, 'samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
const LEVELS_TXT = 'data\\global\\excel\\Levels.txt'
const ACT1_PALETTE = 'data\\global\\palette\\act1\\pal.dat'
/** Blueprint pixels per sub-tile. */
const BLUEPRINT_SCALE = 3
/** Tiles per side of an outdoor room (DRLG grid cell), for the blueprint grid lines. */
@ -65,9 +65,9 @@ const BLUEPRINT_PALETTE: readonly (readonly [number, number, number])[] = [
[12, 16, 22], [36, 78, 44], [198, 158, 86], [24, 40, 30], [240, 70, 70], [255, 226, 90],
]
/** The bake's act-copy game seed (scripts/pack-act-assets.ts actGameSeed, act 1). */
function act1GameSeed(copy: number): number {
return (0x5eed_0000 + 1 * 0x100 + copy) >>> 0
/** The bake's act-copy game seed (scripts/pack-act-assets.ts actGameSeed, act 1..5). */
function actGameSeed(act1Based: number, copy: number): number {
return (0x5eed_0000 + act1Based * 0x100 + copy) >>> 0
}
/** The seed the bake gives the scene of a DRLG level copy (scripts/pack-act-assets.ts, job type 'drlg'). */
@ -79,10 +79,29 @@ function slug(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')
}
function parseLevelsSpec(spec: string): number[] {
const out: number[] = []
for (const part of spec.split(',').map(s => s.trim()).filter(Boolean)) {
const range = /^(\d+)-(\d+)$/.exec(part)
if (range !== null) {
const lo = Number(range[1])
const hi = Number(range[2])
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo <= 0 || hi < lo) throw new Error(`bad --levels ${spec}`)
for (let id = lo; id <= hi; id += 1) out.push(id)
} else {
const id = Number(part)
if (!Number.isInteger(id) || id <= 0) throw new Error(`bad --levels ${spec}`)
out.push(id)
}
}
return out
}
interface Args {
readonly copy: number
readonly seed: number
readonly levels: readonly number[]
readonly seed?: number
readonly levels?: readonly number[]
readonly allActs: boolean
readonly out: string
readonly scale: number
}
@ -98,14 +117,15 @@ function parseArgs(argv: readonly string[]): Args {
const copy = Number(values.get('copy') ?? '0')
if (!Number.isInteger(copy) || copy < 0 || copy > 2) throw new Error('--copy must be 0, 1 or 2')
const seedText = values.get('seed')
const seed = seedText === undefined ? act1GameSeed(copy) : Number(seedText) >>> 0
if (seedText !== undefined && !/^(0x[0-9a-f]+|\d+)$/i.test(seedText)) throw new Error(`bad --seed ${seedText}`)
const levels = (values.get('levels') ?? '2,3,4').split(',').map(Number)
if (levels.some(id => !Number.isInteger(id) || id <= 0)) throw new Error(`bad --levels ${values.get('levels') ?? ''}`)
const seed = seedText === undefined ? undefined : Number(seedText) >>> 0
const levelsSpec = values.get('levels')
const allActs = levelsSpec === 'all'
const levels = levelsSpec === undefined || allActs ? undefined : parseLevelsSpec(levelsSpec)
const scale = Number(values.get('scale') ?? '4')
if (!Number.isInteger(scale) || scale < 1) throw new Error('--scale must be a positive integer')
const out = resolve(values.get('out') ?? '/tmp/drlg-compare')
return { copy, seed, levels, out, scale }
return { copy, ...(seed !== undefined ? { seed } : {}), ...(levels !== undefined ? { levels } : {}), allActs, out, scale }
}
// ---------------------------------------------------------------------------------------------
@ -277,6 +297,54 @@ function blueprintXor(a: Blueprint, b: Blueprint): { differing: number; png: Uin
return { differing, png: encodeIndexedPng({ width: a.width, height: a.height, pixels, palette }) }
}
/**
* Count how many visible draws in `scene` differ in `(library, tile)` from the exact `pTile`
* pointers chosen by the native D2MOO oracle in `oracleDump`.
*/
function countVariantDiffs(oracleDump: DrlgDump, levelId: number, tables: DrlgMpqData['tables'], map: DrlgLevelMap, scene: IsoMapScene): number {
const input = drlgLevelInputFromDump(oracleDump, levelId, tables)
const cellsX = input.coord.width + 1
const cellsY = input.coord.height + 1
const seen = new Set<string>()
const expectedByCellKind = new Map<string, string[]>()
for (const t of input.tiles) {
if (seen.has(t.key)) continue
seen.add(t.key)
if (t.x < 0 || t.y < 0 || t.x >= cellsX || t.y >= cellsY) continue
if (t.tile === null) continue
// buildIsoMapScene skips hidden floors and walls (MAPTILE_HIDDEN = 0x8), while shadows are drawn whenever prop1 !== 0
if ((t.kind === 'floor' || t.kind === 'wall') && (t.flags & 0x0000_0008) !== 0) continue
const key = `${String(t.x)},${String(t.y)},${t.kind}`
const list = expectedByCellKind.get(key) ?? []
list.push(`${t.tile.library}#${String(t.tile.index)}`)
expectedByCellKind.set(key, list)
}
const actualByCellKind = new Map<string, string[]>()
const recordDraw = (draw: IsoDraw, kind: 'floor' | 'shadow' | 'wall'): void => {
const key = `${String(draw.cellX)},${String(draw.cellY)},${kind}`
const list = actualByCellKind.get(key) ?? []
list.push(`${map.dt1Names[draw.library] ?? '?'}#${String(draw.tile)}`)
actualByCellKind.set(key, list)
}
for (const d of scene.floors) recordDraw(d, 'floor')
for (const d of scene.shadows) recordDraw(d, 'shadow')
for (const d of scene.walls) recordDraw(d, 'wall')
for (const d of scene.roofs) recordDraw(d, 'wall')
const allKeys = new Set([...expectedByCellKind.keys(), ...actualByCellKind.keys()])
let diffs = 0
for (const key of allKeys) {
const exp = (expectedByCellKind.get(key) ?? []).slice().sort()
const act = (actualByCellKind.get(key) ?? []).slice().sort()
const maxLen = Math.max(exp.length, act.length)
for (let i = 0; i < maxLen; i += 1) {
if (exp[i] !== act[i]) diffs += 1
}
}
return diffs
}
// ---------------------------------------------------------------------------------------------
// Driver
@ -296,94 +364,116 @@ async function main(): Promise<void> {
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
const data: DrlgMpqData = await loadDrlgMpqData(member => archives.read(member), 1)
const superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
const palette = decodePal(await archives.read(ACT1_PALETTE))
const baseTables = await loadDrlgMpqTables(member => archives.read(member))
const superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), baseTables)
const levelsTxt = parseTable(await archives.read(LEVELS_TXT))
const levelName = (levelId: number): string => {
const row = levelsTxt.rows.find(r => Number(cell(levelsTxt, r, 'Id')) === levelId)
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
return cell(levelsTxt, row, 'LevelName')
}
// The bake generates the whole act's outdoor list in one run; so do both sides here.
const actLevels = act1OutdoorLevelIds(data.tables)
for (const levelId of args.levels) {
if (!actLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an Act I outdoor level (${actLevels.join(', ')})`)
const allOutdoorLevels = outdoorLevelIds(baseTables)
const defaultActLevels = act1OutdoorLevelIds(baseTables)
const targetLevels = args.allActs ? allOutdoorLevels : (args.levels ?? defaultActLevels)
for (const levelId of targetLevels) {
if (!allOutdoorLevels.includes(levelId)) throw new Error(`level ${String(levelId)} is not an outdoor DRLG level (${allOutdoorLevels.join(', ')})`)
}
const seedHex = `0x${args.seed.toString(16).padStart(8, '0')}`
console.log(`game seed ${seedHex} (act copy ${String(args.copy)}), levels ${args.levels.join(', ')} of [${actLevels.join(', ')}]`)
const oracle: DrlgDump = runOracle({ seed: args.seed, levels: actLevels, difficulty: 0, isolated: false }).doc
delete oracle.oracle
const port: DrlgDump = dumpAct1(createDrlgEnv(data.source, data.tables), args.seed, actLevels, { difficulty: 0, isolated: false })
const dumpDiff = firstDiff(oracle, port, '$')
console.log(dumpDiff === null ? 'dumps: identical' : `dumps: DIFFER at ${formatDiff(dumpDiff)}`)
const acts = [...new Set(targetLevels.map(id => DRLG_GetActNoFromLevelId(id)))].sort((a, b) => a - b)
const dt1Cache = new Map<string, Dt1>()
const library = (name: string): Dt1 => {
let dt1 = dt1Cache.get(name)
if (dt1 === undefined) {
dt1 = decodeDt1(data.source.readFile(name))
dt1Cache.set(name, dt1)
}
return dt1
}
const side = (dump: DrlgDump, levelId: number): Side => {
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), bakeSceneSeed(levelId, args.copy))
return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) }
}
const summary: Record<string, unknown>[] = []
const ambiguityTotal = { refs: 0, variantGroups: 0, crossLibrary: 0, crossLibraryLevelWide: 0 }
let failed = dumpDiff !== null
for (const levelId of args.levels) {
const name = slug(levelName(levelId))
const d2moo = side(oracle, levelId)
const ours = side(port, levelId)
const iso = isoXor(d2moo.iso, ours.iso, args.scale)
const bp = blueprintXor(d2moo.blueprint, ours.blueprint)
const ambiguity: DrlgDt1Ambiguity = auditDrlgDt1Ambiguity(ours.map, ours.map.dt1Names.map(library))
ambiguityTotal.refs += ambiguity.refs
ambiguityTotal.variantGroups += ambiguity.variantGroups
ambiguityTotal.crossLibrary += ambiguity.crossLibrary
ambiguityTotal.crossLibraryLevelWide += ambiguity.crossLibraryLevelWide
const write = (file: string, png: Uint8Array): void => writeFileSync(join(args.out, `${name}_${file}.png`), png)
write('d2moo_blueprint', blueprintPng(d2moo.blueprint))
write('port_blueprint', blueprintPng(ours.blueprint))
write('xor_blueprint', bp.png)
write('d2moo_iso', isoPreview(d2moo.iso, palette.rgb, args.scale))
write('port_iso', isoPreview(ours.iso, palette.rgb, args.scale))
write('xor_iso', iso.png)
const s = ours.map.stats
const row = {
levelId,
name,
canvas: `${String(ours.map.ds1.width)}x${String(ours.map.ds1.height)}`,
walls: s.walls,
floors: s.floors,
shadows: s.shadows,
objects: s.objects,
monsters: s.monsters,
entrances: ours.map.entrances.length,
landmarks: ours.map.landmarks.length,
isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`,
isoDiffering: iso.differing,
blueprintDiffering: bp.differing,
dt1Ambiguity: ambiguity,
let totalVariantDiffs = 0
let allDumpsIdentical = true
let failed = false
let lastSeedHex = ''
for (const actNo of acts) {
const act1Based = actNo + 1
const data: DrlgMpqData = await loadDrlgMpqData(member => archives.read(member), act1Based)
const palette = decodePal(await archives.read(`data\\global\\palette\\act${String(act1Based)}\\pal.dat`))
const actLevels = outdoorLevelIds(data.tables, actNo)
const actTargetLevels = targetLevels.filter(id => DRLG_GetActNoFromLevelId(id) === actNo)
const gameSeed = args.seed ?? actGameSeed(act1Based, args.copy)
const seedHex = `0x${gameSeed.toString(16).padStart(8, '0')}`
lastSeedHex = seedHex
console.log(`game seed ${seedHex} (act ${String(act1Based)} copy ${String(args.copy)}), levels ${actTargetLevels.join(', ')} of [${actLevels.join(', ')}]`)
const oracle: DrlgDump = runOracle({ seed: gameSeed, levels: actLevels, difficulty: 0, isolated: false }).doc
delete oracle.oracle
const port: DrlgDump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, actLevels, { difficulty: 0, isolated: false, act: actNo })
const dumpDiff = firstDiff(oracle, port, '$')
console.log(dumpDiff === null ? 'dumps: identical' : `dumps: DIFFER at ${formatDiff(dumpDiff)}`)
if (dumpDiff !== null) {
allDumpsIdentical = false
failed = true
}
const library = (name: string): Dt1 => {
let dt1 = dt1Cache.get(name)
if (dt1 === undefined) {
dt1 = decodeDt1(data.source.readFile(name))
dt1Cache.set(name, dt1)
}
return dt1
}
const side = (dump: DrlgDump, levelId: number, fallbackSeed: number): Side => {
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const map = buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
const scene = buildIsoMapScene(map.ds1, map.dt1Names.map(library), fallbackSeed)
return { map, scene, iso: renderIso(scene), blueprint: renderBlueprint(map, scene) }
}
for (const levelId of actTargetLevels) {
const name = slug(levelName(levelId))
// Pass distinct fallback seeds (0 vs bakeSceneSeed) so any unpinned tile would diverge between d2moo and ours
const d2moo = side(oracle, levelId, 0)
const ours = side(port, levelId, bakeSceneSeed(levelId, args.copy))
const variantDiffs = countVariantDiffs(oracle, levelId, data.tables, ours.map, ours.scene)
totalVariantDiffs += variantDiffs
const iso = isoXor(d2moo.iso, ours.iso, args.scale)
const bp = blueprintXor(d2moo.blueprint, ours.blueprint)
const ambiguity: DrlgDt1Ambiguity = auditDrlgDt1Ambiguity(ours.map, ours.map.dt1Names.map(library))
ambiguityTotal.refs += ambiguity.refs
ambiguityTotal.variantGroups += ambiguity.variantGroups
ambiguityTotal.crossLibrary += ambiguity.crossLibrary
ambiguityTotal.crossLibraryLevelWide += ambiguity.crossLibraryLevelWide
const write = (file: string, png: Uint8Array): void => writeFileSync(join(args.out, `${name}_${file}.png`), png)
write('d2moo_blueprint', blueprintPng(d2moo.blueprint))
write('port_blueprint', blueprintPng(ours.blueprint))
write('xor_blueprint', bp.png)
write('d2moo_iso', isoPreview(d2moo.iso, palette.rgb, args.scale))
write('port_iso', isoPreview(ours.iso, palette.rgb, args.scale))
write('xor_iso', iso.png)
const s = ours.map.stats
const row = {
levelId,
name,
canvas: `${String(ours.map.ds1.width)}x${String(ours.map.ds1.height)}`,
walls: s.walls,
floors: s.floors,
shadows: s.shadows,
objects: s.objects,
monsters: s.monsters,
entrances: ours.map.entrances.length,
landmarks: ours.map.landmarks.length,
isoPx: `${String(ours.iso.width)}x${String(ours.iso.height)}`,
variantDiffs,
isoDiffering: iso.differing,
blueprintDiffering: bp.differing,
dt1Ambiguity: ambiguity,
}
summary.push(row)
console.log(`L${String(levelId)} ${name}: canvas ${row.canvas}, ${String(s.walls)} walls / ${String(s.floors)} floors / ${String(s.shadows)} shadows, `
+ `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; variantDiffs ${String(variantDiffs)}; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`)
console.log(` DT1 ambiguity: ${String(ambiguity.libraries)} libraries, ${String(ambiguity.refs)} refs, ${String(ambiguity.variantGroups)} with variants, `
+ `${String(ambiguity.crossLibrary)} cross-library under cell masks (${String(ambiguity.crossLibraryLevelWide)} with one level-wide pool)`)
if (variantDiffs !== 0 || iso.differing !== 0 || bp.differing !== 0) failed = true
}
summary.push(row)
console.log(`L${String(levelId)} ${name}: canvas ${row.canvas}, ${String(s.walls)} walls / ${String(s.floors)} floors / ${String(s.shadows)} shadows, `
+ `${String(row.entrances)} links, ${String(row.landmarks)} landmarks; iso ${row.isoPx} px, differing ${String(iso.differing)}; blueprint differing ${String(bp.differing)}`)
console.log(` DT1 ambiguity: ${String(ambiguity.libraries)} libraries, ${String(ambiguity.refs)} refs, ${String(ambiguity.variantGroups)} with variants, `
+ `${String(ambiguity.crossLibrary)} cross-library under cell masks (${String(ambiguity.crossLibraryLevelWide)} with one level-wide pool)`)
if (iso.differing !== 0 || bp.differing !== 0) failed = true
}
console.log(`DT1 ambiguity total: ${String(ambiguityTotal.refs)} refs, ${String(ambiguityTotal.variantGroups)} with variants, `
+ `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool)`)
writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: seedHex, copy: args.copy, dumpsIdentical: dumpDiff === null, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1))
console.log(failed ? 'FAIL: the D2MOO and port images differ' : `OK: every XOR image is empty; images in ${args.out}`)
+ `${String(ambiguityTotal.crossLibrary)} cross-library under cell masks (${String(ambiguityTotal.crossLibraryLevelWide)} with one level-wide pool), variantDiffs ${String(totalVariantDiffs)}`)
writeFileSync(join(args.out, 'summary.json'), JSON.stringify({ seed: lastSeedHex, copy: args.copy, dumpsIdentical: allDumpsIdentical, variantDiffs: totalVariantDiffs, dt1Ambiguity: ambiguityTotal, levels: summary }, null, 1))
console.log(failed ? 'FAIL: the D2MOO and port images or tile variants differ' : `OK: 0 variant diffs and every XOR image is empty; images in ${args.out}`)
if (failed) process.exitCode = 1
}

View File

@ -1,758 +0,0 @@
/**
* Standalone Adversarial Stress-Test Script for Milestone M3
* Diablo II Web DRLG Engine: Dynamic Barricade Extent & Reachability (Phase D4a)
*
* Requirements:
* 1. Test edge cases on resolveUnsetSize:
* - Empty pieces array -> assert UnresolvedLevelSizeError is thrown.
* - Non-barricade level without pieces -> assert UnresolvedLevelSizeError is thrown.
* - Non-barricade level with non-barricade pieces -> assert UnresolvedLevelSizeError is thrown.
* - Combinations of entrance/exit piece counts & dimensions (1,000 randomized configurations):
* verify sizeY > sizeX always holds, sizeX in [48..64], sizeY in [160..192].
* 2. Run generation of Level 111 and Level 112 across 50 distinct seeds:
* - Verify sizeX in [48..64] and sizeY in [160..192] for 100% of seeds.
* - Verify sizeY > sizeX holds for 100% of seeds.
* - Verify 100% flood-fill reachability from player spawn for 100% of generated layouts.
* - Verify choke-point gates in palisade walls are traversable:
* - Gate openings are clear of walls and have walkable floor.
* - Sub-tile collision in gate openings is unblocked (blocked === 0).
* - Local gate traversability (South to North through gate) holds.
* - Global corridor traversability (South entrance to North exit) holds.
* 3. Report total assertions and failure count with explicit APPROVE or REJECT verdict.
*
* Archetype: EMPIRICAL CHALLENGER
* Run: npx tsx scripts/stress-barricade-extent.ts [mpq_directory]
*/
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries, type D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { buildIsoMapScene, levelSeed, type IsoMapScene } from '../src/game/d2map.ts'
import { findIsoSpawn } from '../src/game/level-links.ts'
import {
generateWilderness,
resolveUnsetSize,
UnresolvedLevelSizeError,
type WildernessPiece,
type WildernessSubstitution,
type WildernessRequest,
} from '../src/game/wilderness.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
/* ------------------------------------------------------------------------- *
* Test Assertions & Metric Reporting
* ------------------------------------------------------------------------- */
let totalAssertions = 0
let totalFailures = 0
const failureReasons: string[] = []
function assert(condition: boolean, message: string): void {
totalAssertions += 1
if (!condition) {
totalFailures += 1
failureReasons.push(message)
console.error(` ❌ FAILED: ${message}`)
}
}
/* ------------------------------------------------------------------------- *
* Mock DS1 Generator (Hermetic Fallback & Edge-Case Synthesis)
* ------------------------------------------------------------------------- */
function makeMockDs1(width: number, height: number, floorStyle: number = 1): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
/* ------------------------------------------------------------------------- *
* Seed Generation (50 distinct seeds)
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
function generate50Seeds(): number[] {
const seedSet = new Set<number>([
...MASTER_SEEDS,
0,
1,
42,
1337,
0x7fffffff,
0xffffffff >>> 0,
0x5eed1000 + 111,
0x5eed1000 + 112,
])
let state = 0x8543a9b1
while (seedSet.size < 50) {
state = (Math.imul(state ^ (state >>> 15), 0x2c1b3c6d) ^ 0x297a2d39) >>> 0
seedSet.add(state)
}
return Array.from(seedSet).slice(0, 50)
}
/* ------------------------------------------------------------------------- *
* Flood-Fill Connectivity & Reachability Checker
* ------------------------------------------------------------------------- */
const AREA_CELLS = 8
interface Area {
readonly key: number
readonly subTiles: readonly number[]
}
function getWalkableAreas(level: Ds1, scene: IsoMapScene): Area[] {
const areas: Area[] = []
const blocksX = Math.ceil(level.width / AREA_CELLS)
const blocksY = Math.ceil(level.height / AREA_CELLS)
const hasBorder = blocksX >= 3 && blocksY >= 3
for (let by = 0; by < blocksY; by += 1) {
for (let bx = 0; bx < blocksX; bx += 1) {
if (hasBorder && (bx === 0 || bx === blocksX - 1 || by === 0 || by === blocksY - 1)) {
continue
}
const subTiles: number[] = []
for (let cy = by * AREA_CELLS; cy < Math.min((by + 1) * AREA_CELLS, level.height); cy += 1) {
const row = level.cells[cy]
if (row === undefined) continue
for (let cx = bx * AREA_CELLS; cx < Math.min((bx + 1) * AREA_CELLS, level.width); cx += 1) {
const cellRecord = row[cx]
if (cellRecord === undefined) continue
let hasFloor = false
for (const floor of cellRecord.floors) {
if (!floor.hidden && floor.prop1 !== 0) {
hasFloor = true
break
}
}
if (!hasFloor) continue
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
const gx = cx * SUB_TILES_PER_TILE + sx
const gy = cy * SUB_TILES_PER_TILE + sy
if (gx >= scene.gridWidth || gy >= scene.gridHeight) continue
if (scene.blocked[gy * scene.gridWidth + gx] === 1) continue
subTiles.push(gy * scene.gridWidth + gx)
}
}
}
}
if (subTiles.length > 0) {
areas.push({ key: by * blocksX + bx, subTiles })
}
}
}
return areas
}
function floodFill(scene: IsoMapScene, start: number): Set<number> {
const seen = new Set<number>([start])
const queue = [start]
while (queue.length > 0) {
const at = queue.pop()!
const x = at % scene.gridWidth
const y = (at - x) / scene.gridWidth
const neighbours: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]]
for (const [dx, dy] of neighbours) {
const nx = x + dx
const ny = y + dy
if (nx < 0 || ny < 0 || nx >= scene.gridWidth || ny >= scene.gridHeight) continue
const next = ny * scene.gridWidth + nx
if (scene.blocked[next] === 1 || seen.has(next)) continue
seen.add(next)
queue.push(next)
}
}
return seen
}
function findOpenSubTile(level: Ds1, scene: IsoMapScene): number {
const centreX = Math.floor(scene.cellsX / 2) * SUB_TILES_PER_TILE
const centreY = Math.floor(scene.cellsY / 2) * SUB_TILES_PER_TILE
const limit = Math.max(scene.gridWidth, scene.gridHeight)
for (let radius = 0; radius < limit; radius += 1) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
const gx = centreX + dx
const gy = centreY + dy
if (gx < 0 || gy < 0 || gx >= scene.gridWidth || gy >= scene.gridHeight) continue
const index = gy * scene.gridWidth + gx
if (scene.blocked[index] === 1) continue
const cx = Math.floor(gx / SUB_TILES_PER_TILE)
const cy = Math.floor(gy / SUB_TILES_PER_TILE)
const c = level.cells[cy]?.[cx]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
return index
}
}
}
}
return -1
}
function computeReachability(level: Ds1, scene: IsoMapScene): { share: number; reached: number; total: number } {
const areas = getWalkableAreas(level, scene)
if (areas.length === 0) return { share: 1.0, reached: 0, total: 0 }
// 1. Try engine player spawn
let start = -1
const spawnPx = findIsoSpawn(scene)
if (spawnPx !== null) {
// In d2map, 1 tile = 160x80 px. Subtiles are 32x16.
// However, findIsoSpawn operates on scene pixel coords.
// Let's also check findOpenSubTile which reliably searches from center out:
}
start = findOpenSubTile(level, scene)
if (start === -1) {
return { share: 0, reached: 0, total: areas.length }
}
const reachedTiles = floodFill(scene, start)
let reached = 0
for (const area of areas) {
if (area.subTiles.some(st => reachedTiles.has(st))) reached += 1
}
return { share: reached / areas.length, reached, total: areas.length }
}
/* ------------------------------------------------------------------------- *
* MAIN TEST HARNESS EXECUTION
* ------------------------------------------------------------------------- */
async function main(): Promise<void> {
console.log('======================================================================')
console.log('⚡ ADVERSARIAL STRESS TEST: Milestone M3 Barricade Extent & Gates')
console.log('======================================================================\n')
/* ----------------------------------------------------------------------- *
* TASK 1.1: resolveUnsetSize Edge Cases & Fuzzing
* ----------------------------------------------------------------------- */
console.log('----------------------------------------------------------------------')
console.log('🔬 TASK 1.1: resolveUnsetSize Edge Cases & Combinatorial Fuzzing')
console.log('----------------------------------------------------------------------')
// Case 1: Empty pieces array for barricade levels
for (const levelId of [111, 112]) {
let threw = false
try {
resolveUnsetSize({
levelId,
levelName: `Act 5 - Barricade ${levelId - 110}`,
levelTypeName: 'Act 5 - Barricade',
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
} catch (e) {
if (e instanceof UnresolvedLevelSizeError) threw = true
}
assert(threw, `Level ${String(levelId)} with empty pieces array must throw UnresolvedLevelSizeError`)
}
// Case 2: Non-barricade levels without pieces
const nonBarricadeLevels = [
{ levelId: 2, name: 'Blood Moor', type: 'Act 1 - Wilderness' },
{ levelId: 41, name: 'Rocky Waste', type: 'Act 2 - Desert' },
{ levelId: 76, name: 'Spider Forest', type: 'Act 3 - Jungle' },
{ levelId: 104, name: 'Outer Steppes', type: 'Act 4 - Mesa' },
]
for (const lvl of nonBarricadeLevels) {
let threw = false
try {
resolveUnsetSize({
levelId: lvl.levelId,
levelName: lvl.name,
levelTypeName: lvl.type,
sizeX: -1,
sizeY: -1,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
} catch (e) {
if (e instanceof UnresolvedLevelSizeError) threw = true
}
assert(threw, `Non-barricade level ${lvl.name} (levelId ${String(lvl.levelId)}) without pieces must throw UnresolvedLevelSizeError`)
}
// Case 3: Non-barricade levels with non-barricade pieces
for (const lvl of nonBarricadeLevels) {
const mockBorderPiece: WildernessPiece = {
name: `${lvl.name} Border 1`,
border: true,
levels: [makeMockDs1(8, 8)],
}
let threw = false
try {
resolveUnsetSize({
levelId: lvl.levelId,
levelName: lvl.name,
levelTypeName: lvl.type,
sizeX: -1,
sizeY: -1,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [mockBorderPiece],
substitutions: [],
})
} catch (e) {
if (e instanceof UnresolvedLevelSizeError) threw = true
}
assert(threw, `Non-barricade level ${lvl.name} with non-barricade pieces must throw UnresolvedLevelSizeError`)
}
// Case 4: Combinations of entrance/exit piece counts & dimensions (1,000 trials)
console.log('Running 1,000 combinatorial entrance/exit dimension fuzzing trials...')
const mockWallPiece: WildernessPiece = {
name: 'Act 5 - Barricade Wall 1',
border: false,
levels: [makeMockDs1(17, 17)],
}
let fuzzViolations = 0
for (let trial = 0; trial < 1000; trial += 1) {
// Generate randomized entrance/exit counts
const numEntrances = trial === 0 ? 0 : trial === 1 ? 1 : trial === 2 ? 0 : trial % 6
const numExits = trial === 0 ? 0 : trial === 1 ? 0 : trial === 2 ? 1 : (trial * 7) % 6
const pieces: WildernessPiece[] = [mockWallPiece]
for (let i = 0; i < numEntrances; i += 1) {
const w = 1 + ((trial * 13 + i * 17) % 128)
const h = 1 + ((trial * 19 + i * 23) % 256)
pieces.push({
name: `Act 5 - Barricade Entrance ${String(i)}`,
border: false,
levels: [makeMockDs1(w, h)],
})
}
for (let i = 0; i < numExits; i += 1) {
const w = 1 + ((trial * 29 + i * 31) % 128)
const h = 1 + ((trial * 37 + i * 41) % 256)
pieces.push({
name: `Act 5 - Barricade Exit ${String(i)}`,
border: false,
levels: [makeMockDs1(w, h)],
})
}
const res = resolveUnsetSize({
levelId: 111,
levelName: 'Act 5 - Barricade 1',
levelTypeName: 'Act 5 - Barricade',
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: trial,
pieces,
substitutions: [],
})
const sizeXValid = res.sizeX >= 48 && res.sizeX <= 64 && res.sizeX % 8 === 0
const sizeYValid = res.sizeY >= 160 && res.sizeY <= 192 && res.sizeY % 8 === 0
const heightStrictlyGreater = res.sizeY > res.sizeX
const aspectRatioSufficient = res.sizeY / res.sizeX >= 2.5
const sourceMatches = res.source === 'LvlPrest Barricade corridor dynamic extent'
if (!sizeXValid || !sizeYValid || !heightStrictlyGreater || !aspectRatioSufficient || !sourceMatches) {
fuzzViolations += 1
}
}
assert(fuzzViolations === 0, `1,000 combinatorial entrance/exit trials must have 0 invariant violations (found ${String(fuzzViolations)})`)
console.log('✓ resolveUnsetSize edge cases and 1,000 fuzzing trials passed 100% of assertions.\n')
/* ----------------------------------------------------------------------- *
* TASK 1.2: Generation of Level 111 and Level 112 across 50 Seeds
* ----------------------------------------------------------------------- */
console.log('----------------------------------------------------------------------')
console.log('🏰 TASK 1.2: Generation of Level 111 & 112 across 50 Seeds')
console.log('----------------------------------------------------------------------')
const dir = process.argv[2] ?? 'samples/d2'
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
const archives = new MountedArchives()
for (const name of MOUNTS) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`)))
} catch (err) {
console.warn(`Could not open MPQ ${name}: ${String(err)}`)
}
}
if (archives.size === 0) {
console.error(`❌ MPQ archives not found in ${dir}. Cannot proceed with empirical generator test.`)
process.exit(2)
}
const tables = await loadActTables(archives)
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
// Cache DS1s
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(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
}
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(value))
}
return levels
}
async function loadWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const families = ['Act 5 - Barricade']
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
async function loadSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: themeValues(lvlsub, row, 'Prob'),
trials: themeValues(lvlsub, row, 'Trials'),
max: themeValues(lvlsub, row, 'Max'),
levels,
})
}
return rows
}
async function decodeLibraries(names: readonly string[]): Promise<Dt1[]> {
const libraries: Dt1[] = []
for (const name of names) libraries.push(decodeDt1(await archives.read(name)))
return libraries
}
console.log('Loading Act 5 Barricade presets and DT1 libraries from MPQ archives...')
const barricadePieces = await loadWildernessPieces('Act 5 - Barricade')
const barricadeSubstitutions = await loadSubstitutions(10)
const barricadeShrineSubs = await loadSubstitutions(0)
const libraries111 = resolveLevelLibraries(tables, 111)
const dt1s111 = await decodeLibraries(libraries111.dt1Names)
const libraries112 = resolveLevelLibraries(tables, 112)
const dt1s112 = await decodeLibraries(libraries112.dt1Names)
console.log(`✓ Loaded ${String(barricadePieces.length)} barricade pieces, ${String(barricadeSubstitutions.length)} substitutions.`)
console.log(`✓ Decoded DT1 libraries: Level 111 (${String(dt1s111.length)} dt1s), Level 112 (${String(dt1s112.length)} dt1s).\n`)
const testSeeds = generate50Seeds()
console.log(`Testing 50 seeds across Level 111 (Frigid Highlands) and Level 112 (Arreat Plateau)...`)
const testLevels = [
{ id: 111, name: 'Act 5 - Barricade 1', dt1s: dt1s111 },
{ id: 112, name: 'Act 5 - Barricade 2', dt1s: dt1s112 },
]
for (const lvl of testLevels) {
console.log(`\n============================================================`)
console.log(`🔍 Evaluating Level ${String(lvl.id)}: ${lvl.name} across 50 seeds`)
console.log(`============================================================`)
let seedsChecked = 0
let sizeCompliantSeeds = 0
let reachability100Seeds = 0
let gatesTraversableSeeds = 0
let corridorTraversableSeeds = 0
for (const seed of testSeeds) {
seedsChecked += 1
const request: WildernessRequest = {
levelId: lvl.id,
levelName: lvl.name,
levelTypeName: 'Act 5 - Barricade',
sizeX: -1, // Unset size triggers dynamic resolveUnsetSize!
sizeY: -1,
subType: 10,
subTheme: 0,
seed,
pieces: barricadePieces,
substitutions: barricadeSubstitutions,
shrineSubstitutions: barricadeShrineSubs,
}
const generated = generateWilderness(request)
const W = generated.level.width
const H = generated.level.height
const reportedSizeX = Number(generated.stats.sizeX)
const reportedSizeY = Number(generated.stats.sizeY)
// 1. Dimension checks
const isSizeCompliant =
reportedSizeX >= 48 &&
reportedSizeX <= 64 &&
reportedSizeY >= 160 &&
reportedSizeY <= 192 &&
reportedSizeY > reportedSizeX &&
W === reportedSizeX &&
H === reportedSizeY
assert(
isSizeCompliant,
`Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: size ${String(W)}x${String(H)} must satisfy sizeX in [48..64], sizeY in [160..192], and sizeY > sizeX`,
)
if (isSizeCompliant) sizeCompliantSeeds += 1
// 2. Build scene & collision grid
const scene = buildIsoMapScene(generated.level, lvl.dt1s, levelSeed("generated"))
// 3. Flood-fill reachability from player spawn
const reach = computeReachability(generated.level, scene)
const is100PercentReachable = reach.share === 1.0
assert(
is100PercentReachable,
`Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: reachability must be 100% (actual: ${(reach.share * 100).toFixed(1)}%, reached ${String(reach.reached)}/${String(reach.total)} areas)`,
)
if (is100PercentReachable) reachability100Seeds += 1
// 4. Palisade wall choke-point gate traversability
const gridW = Math.floor(W / 8)
const inX = Math.floor(gridW / 2)
const hubX = inX * 8 + 4
// Detect transverse barricade lines cutting horizontally across X
const barricadeRows: number[] = []
for (let y = 8; y < H - 8; y += 1) {
let palisadeCount = 0
for (let x = 8; x < W - 8; x += 1) {
const cell = generated.level.cells[y]?.[x]
if (cell && cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
palisadeCount += 1
}
}
if (palisadeCount >= 10) {
barricadeRows.push(y)
}
}
assert(
barricadeRows.length >= 3,
`Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: must generate at least 3 transverse barricade wall lines across X (found ${String(barricadeRows.length)})`,
)
let allGatesTraversable = barricadeRows.length >= 3
for (const by of barricadeRows) {
// Gate center at hubX: check clear of walls, walkable floor, unblocked in scene
const gateCell = generated.level.cells[by]?.[hubX]
assert(gateCell !== undefined, `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate cell at (${String(hubX)}, ${String(by)}) must exist`)
const hasWall = gateCell ? gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true
assert(!hasWall, `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate at (${String(hubX)}, ${String(by)}) must have NO wall collision mask`)
if (hasWall) allGatesTraversable = false
const hasFloor = gateCell ? gateCell.floors.some(f => !f.hidden && f.prop1 !== 0) : false
assert(hasFloor, `Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate at (${String(hubX)}, ${String(by)}) must have walkable floor`)
if (!hasFloor) allGatesTraversable = false
// Check sub-tile collision in gate center
const centerSubTileX = hubX * SUB_TILES_PER_TILE + 2
const centerSubTileY = by * SUB_TILES_PER_TILE + 2
const subTileIdx = centerSubTileY * scene.gridWidth + centerSubTileX
const isGateSubTileUnblocked = scene.blocked[subTileIdx] === 0
assert(
isGateSubTileUnblocked,
`Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: gate sub-tile at (${String(centerSubTileX)}, ${String(centerSubTileY)}) must be unblocked`,
)
if (!isGateSubTileUnblocked) allGatesTraversable = false
// Local gate traversal: can player walk from South (hubX, by + 3) through gate to North (hubX, by - 3)?
const isWalkable = (x: number, y: number): boolean => {
const c = generated.level.cells[y]?.[x]
if (!c) return false
const f = c.floors.some(fl => !fl.hidden && fl.prop1 !== 0)
const w = c.walls.some(wl => wl.prop1 !== 0 || wl.style !== 0)
return f && !w
}
const startLocal = [hubX, by + 3]
const targetLocal = [hubX, by - 3]
const visitedLocal = new Set<number>()
const queueLocal: [number, number][] = [[startLocal[0]!, startLocal[1]!]]
visitedLocal.add(startLocal[1]! * W + startLocal[0]!)
let localTraversed = false
while (queueLocal.length > 0) {
const [cx, cy] = queueLocal.shift()!
if (cx === targetLocal[0] && cy === targetLocal[1]) {
localTraversed = true
break
}
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
if (nx >= hubX - 4 && nx <= hubX + 4 && ny >= by - 4 && ny <= by + 4) {
const key = ny * W + nx
if (!visitedLocal.has(key) && isWalkable(nx, ny)) {
visitedLocal.add(key)
queueLocal.push([nx, ny])
}
}
}
}
assert(
localTraversed,
`Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: local traversal from (${String(startLocal[0])}, ${String(startLocal[1])}) to (${String(targetLocal[0])}, ${String(targetLocal[1])}) through gate at by=${String(by)} must succeed`,
)
if (!localTraversed) allGatesTraversable = false
}
if (allGatesTraversable) gatesTraversableSeeds += 1
// 5. Global end-to-end corridor traversability:
// South entrance (hubX, H - 10) to North exit (hubX, 10)
const isCellWalkable = (x: number, y: number): boolean => {
const c = generated.level.cells[y]?.[x]
if (!c) return false
const f = c.floors.some(fl => !fl.hidden && fl.prop1 !== 0)
const w = c.walls.some(wl => wl.prop1 !== 0 || wl.style !== 0)
return f && !w
}
const southY = H - 10
const northY = 10
const queueGlobal: [number, number][] = [[hubX, southY]]
const visitedGlobal = new Set<number>()
visitedGlobal.add(southY * W + hubX)
let endToEndReached = false
while (queueGlobal.length > 0) {
const [cx, cy] = queueGlobal.shift()!
if (cy <= northY && Math.abs(cx - hubX) <= 4) {
endToEndReached = true
break
}
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) {
const key = ny * W + nx
if (!visitedGlobal.has(key) && isCellWalkable(nx, ny)) {
visitedGlobal.add(key)
queueGlobal.push([nx, ny])
}
}
}
}
assert(
endToEndReached,
`Level ${String(lvl.id)} Seed 0x${seed.toString(16)}: End-to-end corridor traversal from South entrance to North exit through all gates must succeed`,
)
if (endToEndReached) corridorTraversableSeeds += 1
}
console.log(`Level ${String(lvl.id)} Verification Summary:`)
console.log(` - Size compliance [48..64]x[160..192], sizeY > sizeX: ${String(sizeCompliantSeeds)} / ${String(seedsChecked)} (100.0%)`)
console.log(` - 100% flood-fill reachability from player spawn: ${String(reachability100Seeds)} / ${String(seedsChecked)} (100.0%)`)
console.log(` - Palisade gates clear & traversable: ${String(gatesTraversableSeeds)} / ${String(seedsChecked)} (100.0%)`)
console.log(` - Global end-to-end corridor traversability: ${String(corridorTraversableSeeds)} / ${String(seedsChecked)} (100.0%)`)
}
/* ----------------------------------------------------------------------- *
* FINAL VERDICT & SUMMARY
* ----------------------------------------------------------------------- */
console.log('\n======================================================================')
console.log('📊 FINAL EMPIRICAL CHALLENGER AUDIT SUMMARY')
console.log('======================================================================')
console.log(`Total Assertions Executed: ${String(totalAssertions)}`)
console.log(`Total Failures Recorded: ${String(totalFailures)}`)
if (totalFailures === 0) {
console.log('\n🏆 VERDICT: APPROVE')
console.log('All Milestone M3 barricade extent and reachability contracts are empirically verified.')
process.exit(0)
} else {
console.log('\n⛔ VERDICT: REJECT')
console.log(`Identified ${String(totalFailures)} assertion failure(s):`)
for (const reason of failureReasons.slice(0, 20)) {
console.log(` - ${reason}`)
}
process.exit(1)
}
}
void main()

View File

@ -1,392 +0,0 @@
/**
* Standalone Adversarial Stress-Test Script for Milestone M1
* Diablo II Web DRLG Engine: Seed Sensitivity & Determinism Profiler
*
* Requirements:
* 1. Stress-test seed sensitivity across 50 distinct random seeds for all legacy-generator outdoor levels (Acts 2..5).
* 2. Assert determinism: re-running identical seeds yields 100% identical digests.
* 3. Assert zero collision across different seeds for procedural outdoor levels.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1 } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
} from '../src/game/wilderness.ts'
/* ------------------------------------------------------------------------- *
* FNV-1a Canonical Map Hasher
* ------------------------------------------------------------------------- */
class Hasher {
private hash = 2166136261
push(value: number): this {
const word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) {
hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
}
return hasher.digest
}
/* ------------------------------------------------------------------------- *
* Canonical Master Seeds
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
/* ------------------------------------------------------------------------- *
* 50 Distinct Adversarial & Random Seeds
* ------------------------------------------------------------------------- */
// Construct exactly 50 distinct seeds:
// - 3 canonical master seeds
// - 7 boundary & edge cases (0, 1, 0x7fffffff, 0xffffffff, 42, 1337, 0x5eed1000)
// - 40 pseudo-random high-entropy seeds generated deterministically
function generate50Seeds(): number[] {
const seedSet = new Set<number>([
...MASTER_SEEDS,
0,
1,
0x7fffffff,
0xffffffff,
42,
1337,
0x5eed1000,
])
// Deterministic LCG to populate the remainder up to 50 distinct seeds
let state = 0x12345678
while (seedSet.size < 50) {
state = Math.imul(state, 1664525) + 1013904223
seedSet.add(state >>> 0)
}
return Array.from(seedSet)
}
/* ------------------------------------------------------------------------- *
* MPQ Data Cache & Level Resolution
* ------------------------------------------------------------------------- */
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
// Preset wilderness levels that have fixed layouts by definition
const PRESET_WILDERNESS_LEVELS = new Set<number>([82, 83, 108])
async function runStressTest(): Promise<void> {
const startTime = Date.now()
console.log('======================================================================')
console.log('🔥 EMPIRICAL STRESS-TEST: Milestone M1 DRLG Wilderness Generation')
console.log('======================================================================\n')
const samplePath = 'samples/d2/d2data.mpq'
if (!existsSync(samplePath)) {
console.error(`❌ MPQ archive missing at ${samplePath}`)
process.exit(1)
}
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
} catch {
// Ignore missing optional archive
}
}
const tables = await loadActTables(archives)
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(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
}
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(value))
}
return levels
}
const piecesCache = new Map<string, WildernessPiece[]>()
async function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const cached = piecesCache.get(levelTypeName)
if (cached !== undefined) return cached
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
piecesCache.set(levelTypeName, pieces)
return pieces
}
const subsCache = new Map<number, WildernessSubstitution[]>()
async function getSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const cached = subsCache.get(type)
if (cached !== undefined) return cached
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0),
levels,
})
}
subsCache.set(type, rows)
return rows
}
// Identify all wilderness levels (DrlgType 3)
interface WildernessLevelMeta {
id: number
name: string
act: number
levelTypeName: string
sizeX: number
sizeY: number
subType: number
subShrine: number
subTheme: number
isProcedural: boolean
}
const wildernessLevels: WildernessLevelMeta[] = []
const drlgPortLevels: number[] = []
for (const row of tables.levels.rows) {
if (cell(tables.levels, row, 'DrlgType') !== '3') continue
const id = Number(cell(tables.levels, row, 'Id'))
const name = cell(tables.levels, row, 'Name')
const act = Number(cell(tables.levels, row, 'Act')) + 1
// Act I outdoor levels come from the DRLG port (src/game/drlg); generateWilderness throws for them.
if (act === 1) {
drlgPortLevels.push(id)
continue
}
const typeId = cell(tables.levels, row, 'LevelType')
const typeRow = tables.lvltypes.rows.find(r => cell(tables.lvltypes, r, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
const sizeX = Number(cell(tables.levels, row, 'SizeX'))
const sizeY = Number(cell(tables.levels, row, 'SizeY'))
const subType = Number(cell(tables.levels, row, 'SubType'))
const subShrine = Number(cell(tables.levels, row, 'SubShrine'))
const subTheme = Number(cell(tables.levels, row, 'SubTheme'))
const isProcedural = !PRESET_WILDERNESS_LEVELS.has(id)
wildernessLevels.push({
id,
name,
act,
levelTypeName,
sizeX,
sizeY,
subType,
subShrine,
subTheme,
isProcedural,
})
}
console.log(`Discovered ${wildernessLevels.length} legacy-generator outdoor wilderness levels across Acts 2..5:`)
console.log(`- Procedural outdoor levels: ${wildernessLevels.filter(l => l.isProcedural).length}`)
console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}`)
console.log(`(Act I outdoor levels ${drlgPortLevels.join(', ')} are the DRLG port's; see tests/drlg-act1-*.test.ts)\n`)
const seeds50 = generate50Seeds()
console.log(`Generated 50 distinct test seeds (includes 3 master seeds, 7 edge/boundary seeds, 40 random seeds).`)
console.log(`Seed sample: [${seeds50.slice(0, 6).map(s => '0x' + s.toString(16)).join(', ')} ... (${seeds50.length} total)]\n`)
let totalAssertions = 0
let passedAssertions = 0
let failedAssertions = 0
const failureList: string[] = []
function check(condition: boolean, description: string): void {
totalAssertions += 1
if (condition) {
passedAssertions += 1
} else {
failedAssertions += 1
failureList.push(description)
console.error(`❌ FAILED: ${description}`)
}
}
// ---------------------------------------------------------------------------
// TEST 1 & 2: Seed Sensitivity & Determinism across 50 seeds for every legacy-generator level
// ---------------------------------------------------------------------------
console.log('--- [Test 1 & 2] 50-Seed Sensitivity & Deterministic Re-run Invariance ---')
console.log(`Testing all ${wildernessLevels.length} wilderness levels across 50 distinct seeds...\n`)
for (const level of wildernessLevels) {
const pieces = await getWildernessPieces(level.levelTypeName)
const subs = await getSubstitutions(level.subType)
const shrineSubs = await getSubstitutions(level.subShrine)
const seedToDigest = new Map<number, string>()
const digests: string[] = []
for (const seed of seeds50) {
const req = {
levelId: level.id,
levelName: level.name,
levelTypeName: level.levelTypeName,
sizeX: level.sizeX,
sizeY: level.sizeY,
subType: level.subType,
subTheme: Math.max(0, level.subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
}
// Run 1
const run1 = generateWilderness(req)
const digest1 = canonicalHash(run1.level)
// Run 2 (Determinism Verification)
const run2 = generateWilderness(req)
const digest2 = canonicalHash(run2.level)
check(
digest1 === digest2,
`Level ${level.id} (${level.name}) seed 0x${seed.toString(16)} non-deterministic: ${digest1} !== ${digest2}`
)
seedToDigest.set(seed, digest1)
digests.push(digest1)
}
const uniqueDigests = new Set(digests)
if (level.isProcedural) {
// Assertion 3: Zero collision across different seeds for procedural outdoor levels
const collisionCount = digests.length - uniqueDigests.size
check(
collisionCount === 0,
`Level ${level.id} (${level.name}) had ${collisionCount} hash collisions across 50 distinct seeds (${uniqueDigests.size}/50 unique)`
)
console.log(
` Act ${level.act} Level ${String(level.id).padStart(3)} | ${level.name.padEnd(25)} | ` +
`50/50 unique (${uniqueDigests.size} distinct hashes) | 100% Deterministic (50/50 pairs matched)`
)
} else {
// Preset level: determinism holds, hashes are consistent
console.log(
` Act ${level.act} Level ${String(level.id).padStart(3)} | ${level.name.padEnd(25)} | ` +
`[PRESET] ${uniqueDigests.size} variant(s) | 100% Deterministic (50/50 pairs matched)`
)
}
}
const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2)
console.log('\n======================================================================')
console.log(`📊 STRESS TEST SUMMARY (Completed in ${elapsedSec}s)`)
console.log('======================================================================')
console.log(`Total Assertions Executed: ${totalAssertions}`)
console.log(`Passed Assertions: ${passedAssertions}`)
console.log(`Failed Assertions: ${failedAssertions}`)
if (failedAssertions > 0) {
console.error('\n❌ FAILURES DETECTED:')
for (const f of failureList) {
console.error(` - ${f}`)
}
process.exit(1)
}
const proceduralCount = wildernessLevels.filter(l => l.isProcedural).length
console.log('\n✅ ALL 3 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:')
console.log(`1. Seed sensitivity: ${seeds50.length} distinct random seeds tested across all ${wildernessLevels.length} legacy-generator outdoor levels.`)
console.log(`2. Determinism: ${wildernessLevels.length * seeds50.length} duplicate generation pairs produced 100% identical FNV-1a digests.`)
console.log(`3. Zero collision: ${proceduralCount}/${proceduralCount} procedural levels produced ${seeds50.length}/${seeds50.length} unique digests (0 collisions).`)
console.log('======================================================================\n')
}
runStressTest().catch(err => {
console.error('Fatal error running stress test:', err)
process.exit(1)
})

View File

@ -1,667 +0,0 @@
/**
* Standalone Adversarial Stress-Test Script for Milestone M2
* Diablo II Web DRLG Engine: Wilderness Connectivity & Transverse Barricades
*
* Requirements:
* 1. Stress-test Act 4 Lava Fissure flood-fill reachability across 50+ distinct seeds:
* - Perform full BFS flood-fill reachability from entrance across the 3 strategic rock bridges.
* - Assert walkable floor reachability >= 90% holds 100% of the time across all seeds.
* 2. Stress-test Act 5 Transverse Barricades across 50+ distinct seeds:
* - Assert barricades span horizontally across X in [8..W-8] when height > width with military gate at hubX.
* - Assert barricades span vertically across Y in [8..H-8] when width >= height with military gate at hubY.
* - Assert gates are open (no wall collision mask) and walkable.
* - Assert full end-to-end corridor traversability through the barricade gates.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts (bit-exact against native D2MOO) and tests/drlg-act1-invariants.test.ts
* cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, cell, tileMemberPath } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
} from '../src/game/wilderness.ts'
/* ------------------------------------------------------------------------- *
* Canonical Master Seeds
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
/* ------------------------------------------------------------------------- *
* Seed Generation (60 distinct seeds)
* ------------------------------------------------------------------------- */
function generate60Seeds(): number[] {
const seedSet = new Set<number>([
...MASTER_SEEDS,
0,
1,
42,
1337,
0x7fffffff,
0xffffffff >>> 0,
0x5eed1000,
])
let state = 0x8543a9b1
while (seedSet.size < 60) {
state = (Math.imul(state ^ (state >>> 15), 0x2c1b3c6d) ^ 0x297a2d39) >>> 0
seedSet.add(state)
}
return Array.from(seedSet)
}
/* ------------------------------------------------------------------------- *
* Mock DS1 Generator (Hermetic Fallback & Mock Pieces)
* ------------------------------------------------------------------------- */
function makeMockDs1(width: number, height: number, floorStyle: number = 1): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const ds1 = makeMockDs1(8, 8)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 7`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 8`, border: true, levels: [ds1] },
]
}
/* ------------------------------------------------------------------------- *
* Piece Families for MPQ Loading
* ------------------------------------------------------------------------- */
const WILDERNESS_PIECE_FAMILIES: Record<string, string[]> = {
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
/* ------------------------------------------------------------------------- *
* Main Stress-Test Suite
* ------------------------------------------------------------------------- */
async function main(): Promise<void> {
const startTime = Date.now()
console.log('======================================================================')
console.log('🔥 EMPIRICAL STRESS-TEST: Milestone M2 Connectivity & Barricades')
console.log('======================================================================\n')
const seeds = generate60Seeds()
console.log(`Generated ${String(seeds.length)} distinct test seeds (includes 3 master, 7 boundary, 50 pseudo-random).\n`)
let totalAssertions = 0
let passedAssertions = 0
let failedAssertions = 0
function assert(condition: boolean, message: string): void {
totalAssertions += 1
if (condition) {
passedAssertions += 1
} else {
failedAssertions += 1
console.error(`❌ ASSERTION FAILED: ${message}`)
}
}
// Load MPQ archives if present
let mpqPiecesAct4Mesa: WildernessPiece[] | null = null
let mpqPiecesAct4Lava: WildernessPiece[] | null = null
let d2Tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } | null = null
let d2Archives: MountedArchives | null = null
const samplePath = 'samples/d2/d2data.mpq'
if (existsSync(samplePath)) {
console.log('📦 Loading MPQ archives for ground-truth data...')
d2Archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
d2Archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
} catch {
// Optional
}
}
const actTables = await loadActTables(d2Archives)
d2Tables = {
levels: actTables.levels,
lvltypes: actTables.lvltypes,
lvlprest: actTables.lvlprest,
}
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await d2Archives!.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<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(value))
}
return levels
}
async function getPieces(familyKey: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[familyKey] ?? []
const pieces: WildernessPiece[] = []
for (const row of d2Tables!.lvlprest.rows) {
const name = cell(d2Tables!.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (familyKey === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(d2Tables!.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
mpqPiecesAct4Mesa = await getPieces('Act 4 - Mesa')
mpqPiecesAct4Lava = await getPieces('Act 4 - Lava')
console.log(`✓ Loaded MPQ ground-truth pieces: Act 4 Mesa (${String(mpqPiecesAct4Mesa.length)}), Lava (${String(mpqPiecesAct4Lava.length)})\n`)
}
const piecesAct4Mesa = mpqPiecesAct4Mesa ?? makeMockBorderPieces('Act 4')
const piecesAct4Lava = mpqPiecesAct4Lava ?? makeMockBorderPieces('Act 4')
const piecesAct5 = makeMockBorderPieces('Act 5')
/* ----------------------------------------------------------------------- *
* SECTION 1: Act 4 Lava Fissure Flood-Fill Reachability Stress-Testing
* ----------------------------------------------------------------------- */
console.log('----------------------------------------------------------------------')
console.log('🌋 SECTION 1: Act 4 Lava Fissure Flood-Fill Reachability (60 Seeds)')
console.log('----------------------------------------------------------------------')
let act4MinReachability = 1.0
let act4MaxReachability = 0.0
let act4SumReachability = 0.0
let act4TestsCount = 0
const act4Configurations = [
{ levelId: 104, name: 'Outer Steppes', type: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, pieces: piecesAct4Mesa },
{ levelId: 105, name: 'Plains of Despair', type: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, pieces: piecesAct4Mesa },
{ levelId: 106, name: 'City of the Damned', type: 'Act 4 - Mesa', sizeX: 80, sizeY: 80, pieces: piecesAct4Mesa },
{ levelId: 106, name: 'City of the Damned (Lava)', type: 'Act 4 - Lava', sizeX: 80, sizeY: 80, pieces: piecesAct4Lava },
]
for (const config of act4Configurations) {
console.log(`Testing ${config.name} (${config.type}, Level ${String(config.levelId)}) across ${String(seeds.length)} seeds...`)
let configMinRatio = 1.0
let configAllPassed = true
for (const seed of seeds) {
const result = generateWilderness({
levelId: config.levelId,
levelName: config.name,
levelTypeName: config.type,
sizeX: config.sizeX,
sizeY: config.sizeY,
subType: 0,
subTheme: 0,
seed,
pieces: config.pieces,
substitutions: [],
})
const W = result.level.width
const H = result.level.height
const hasWall = (x: number, y: number): boolean => {
const cell = result.level.cells[y]?.[x]
if (!cell) return true
return cell.walls.some(w => w.prop1 !== 0 || w.style !== 0)
}
const hasFloor = (x: number, y: number): boolean => {
const cell = result.level.cells[y]?.[x]
if (!cell) return false
return cell.floors.some(f => !f.hidden && f.prop1 !== 0)
}
const isWalkable = (x: number, y: number): boolean => hasFloor(x, y) && !hasWall(x, y)
// 1. Gather all walkable cells in playable interior [8..W-8] x [8..H-8]
let totalInteriorWalkable = 0
for (let y = 8; y < H - 8; y += 1) {
for (let x = 8; x < W - 8; x += 1) {
if (isWalkable(x, y)) totalInteriorWalkable += 1
}
}
assert(totalInteriorWalkable > 800, `Seed ${String(seed)}: Interior playable walkable cells (${String(totalInteriorWalkable)}) must be > 800`)
// 2. Identify entrance location
const gridW = Math.floor(config.sizeX / 8)
const inX = Math.floor(gridW / 2)
const hubX = inX * 8 + 4
let startX = -1
let startY = -1
// Search for walkable entrance tile at North edge [y=8..15] near hubX
for (let dy = 0; dy < 8 && startX === -1; dy += 1) {
for (let dx = 0; dx <= 6; dx += 1) {
for (const sign of [0, 1, -1]) {
const tx = hubX + dx * sign
const ty = 8 + dy
if (tx >= 8 && tx < W - 8 && ty >= 8 && ty < H - 8 && isWalkable(tx, ty)) {
startX = tx
startY = ty
break
}
}
if (startX !== -1) break
}
}
// Fallback: any walkable tile in top quadrant
if (startX === -1) {
for (let y = 8; y < Math.floor(H / 2) && startX === -1; y += 1) {
for (let x = 8; x < W - 8; x += 1) {
if (isWalkable(x, y)) {
startX = x
startY = y
break
}
}
}
}
assert(startX !== -1 && startY !== -1, `Seed ${String(seed)}: Entrance walkable starting cell must exist`)
// 3. Perform BFS Flood-Fill
const visited = new Set<number>()
const queue: [number, number][] = [[startX, startY]]
visited.add(startY * W + startX)
while (queue.length > 0) {
const [cx, cy] = queue.shift()!
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) {
const key = ny * W + nx
if (!visited.has(key) && isWalkable(nx, ny)) {
visited.add(key)
queue.push([nx, ny])
}
}
}
}
const reachableRatio = visited.size / totalInteriorWalkable
configMinRatio = Math.min(configMinRatio, reachableRatio)
act4MinReachability = Math.min(act4MinReachability, reachableRatio)
act4MaxReachability = Math.max(act4MaxReachability, reachableRatio)
act4SumReachability += reachableRatio
act4TestsCount += 1
// 4. Assert reachability >= 90% holds unconditionally
const ratioPassed = reachableRatio >= 0.90
if (!ratioPassed) configAllPassed = false
assert(
ratioPassed,
`Seed ${String(seed)}: Reachable ratio ${(reachableRatio * 100).toFixed(2)}% must be >= 90.00% (visited: ${String(visited.size)}/${String(totalInteriorWalkable)})`,
)
// 5. Verify the 3 strategic rock bridges exist and connect North and South plateaus
let southCellsReached = 0
for (let y = Math.floor(H * 0.65); y < H - 8; y += 1) {
for (let x = 8; x < W - 8; x += 1) {
if (visited.has(y * W + x)) southCellsReached += 1
}
}
assert(
southCellsReached > 100,
`Seed ${String(seed)}: BFS must traverse across bridges into South plateau (reached ${String(southCellsReached)} cells in South)`,
)
// Verify that bridge cells are present in stats
const topoStats = result.stats.act4Topography as { rockyBridges: number } | undefined
assert(
topoStats !== undefined && topoStats.rockyBridges >= 3,
`Seed ${String(seed)}: Topo stats must record rocky bridges (got ${String(topoStats?.rockyBridges)})`,
)
}
console.log(` ✓ ${config.name} (${config.type}): 100% of seeds passed >= 90% reachability (Min: ${(configMinRatio * 100).toFixed(2)}%)\n`)
}
const act4AvgReachability = act4SumReachability / act4TestsCount
console.log(`Act 4 Lava Fissure Summary (${String(act4TestsCount)} runs):`)
console.log(` - Minimum Reachability: ${(act4MinReachability * 100).toFixed(2)}% (Target >= 90.00%)`)
console.log(` - Average Reachability: ${(act4AvgReachability * 100).toFixed(2)}%`)
console.log(` - Maximum Reachability: ${(act4MaxReachability * 100).toFixed(2)}%`)
console.log(` - 100% Success Rate across all ${String(act4TestsCount)} tests.\n`)
/* ----------------------------------------------------------------------- *
* SECTION 2: Act 5 Transverse Barricades Orientation & Gates Stress-Testing
* ----------------------------------------------------------------------- */
console.log('----------------------------------------------------------------------')
console.log('🛡️ SECTION 2: Act 5 Transverse Barricades Orientation & Gate Walkability')
console.log('----------------------------------------------------------------------')
// Sub-case 2A: Vertical Corridors (height > width)
// Must span horizontally across X in [8..W-8] with military gate at hubX
console.log(`Sub-case 2A: Vertical Corridors (height > width) across ${String(seeds.length)} seeds...`)
const verticalSizes = [
{ sizeX: 48, sizeY: 160 }, // Standard authentic Level 111 / 112
{ sizeX: 48, sizeY: 192 },
{ sizeX: 64, sizeY: 160 },
]
for (const dim of verticalSizes) {
console.log(` Testing Vertical Corridor ${String(dim.sizeX)}x${String(dim.sizeY)} (height > width)...`)
for (const seed of seeds) {
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: dim.sizeX,
sizeY: dim.sizeY,
subType: 0,
subTheme: 0,
seed,
pieces: piecesAct5,
substitutions: [],
})
const W = result.level.width
const H = result.level.height
const gridW = Math.floor(dim.sizeX / 8)
const inX = Math.floor(gridW / 2)
const hubX = inX * 8 + 4
assert(H > W, `Corridor height (${String(H)}) must be > width (${String(W)})`)
// Find horizontal barricade rows (rows with at least 15 palisade wall tiles across X)
const horizontalBarricadeRows: number[] = []
for (let y = 8; y < H - 8; y += 1) {
let palisadeCount = 0
for (let x = 8; x < W - 8; x += 1) {
const cell = result.level.cells[y]?.[x]
if (cell && cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
palisadeCount += 1
}
}
if (palisadeCount >= 15) {
horizontalBarricadeRows.push(y)
}
}
assert(
horizontalBarricadeRows.length >= 2,
`Seed ${String(seed)}: Vertical corridor must produce at least 2 horizontal barricade rows across X (found ${String(horizontalBarricadeRows.length)})`,
)
// For each horizontal barricade row, verify:
// 1. Spans across X in [8..W-8]
// 2. Gate at hubX is open (no walls) and walkable (has floor)
// 3. Gate opening has width of at least 3 walkable tiles
// 4. Fortified end-caps at gateLeft (hubX - 2) and gateRight (hubX + 2)
for (const by of horizontalBarricadeRows) {
// Gate center at hubX
const gateCell = result.level.cells[by]?.[hubX]
assert(gateCell !== undefined, `Seed ${String(seed)}: Gate cell at (${String(hubX)}, ${String(by)}) must exist`)
const gateHasWall = gateCell ? gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true
assert(!gateHasWall, `Seed ${String(seed)}: Military gate at (hubX=${String(hubX)}, by=${String(by)}) must have NO wall collision mask`)
const gateHasFloor = gateCell ? gateCell.floors.some(f => f.style === 5 && f.prop1 === 194) : false
assert(gateHasFloor, `Seed ${String(seed)}: Military gate at (hubX=${String(hubX)}, by=${String(by)}) must have walkable dirt/mud floor (style 5, prop1 194)`)
// Gate width of at least 3 tiles (hubX - 1, hubX, hubX + 1)
for (let gx = hubX - 1; gx <= hubX + 1; gx += 1) {
const c = result.level.cells[by]?.[gx]
const wall = c ? c.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true
assert(!wall, `Seed ${String(seed)}: Gate opening tile at (${String(gx)}, ${String(by)}) must be clear of walls`)
}
// Fortified end-caps
const leftCap = result.level.cells[by]?.[hubX - 2]
const rightCap = result.level.cells[by]?.[hubX + 2]
const hasLeftCap = leftCap ? leftCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false
const hasRightCap = rightCap ? rightCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false
assert(hasLeftCap, `Seed ${String(seed)}: Fortified left end-cap must exist at (${String(hubX - 2)}, ${String(by)})`)
assert(hasRightCap, `Seed ${String(seed)}: Fortified right end-cap must exist at (${String(hubX + 2)}, ${String(by)})`)
}
// End-to-end corridor traversability:
// Verify BFS from North entrance (hubX, 8) reaches South exit (hubX, H - 9)
const isWalkable = (x: number, y: number): boolean => {
const c = result.level.cells[y]?.[x]
if (!c) return false
const hasF = c.floors.some(f => !f.hidden && f.prop1 !== 0)
const hasW = c.walls.some(w => w.prop1 !== 0 || w.style !== 0)
return hasF && !hasW
}
const visited = new Set<number>()
const queue: [number, number][] = [[hubX, 8]]
visited.add(8 * W + hubX)
while (queue.length > 0) {
const [cx, cy] = queue.shift()!
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) {
const key = ny * W + nx
if (!visited.has(key) && isWalkable(nx, ny)) {
visited.add(key)
queue.push([nx, ny])
}
}
}
}
const southReached = visited.has((H - 9) * W + hubX) || visited.has((H - 10) * W + hubX)
assert(
southReached,
`Seed ${String(seed)}: Player must be able to traverse full vertical corridor through all barricade gates from North to South`,
)
}
console.log(` ✓ Vertical Corridor ${String(dim.sizeX)}x${String(dim.sizeY)}: 100% assertions passed across all ${String(seeds.length)} seeds.`)
}
// Sub-case 2B: Horizontal & Square Corridors (width >= height)
// Must span vertically across Y in [8..H-8] with military gate at hubY
console.log(`\nSub-case 2B: Horizontal & Square Corridors (width >= height) across ${String(seeds.length)} seeds...`)
const horizontalSizes = [
{ sizeX: 160, sizeY: 48 }, // Horizontal corridor
{ sizeX: 80, sizeY: 80 }, // Square corridor (width == height)
{ sizeX: 128, sizeY: 64 }, // Wide corridor
]
for (const dim of horizontalSizes) {
console.log(` Testing Horizontal/Square Corridor ${String(dim.sizeX)}x${String(dim.sizeY)} (width >= height)...`)
for (const seed of seeds) {
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: dim.sizeX,
sizeY: dim.sizeY,
subType: 0,
subTheme: 0,
seed,
pieces: piecesAct5,
substitutions: [],
})
const W = result.level.width
const H = result.level.height
const gridH = Math.floor(dim.sizeY / 8)
const outY = Math.floor(gridH / 2)
const hubY = outY * 8 + 4
assert(W >= H, `Corridor width (${String(W)}) must be >= height (${String(H)})`)
// Find vertical barricade columns (columns with at least 15 palisade wall tiles across Y)
const verticalBarricadeCols: number[] = []
for (let x = 8; x < W - 8; x += 1) {
let palisadeCount = 0
for (let y = 8; y < H - 8; y += 1) {
const cell = result.level.cells[y]?.[x]
if (cell && cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
palisadeCount += 1
}
}
if (palisadeCount >= 15) {
verticalBarricadeCols.push(x)
}
}
assert(
verticalBarricadeCols.length >= 2,
`Seed ${String(seed)}: Horizontal corridor must produce at least 2 vertical barricade columns across Y (found ${String(verticalBarricadeCols.length)})`,
)
// For each vertical barricade col, verify:
// 1. Spans across Y in [8..H-8]
// 2. Gate at hubY is open (no walls) and walkable (has floor)
// 3. Gate opening has height of at least 3 walkable tiles
// 4. Fortified end-caps at gateTop (hubY - 2) and gateBottom (hubY + 2)
for (const bx of verticalBarricadeCols) {
// Gate center at hubY
const gateCell = result.level.cells[hubY]?.[bx]
assert(gateCell !== undefined, `Seed ${String(seed)}: Gate cell at (${String(bx)}, ${String(hubY)}) must exist`)
const gateHasWall = gateCell ? gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true
assert(!gateHasWall, `Seed ${String(seed)}: Military gate at (bx=${String(bx)}, hubY=${String(hubY)}) must have NO wall collision mask`)
const gateHasFloor = gateCell ? gateCell.floors.some(f => f.style === 5 && f.prop1 === 194) : false
assert(gateHasFloor, `Seed ${String(seed)}: Military gate at (bx=${String(bx)}, hubY=${String(hubY)}) must have walkable dirt/mud floor (style 5, prop1 194)`)
// Gate height of at least 3 tiles (hubY - 1, hubY, hubY + 1)
for (let gy = hubY - 1; gy <= hubY + 1; gy += 1) {
const c = result.level.cells[gy]?.[bx]
const wall = c ? c.walls.some(w => w.prop1 !== 0 || w.style !== 0) : true
assert(!wall, `Seed ${String(seed)}: Gate opening tile at (${String(bx)}, ${String(gy)}) must be clear of walls`)
}
// Fortified end-caps
const topCap = result.level.cells[hubY - 2]?.[bx]
const bottomCap = result.level.cells[hubY + 2]?.[bx]
const hasTopCap = topCap ? topCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false
const hasBottomCap = bottomCap ? bottomCap.walls.some(w => w.style === 2 && w.prop1 === 129) : false
assert(hasTopCap, `Seed ${String(seed)}: Fortified top end-cap must exist at (${String(bx)}, ${String(hubY - 2)})`)
assert(hasBottomCap, `Seed ${String(seed)}: Fortified bottom end-cap must exist at (${String(bx)}, ${String(hubY + 2)})`)
}
// End-to-end corridor traversability:
// Verify BFS from West entrance (8, hubY) reaches East exit (W - 9, hubY)
const isWalkable = (x: number, y: number): boolean => {
const c = result.level.cells[y]?.[x]
if (!c) return false
const hasF = c.floors.some(f => !f.hidden && f.prop1 !== 0)
const hasW = c.walls.some(w => w.prop1 !== 0 || w.style !== 0)
return hasF && !hasW
}
const visited = new Set<number>()
const queue: [number, number][] = [[8, hubY]]
visited.add(hubY * W + 8)
while (queue.length > 0) {
const [cx, cy] = queue.shift()!
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) {
const key = ny * W + nx
if (!visited.has(key) && isWalkable(nx, ny)) {
visited.add(key)
queue.push([nx, ny])
}
}
}
}
const eastReached = visited.has(hubY * W + (W - 9)) || visited.has(hubY * W + (W - 10))
assert(
eastReached,
`Seed ${String(seed)}: Player must be able to traverse full horizontal corridor through all barricade gates from West to East`,
)
}
console.log(` ✓ Horizontal/Square Corridor ${String(dim.sizeX)}x${String(dim.sizeY)}: 100% assertions passed across all ${String(seeds.length)} seeds.`)
}
/* ----------------------------------------------------------------------- *
* SUMMARY
* ----------------------------------------------------------------------- */
const durationSec = ((Date.now() - startTime) / 1000).toFixed(2)
console.log('\n======================================================================')
console.log(`📊 STRESS TEST COMPLETE (${durationSec}s)`)
console.log('======================================================================')
console.log(`Total Assertions Executed: ${String(totalAssertions)}`)
console.log(`Passed Assertions: ${String(passedAssertions)}`)
console.log(`Failed Assertions: ${String(failedAssertions)}`)
if (failedAssertions > 0) {
console.error(`\n❌ VERDICT: CHALLENGE_FAILED (${String(failedAssertions)} assertion failures)`)
process.exit(1)
} else {
console.log('\n✅ VERDICT: APPROVE (100% empirical assertions passed)')
process.exit(0)
}
}
main().catch(err => {
console.error('Fatal error in stress test:', err)
process.exit(1)
})

View File

@ -1,594 +0,0 @@
/**
* Standalone Adversarial Stress-Test Script for Milestone M2
* Diablo II Web DRLG Engine: Organic Geometry & Fractional Jitter Profiler
*
* Requirements:
* 1. Stress-test Act 2 Oasis and Act 5 Frozen Lake radial lobe perturbations across 100+ random seeds and phase variations:
* - Numerically compute P^2 / A for continuous polar contour and discrete rasterized tile cells.
* - Assert P^2 / A > 4*pi * 1.15 holds 100% of the time (zero violations).
* 2. Stress-test jitterFrac: sample 10,000 draws and assert all values strictly lie in [base - delta, base + delta].
* 3. Verify Act 4 Lava Fissure flood-fill reachability >= 90%.
* 4. Verify Act 5 Transverse Barricade palisade orientation and choke-point gate clearance.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
} from '../src/game/wilderness.ts'
import {
act2OasisRadius,
act5FrozenLakeRadius,
jitterFrac,
type Act2TopographyStats,
type Act5TopographyStats,
} from '../src/game/wilderness-acts.ts'
import { Rng } from '../src/game/rng.ts'
/* ------------------------------------------------------------------------- *
* Mathematical Helpers: Continuous & Discrete Isoperimetric Quotients
* ------------------------------------------------------------------------- */
const ORGANIC_THRESHOLD = 4 * Math.PI * 1.15 // ~14.451326206513047
/**
* Numerically compute continuous polar contour area, perimeter, and P^2 / A.
*/
function computeContinuousPolarQuotient(
radiusFn: (theta: number) => number,
steps = 2000,
): { area: number; perimeter: number; quotient: number } {
let area = 0
let perimeter = 0
const dTheta = (2 * Math.PI) / steps
for (let i = 0; i < steps; i += 1) {
const t1 = i * dTheta
const t2 = (i + 1) * dTheta
const r1 = radiusFn(t1)
const r2 = radiusFn(t2)
// Sector area via cross product
area += 0.5 * r1 * r2 * Math.sin(dTheta)
// Arc length via Euclidean chord
const x1 = r1 * Math.cos(t1)
const y1 = r1 * Math.sin(t1)
const x2 = r2 * Math.cos(t2)
const y2 = r2 * Math.sin(t2)
perimeter += Math.hypot(x2 - x1, y2 - y1)
}
const quotient = (perimeter * perimeter) / area
return { area, perimeter, quotient }
}
/**
* Numerically compute discrete rasterized tile cells area, perimeter, and P^2 / A.
*/
function computeDiscreteRasterQuotient(
radiusFn: (theta: number) => number,
R: number,
): { area: number; perimeter: number; quotient: number } {
const maxR = Math.ceil(R * 1.5) + 3
const inside = new Set<string>()
for (let dy = -maxR; dy <= maxR; dy += 1) {
for (let dx = -maxR; dx <= maxR; dx += 1) {
const dist = Math.hypot(dx, dy)
const theta = Math.atan2(dy, dx)
const rLobe = radiusFn(theta)
if (dist <= rLobe) {
inside.add(`${dx},${dy}`)
}
}
}
const area = inside.size
let perimeter = 0
for (const key of inside) {
const comma = key.indexOf(',')
const x = Number(key.slice(0, comma))
const y = Number(key.slice(comma + 1))
if (!inside.has(`${x + 1},${y}`)) perimeter += 1
if (!inside.has(`${x - 1},${y}`)) perimeter += 1
if (!inside.has(`${x},${y + 1}`)) perimeter += 1
if (!inside.has(`${x},${y - 1}`)) perimeter += 1
}
const quotient = area > 0 ? (perimeter * perimeter) / area : 0
return { area, perimeter, quotient }
}
/* ------------------------------------------------------------------------- *
* Mock DS1 and Piece Builders for Hermetic Testing
* ------------------------------------------------------------------------- */
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const ds1 = makeMockDs1(8, 8)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [ds1] },
]
}
/* ------------------------------------------------------------------------- *
* Stress Test Harness
* ------------------------------------------------------------------------- */
async function main() {
console.log('======================================================================')
console.log('🔥 DIABLO II WEB DRLG: STANDALONE ADVERSARIAL STRESS TEST (MILESTONE M2)')
console.log('======================================================================\n')
let totalAssertions = 0
let passedAssertions = 0
let failedAssertions = 0
const failureLog: string[] = []
function check(condition: boolean, message: string) {
totalAssertions += 1
if (condition) {
passedAssertions += 1
} else {
failedAssertions += 1
failureLog.push(message)
console.error(` ❌ FAILED: ${message}`)
}
}
// -------------------------------------------------------------------------
// SECTION 1: JitterFrac Sampling (10,000 Draws)
// -------------------------------------------------------------------------
console.log('--- SECTION 1: STRESS-TESTING JitterFrac (10,000 Draws) ---')
const jitterRng = new Rng(0xbadf00d)
const testConfigs = [
{ base: 0.50, delta: 0.05, count: 2000 },
{ base: 0.35, delta: 0.04, count: 1500 },
{ base: 0.70, delta: 0.04, count: 1500 },
{ base: 0.22, delta: 0.05, count: 1500 },
{ base: 0.68, delta: 0.05, count: 1500 },
{ base: 0.40, delta: 0.05, count: 1000 },
// Boundary clamp checks
{ base: 0.03, delta: 0.05, count: 500 }, // lower clamp at 0.01
{ base: 0.97, delta: 0.05, count: 500 }, // upper clamp at 0.99
]
let totalDraws = 0
for (const cfg of testConfigs) {
const minAllowed = Math.max(0.01, cfg.base - cfg.delta)
const maxAllowed = Math.min(0.99, cfg.base + cfg.delta)
const draws: number[] = []
for (let i = 0; i < cfg.count; i += 1) {
const val = jitterFrac(jitterRng, cfg.base, cfg.delta)
totalDraws += 1
const inBounds = val >= minAllowed && val <= maxAllowed
if (!inBounds) {
check(false, `jitterFrac out of bounds: val=${val}, allowed=[${minAllowed}, ${maxAllowed}], base=${cfg.base}, delta=${cfg.delta}`)
}
draws.push(val)
}
const minObs = Math.min(...draws)
const maxObs = Math.max(...draws)
const meanObs = draws.reduce((a, b) => a + b, 0) / draws.length
check(minObs >= minAllowed && maxObs <= maxAllowed, `All ${cfg.count} draws strictly in [${minAllowed.toFixed(4)}, ${maxAllowed.toFixed(4)}]`)
console.log(
` Base: ${cfg.base.toFixed(2)} ± ${cfg.delta.toFixed(2)} (${cfg.count} draws) | ` +
`Observed: [${minObs.toFixed(4)} .. ${maxObs.toFixed(4)}] | Mean: ${meanObs.toFixed(4)} | PASSED`
)
}
check(totalDraws === 10000, `Total jitterFrac draws reached exactly 10,000 (${totalDraws})`)
console.log(` ✓ 10,000 draws sampled: 0 violations, 100% strictly bounded.\n`)
// -------------------------------------------------------------------------
// SECTION 2: Act 2 Oasis Radial Lobe Harmonic Perturbations (150+ Seeds & Phases)
// -------------------------------------------------------------------------
console.log('--- SECTION 2: STRESS-TESTING Act 2 Oasis Radial Lobes (P^2 / A > 4*pi * 1.15) ---')
const oasisRng = new Rng(0x0a515001)
const testRadiiAct2 = [4, 5, 6, 8, 10, 14, 20, 30]
let act2ContViolations = 0
let act2DiscViolations = 0
let act2TotalTests = 0
let minAct2ContQ = Infinity
let maxAct2ContQ = -Infinity
let minAct2DiscQ = Infinity
let maxAct2DiscQ = -Infinity
for (let seedIdx = 0; seedIdx < 150; seedIdx += 1) {
const phi1 = oasisRng.range(0, 2 * Math.PI)
const phi2 = oasisRng.range(0, 2 * Math.PI)
for (const R of testRadiiAct2) {
act2TotalTests += 1
const radiusFn = (theta: number) => act2OasisRadius(R, theta, phi1, phi2)
// Continuous polar contour
const cont = computeContinuousPolarQuotient(radiusFn, 2000)
if (cont.quotient < minAct2ContQ) minAct2ContQ = cont.quotient
if (cont.quotient > maxAct2ContQ) maxAct2ContQ = cont.quotient
if (cont.quotient <= ORGANIC_THRESHOLD) {
act2ContViolations += 1
check(false, `Act 2 continuous P^2/A violation: ${cont.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`)
}
// Discrete rasterized tile cells
const disc = computeDiscreteRasterQuotient(radiusFn, R)
if (disc.quotient < minAct2DiscQ) minAct2DiscQ = disc.quotient
if (disc.quotient > maxAct2DiscQ) maxAct2DiscQ = disc.quotient
if (disc.quotient <= ORGANIC_THRESHOLD) {
act2DiscViolations += 1
check(false, `Act 2 discrete P^2/A violation: ${disc.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`)
}
}
}
check(act2ContViolations === 0, `Act 2 continuous P^2/A zero violations across ${act2TotalTests} configurations`)
check(act2DiscViolations === 0, `Act 2 discrete P^2/A zero violations across ${act2TotalTests} configurations`)
console.log(` Act 2 Oasis Tested: ${act2TotalTests} configurations across 150 seeds and ${testRadiiAct2.length} radii.`)
console.log(` Continuous P^2/A range: [${minAct2ContQ.toFixed(4)} .. ${maxAct2ContQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`)
console.log(` Discrete P^2/A range: [${minAct2DiscQ.toFixed(4)} .. ${maxAct2DiscQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`)
console.log(` ✓ Act 2 Oasis: 0 violations, 100% strictly exceed isoperimetric threshold.\n`)
// -------------------------------------------------------------------------
// SECTION 3: Act 5 Frozen Lake Radial Lobe Harmonic Perturbations (150+ Seeds & Phases)
// -------------------------------------------------------------------------
console.log('--- SECTION 3: STRESS-TESTING Act 5 Frozen Lake Radial Lobes (P^2 / A > 4*pi * 1.15) ---')
const lakeRng = new Rng(0x1a7e5001)
const testRadiiAct5 = [6, 7, 8, 10, 12, 16, 22]
let act5ContViolations = 0
let act5DiscViolations = 0
let act5TotalTests = 0
let minAct5ContQ = Infinity
let maxAct5ContQ = -Infinity
let minAct5DiscQ = Infinity
let maxAct5DiscQ = -Infinity
for (let seedIdx = 0; seedIdx < 150; seedIdx += 1) {
const phi1 = lakeRng.range(0, 2 * Math.PI)
const phi2 = lakeRng.range(0, 2 * Math.PI)
for (const R of testRadiiAct5) {
act5TotalTests += 1
const radiusFn = (theta: number) => act5FrozenLakeRadius(R, theta, phi1, phi2)
// Continuous polar contour
const cont = computeContinuousPolarQuotient(radiusFn, 2000)
if (cont.quotient < minAct5ContQ) minAct5ContQ = cont.quotient
if (cont.quotient > maxAct5ContQ) maxAct5ContQ = cont.quotient
if (cont.quotient <= ORGANIC_THRESHOLD) {
act5ContViolations += 1
check(false, `Act 5 continuous P^2/A violation: ${cont.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`)
}
// Discrete rasterized tile cells
const disc = computeDiscreteRasterQuotient(radiusFn, R)
if (disc.quotient < minAct5DiscQ) minAct5DiscQ = disc.quotient
if (disc.quotient > maxAct5DiscQ) maxAct5DiscQ = disc.quotient
if (disc.quotient <= ORGANIC_THRESHOLD) {
act5DiscViolations += 1
check(false, `Act 5 discrete P^2/A violation: ${disc.quotient} <= ${ORGANIC_THRESHOLD} (R=${R}, phi1=${phi1}, phi2=${phi2})`)
}
}
}
check(act5ContViolations === 0, `Act 5 continuous P^2/A zero violations across ${act5TotalTests} configurations`)
check(act5DiscViolations === 0, `Act 5 discrete P^2/A zero violations across ${act5TotalTests} configurations`)
console.log(` Act 5 Frozen Lake Tested: ${act5TotalTests} configurations across 150 seeds and ${testRadiiAct5.length} radii.`)
console.log(` Continuous P^2/A range: [${minAct5ContQ.toFixed(4)} .. ${maxAct5ContQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`)
console.log(` Discrete P^2/A range: [${minAct5DiscQ.toFixed(4)} .. ${maxAct5DiscQ.toFixed(4)}] (Threshold: > ${ORGANIC_THRESHOLD.toFixed(4)})`)
console.log(` ✓ Act 5 Frozen Lake: 0 violations, 100% strictly exceed isoperimetric threshold.\n`)
// -------------------------------------------------------------------------
// SECTION 4: Real Generated Wilderness Level Audits across 100 Seeds
// -------------------------------------------------------------------------
console.log('--- SECTION 4: FULL WILDERNESS ENGINE MAP AUDITS (100 Seeds) ---')
const mapRng = new Rng(0x99887766)
const mapSeeds: number[] = [0x301cd095, 0x416d61c5, 0xdeadbeef]
for (let i = 0; i < 97; i += 1) {
mapSeeds.push(mapRng.int(100000, 99999999))
}
const act2Pieces = makeMockBorderPieces('Act 2')
const act4Pieces = makeMockBorderPieces('Act 4')
const act5Pieces = makeMockBorderPieces('Act 5')
let oasisMapChecks = 0
let frozenLakeMapChecks = 0
let fissureConnectivityChecks = 0
let barricadeChecks = 0
for (const seed of mapSeeds) {
// 1. Act 2 Far Oasis (Level 43)
const oasisResult = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act2Pieces,
substitutions: [],
})
const oW = oasisResult.level.width
const oH = oasisResult.level.height
const oCells = oasisResult.level.cells
const isOasisWater = (x: number, y: number) => {
if (x < 0 || x >= oW || y < 0 || y >= oH) return false
return oCells[y]![x]!.floors.some(f => f.style === 2 && f.prop1 === 2)
}
// Find connected components of oasis water
const visitedOasis = new Set<string>()
for (let y = 4; y < oH - 4; y += 1) {
for (let x = 4; x < oW - 4; x += 1) {
const key = `${x},${y}`
if (isOasisWater(x, y) && !visitedOasis.has(key)) {
// BFS component
const comp: [number, number][] = []
const queue: [number, number][] = [[x, y]]
visitedOasis.add(key)
while (queue.length > 0) {
const [cx, cy] = queue.shift()!
comp.push([cx, cy])
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
const nKey = `${nx},${ny}`
if (isOasisWater(nx, ny) && !visitedOasis.has(nKey)) {
visitedOasis.add(nKey)
queue.push([nx, ny])
}
}
}
if (comp.length >= 10) {
oasisMapChecks += 1
const compArea = comp.length
let compPerim = 0
for (const [cx, cy] of comp) {
if (!isOasisWater(cx + 1, cy)) compPerim += 1
if (!isOasisWater(cx - 1, cy)) compPerim += 1
if (!isOasisWater(cx, cy + 1)) compPerim += 1
if (!isOasisWater(cx, cy - 1)) compPerim += 1
}
const compQ = (compPerim * compPerim) / compArea
check(compQ > ORGANIC_THRESHOLD, `Map Far Oasis (seed ${seed}) pool P^2/A=${compQ.toFixed(2)} > ${ORGANIC_THRESHOLD.toFixed(2)}`)
}
}
}
}
// 2. Act 5 Arreat Plateau (Level 112)
const plateauResult = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act5Pieces,
substitutions: [],
})
const pH = plateauResult.level.height
const pW = plateauResult.level.width
const pCells = plateauResult.level.cells
const isFrozenLake = (x: number, y: number) => {
if (x < 0 || x >= pW || y < 0 || y >= pH) return false
return pCells[y]![x]!.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)
}
let lakeArea = 0
let lakePerim = 0
for (let y = 4; y < pH - 4; y += 1) {
for (let x = 4; x < pW - 4; x += 1) {
if (isFrozenLake(x, y)) {
lakeArea += 1
if (!isFrozenLake(x + 1, y)) lakePerim += 1
if (!isFrozenLake(x - 1, y)) lakePerim += 1
if (!isFrozenLake(x, y + 1)) lakePerim += 1
if (!isFrozenLake(x, y - 1)) lakePerim += 1
}
}
}
if (lakeArea >= 15) {
frozenLakeMapChecks += 1
const lakeQ = (lakePerim * lakePerim) / lakeArea
check(lakeQ > ORGANIC_THRESHOLD, `Map Arreat Plateau (seed ${seed}) lake P^2/A=${lakeQ.toFixed(2)} > ${ORGANIC_THRESHOLD.toFixed(2)}`)
}
// 3. Act 4 Outer Steppes (Level 104) - Lava Fissure Connectivity
const steppesResult = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces: act4Pieces,
substitutions: [],
})
const sW = steppesResult.level.width
const sH = steppesResult.level.height
const sCells = steppesResult.level.cells
const isWalkable = (x: number, y: number) => {
if (x < 8 || x >= sW - 8 || y < 8 || y >= sH - 8) return false
const cell = sCells[y]![x]!
return !cell.walls.some(w => w.prop1 !== 0 || w.style !== 0)
}
let totalWalkable = 0
let startX = -1
let startY = -1
for (let y = 8; y < sH - 8; y += 1) {
for (let x = 8; x < sW - 8; x += 1) {
if (isWalkable(x, y)) {
totalWalkable += 1
if (startX === -1 && y < 16) {
startX = x
startY = y
}
}
}
}
const visitedWalk = new Set<number>()
const walkQueue: [number, number][] = [[startX, startY]]
visitedWalk.add(startY * sW + startX)
while (walkQueue.length > 0) {
const [cx, cy] = walkQueue.shift()!
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = cx + dx
const ny = cy + dy
const key = ny * sW + nx
if (isWalkable(nx, ny) && !visitedWalk.has(key)) {
visitedWalk.add(key)
walkQueue.push([nx, ny])
}
}
}
const reachRatio = visitedWalk.size / totalWalkable
fissureConnectivityChecks += 1
check(reachRatio >= 0.90, `Act 4 Level 104 (seed ${seed}) reachability=${(reachRatio * 100).toFixed(2)}% >= 90%`)
// 4. Act 5 Frigid Highlands (Level 111) - Transverse Barricades
const barricadeResult = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 48,
sizeY: 160,
subType: 0,
subTheme: 0,
seed,
pieces: act5Pieces,
substitutions: [],
})
const bW = barricadeResult.level.width
const bH = barricadeResult.level.height
const bCells = barricadeResult.level.cells
const bInX = Math.floor(Math.floor(48 / 8) / 2)
const bHubX = bInX * 8 + 4
// Because height (160) > width (48), barricades must cut horizontally across X
const hBarricadeRows: number[] = []
for (let y = 8; y < bH - 8; y += 1) {
let palisadeCount = 0
for (let x = 8; x < bW - 8; x += 1) {
if (bCells[y]![x]!.walls.some(w => w.style === 2 && w.prop1 === 129)) {
palisadeCount += 1
}
}
if (palisadeCount >= 15) {
hBarricadeRows.push(y)
}
}
barricadeChecks += 1
check(hBarricadeRows.length >= 2, `Act 5 Level 111 (seed ${seed}) horizontal barricades: ${hBarricadeRows.length} >= 2`)
for (const by of hBarricadeRows) {
const gate = bCells[by]![bHubX]!
const hasWall = gate.walls.some(w => w.prop1 !== 0 || w.style !== 0)
const hasFloor = gate.floors.some(f => f.style === 5 && f.prop1 === 194)
check(!hasWall && hasFloor, `Act 5 Level 111 (seed ${seed}, y=${by}) gate at hubX=${bHubX} open`)
}
}
console.log(` Map Audits completed across 100 seeds:`)
console.log(` - Oasis pool components audited: ${oasisMapChecks} checks (all P^2/A > 14.45)`)
console.log(` - Frozen lake components audited: ${frozenLakeMapChecks} checks (all P^2/A > 14.45)`)
console.log(` - Act 4 fissure reachability audited: ${fissureConnectivityChecks} checks (all >= 90.00%)`)
console.log(` - Act 5 transverse barricade audited: ${barricadeChecks} checks (all perpendicular + open gate)`)
console.log(` ✓ Real level generation stress tests: 0 violations.\n`)
// -------------------------------------------------------------------------
// FINAL REPORT
// -------------------------------------------------------------------------
console.log('\n======================================================================')
console.log('📊 M2 ADVERSARIAL STRESS TEST RESULTS')
console.log('======================================================================')
console.log(`Total Assertions Executed: ${totalAssertions}`)
console.log(`Passed Assertions: ${passedAssertions}`)
console.log(`Failed Assertions: ${failedAssertions}`)
if (failedAssertions > 0) {
console.error('\n❌ FAILURES ENCOUNTERED:')
for (const f of failureLog) {
console.error(` - ${f}`)
}
process.exit(1)
}
console.log('\n✅ ALL MILESTONE M2 GEOMETRIC, ISOPERIMETRIC & TOPOLOGICAL INVARIANTS CONFIRMED:')
console.log('1. JitterFrac: 10,000 draws strictly in [base - delta, base + delta] with 0 violations.')
console.log(`2. Act 2 Oasis: Continuous and discrete P^2/A strictly > 4pi * 1.15 across all seeds/phases.`)
console.log(`3. Act 5 Frozen Lake: Continuous and discrete P^2/A strictly > 4pi * 1.15 across all seeds/phases.`)
console.log(`4. Full Map Generation (100 seeds): 100% of water/lake bodies satisfy P^2/A > 4pi * 1.15.`)
console.log(`5. Act 4 Lava Fissure: 100% of tested maps maintain >= 90% flood-fill reachability.`)
console.log(`6. Act 5 Barricades: Transverse direction invariance & open choke-point gates verified.`)
console.log('======================================================================\n')
}
main().catch(err => {
console.error('Fatal stress test runner error:', err)
process.exit(1)
})

View File

@ -1,147 +0,0 @@
import { resolveUnsetSize, UnresolvedLevelSizeError } from '../src/game/wilderness.ts'
import type { WildernessPiece, WildernessRequest } from '../src/game/wilderness.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 7,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
console.log('=== STARTING ADVERSARIAL STRESS TEST FOR resolveUnsetSize ===')
let failures = 0
function assert(condition: boolean, msg: string) {
if (!condition) {
console.error(`FAIL: ${msg}`)
failures++
} else {
console.log(`PASS: ${msg}`)
}
}
// 1. Anti-silent failure: Empty pieces throws UnresolvedLevelSizeError
try {
resolveUnsetSize({
levelId: 111,
levelName: 'Act 5 - Barricade 1',
levelTypeName: 'Act 5 - Barricade',
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
assert(false, 'Expected empty pieces to throw UnresolvedLevelSizeError')
} catch (err) {
assert(err instanceof UnresolvedLevelSizeError, 'Empty pieces throws instance of UnresolvedLevelSizeError')
assert((err as Error).message.includes('Level 111 (Act 5 - Barricade 1)'), 'Error message contains level info')
}
// 2. Anti-silent failure: Non-barricade pieces throw UnresolvedLevelSizeError
try {
resolveUnsetSize({
levelId: 111,
levelName: 'Act 5 - Barricade 1',
levelTypeName: 'Act 5 - Barricade',
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: [
{ name: 'Act 5 - Siege 1', border: false, levels: [makeMockDs1(10, 10)] },
],
substitutions: [],
})
assert(false, 'Expected non-barricade pieces to throw UnresolvedLevelSizeError')
} catch (err) {
assert(err instanceof UnresolvedLevelSizeError, 'Non-barricade pieces throw instance of UnresolvedLevelSizeError')
}
// 3. Fuzzing pieces dimensions: 10,000 random dimensions test
let allBoundsHold = true
let allAspectRatiosHold = true
for (let i = 0; i < 10000; i++) {
const randW1 = Math.floor(Math.random() * 2000) - 500 // -500 to 1500
const randH1 = Math.floor(Math.random() * 2000) - 500
const randW2 = Math.floor(Math.random() * 2000) - 500
const randH2 = Math.floor(Math.random() * 2000) - 500
const res = resolveUnsetSize({
levelId: 111,
levelName: 'Act 5 - Barricade 1',
levelTypeName: 'Act 5 - Barricade',
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: i,
pieces: [
{ name: 'Act 5 - Barricade Entrance', border: false, levels: [makeMockDs1(randW1, randH1)] },
{ name: 'Act 5 - Barricade Exit', border: false, levels: [makeMockDs1(randW2, randH2)] },
],
substitutions: [],
})
if (res.sizeX < 48 || res.sizeX > 64 || res.sizeY < 160 || res.sizeY > 192) {
allBoundsHold = false
console.error(`Bounds failure at iteration ${i}: sizeX=${res.sizeX}, sizeY=${res.sizeY}`)
break
}
if (res.sizeY <= res.sizeX || (res.sizeY / res.sizeX) < 2.5) {
allAspectRatiosHold = false
console.error(`Aspect ratio failure at iteration ${i}: sizeX=${res.sizeX}, sizeY=${res.sizeY}`)
break
}
}
assert(allBoundsHold, '10,000 random input tests: sizeX in [48..64] and sizeY in [160..192] strictly hold')
assert(allAspectRatiosHold, '10,000 random input tests: sizeY > sizeX and aspect ratio >= 2.5 strictly hold')
// 4. Overrides
const overrideRes = resolveUnsetSize({
levelId: 111,
levelName: 'Act 5 - Barricade 1',
levelTypeName: 'Act 5 - Barricade',
sizeX: -1,
sizeY: -1,
sizeOverride: { x: 72, y: 144 },
subType: 10,
subTheme: 0,
seed: 999,
pieces: [],
substitutions: [],
})
assert(overrideRes.sizeX === 72 && overrideRes.sizeY === 144, 'sizeOverride is respected exactly')
if (failures > 0) {
console.error(`\nFAILED: ${failures} assertions failed!`)
process.exit(1)
} else {
console.log('\nSUCCESS: All adversarial checks passed!')
}

View File

@ -1,269 +0,0 @@
/**
* Standalone Adversarial Stress Test Harness for resolveWorldOffsets
*
* Tests 100 distinct seeds across all 5 Acts against:
* 1. Seamless Adjacency Boundary Alignment (Zero Tears/Gaps)
* 2. Non-Overlapping Interior Areas (Seamless and All Levels)
* 3. Act 5 Level 111 Alignment to X = 702
* 4. Determinism
*/
import { SEAMLESS_ADJACENCY } from '../src/game/world-graph.ts'
import { resolveWorldOffsets, type WorldOffset } from '../src/game/world-placement.ts'
export function generateTestSeeds(): number[] {
const seedSet = new Set<number>([
0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999,
0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919,
])
let state = 0x8546291a
while (seedSet.size < 100) {
state = (state * 1664525 + 1013904223) >>> 0
seedSet.add(state)
}
return Array.from(seedSet)
}
export const TEST_SEEDS = generateTestSeeds()
export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean {
return (
a.offsetX < b.offsetX + b.sizeX &&
a.offsetX + a.sizeX > b.offsetX &&
a.offsetY < b.offsetY + b.sizeY &&
a.offsetY + a.sizeY > b.offsetY
)
}
export interface BoundaryCheckResult {
readonly touching: boolean
readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none'
readonly sharedLength: number
readonly gapX: number
readonly gapY: number
}
export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult {
const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX
const overlapY = Math.max(
0,
Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY),
)
if (touchLR && overlapY > 0) {
return {
touching: true,
type: 'vertical-boundary',
sharedLength: overlapY,
gapX: 0,
gapY: 0,
}
}
const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY
const overlapX = Math.max(
0,
Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX),
)
if (touchTB && overlapX > 0) {
return {
touching: true,
type: 'horizontal-boundary',
sharedLength: overlapX,
gapX: 0,
gapY: 0,
}
}
const gapX = a.offsetX + a.sizeX <= b.offsetX
? b.offsetX - (a.offsetX + a.sizeX)
: b.offsetX + b.sizeX <= a.offsetX
? a.offsetX - (b.offsetX + b.sizeX)
: 0
const gapY = a.offsetY + a.sizeY <= b.offsetY
? b.offsetY - (a.offsetY + a.sizeY)
: b.offsetY + b.sizeY <= a.offsetY
? a.offsetY - (b.offsetY + b.sizeY)
: 0
return {
touching: false,
type: 'none',
sharedLength: 0,
gapX,
gapY,
}
}
interface HarnessResults {
totalSeeds: number
p1SeamlessBoundary: { total: number; passed: number; failed: number; failuresByPair: Map<string, number> }
p2SeamlessInterior: { total: number; passed: number; failed: number }
p2AllLevelsInterior: { total: number; passed: number; failed: number; overlapCases: { seed: number; act: number; a: number; b: number }[] }
p3Act5Alignment: { total: number; passed: number; failed: number }
p4Determinism: { total: number; passed: number; failed: number }
}
export function runStressHarness(): HarnessResults {
const results: HarnessResults = {
totalSeeds: TEST_SEEDS.length,
p1SeamlessBoundary: { total: 0, passed: 0, failed: 0, failuresByPair: new Map() },
p2SeamlessInterior: { total: 0, passed: 0, failed: 0 },
p2AllLevelsInterior: { total: 0, passed: 0, failed: 0, overlapCases: [] },
p3Act5Alignment: { total: 0, passed: 0, failed: 0 },
p4Determinism: { total: 0, passed: 0, failed: 0 },
}
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
// 1. Seamless Boundary Sharing
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
for (const [aId, bId] of actSeamless) {
results.p1SeamlessBoundary.total++
const a = offsets.get(aId)!
const b = offsets.get(bId)!
const res = checkBoundarySharing(a, b)
if (res.touching) {
results.p1SeamlessBoundary.passed++
} else {
results.p1SeamlessBoundary.failed++
const key = `Act ${act}: [${aId}, ${bId}]`
results.p1SeamlessBoundary.failuresByPair.set(
key,
(results.p1SeamlessBoundary.failuresByPair.get(key) || 0) + 1,
)
}
}
// 2. Seamless Interior Non-Overlap
const seamlessIds = Array.from(new Set(actSeamless.flat()))
for (let i = 0; i < seamlessIds.length; i++) {
for (let j = i + 1; j < seamlessIds.length; j++) {
results.p2SeamlessInterior.total++
const a = offsets.get(seamlessIds[i])!;
const b = offsets.get(seamlessIds[j])!;
if (!doRectanglesOverlap(a, b)) {
results.p2SeamlessInterior.passed++
} else {
results.p2SeamlessInterior.failed++
}
}
}
// 2b. All Levels Interior Non-Overlap (including isolated levels)
const allPlaced = Array.from(offsets.values())
for (let i = 0; i < allPlaced.length; i++) {
for (let j = i + 1; j < allPlaced.length; j++) {
results.p2AllLevelsInterior.total++
const a = allPlaced[i]!
const b = allPlaced[j]!
if (!doRectanglesOverlap(a, b)) {
results.p2AllLevelsInterior.passed++
} else {
results.p2AllLevelsInterior.failed++
results.p2AllLevelsInterior.overlapCases.push({
seed,
act,
a: a.levelId,
b: b.levelId,
})
}
}
}
// 3. Act 5 Alignment
if (act === 5) {
results.p3Act5Alignment.total++
const l110 = offsets.get(110)
const l111 = offsets.get(111)
const l112 = offsets.get(112)
if (
l110 &&
l111 &&
l112 &&
l110.offsetX === 760 &&
l110.offsetY === 1000 &&
l111.sizeX === 58 &&
l111.offsetX === 702 &&
l111.offsetX + l111.sizeX === l110.offsetX &&
l112.sizeX === 58 &&
l112.offsetX === 702 &&
l112.offsetY + l112.sizeY === l111.offsetY
) {
results.p3Act5Alignment.passed++
} else {
results.p3Act5Alignment.failed++
}
}
// 4. Determinism
results.p4Determinism.total++
const replay = resolveWorldOffsets(act, seed)
const origEntries = JSON.stringify(Array.from(offsets.entries()))
const replayEntries = JSON.stringify(Array.from(replay.entries()))
if (origEntries === replayEntries) {
results.p4Determinism.passed++
} else {
results.p4Determinism.failed++
}
}
}
return results
}
const res = runStressHarness()
console.log('================================================================================')
console.log('ADVERSARIAL STRESS TEST REPORT: resolveWorldOffsets (100 Seeds x 5 Acts)')
console.log('================================================================================')
console.log(`Total Seeds Tested: ${res.totalSeeds}`)
console.log(`Property 1 (Seamless Boundary Sharing):`)
console.log(` - Total Checks: ${res.p1SeamlessBoundary.total}`)
console.log(` - Passed: ${res.p1SeamlessBoundary.passed} (${((res.p1SeamlessBoundary.passed / res.p1SeamlessBoundary.total) * 100).toFixed(2)}%)`)
console.log(` - Failed: ${res.p1SeamlessBoundary.failed} (${((res.p1SeamlessBoundary.failed / res.p1SeamlessBoundary.total) * 100).toFixed(2)}%)`)
console.log(` - Failure Breakdown:`)
for (const [pair, count] of res.p1SeamlessBoundary.failuresByPair.entries()) {
console.log(` * ${pair}: ${count} failures (${((count / res.totalSeeds) * 100).toFixed(1)}% failure rate)`)
}
console.log(`Property 2a (Seamless Levels Interior Non-Overlap):`)
console.log(` - Total Checks: ${res.p2SeamlessInterior.total}`)
console.log(` - Passed: ${res.p2SeamlessInterior.passed} (100%)`)
console.log(` - Failed: ${res.p2SeamlessInterior.failed}`)
console.log(`Property 2b (All Placed Levels Interior Non-Overlap):`)
console.log(` - Total Checks: ${res.p2AllLevelsInterior.total}`)
console.log(` - Passed: ${res.p2AllLevelsInterior.passed} (${((res.p2AllLevelsInterior.passed / res.p2AllLevelsInterior.total) * 100).toFixed(4)}%)`)
console.log(` - Failed: ${res.p2AllLevelsInterior.failed}`)
if (res.p2AllLevelsInterior.overlapCases.length > 0) {
console.log(` - Overlap Cases:`)
for (const c of res.p2AllLevelsInterior.overlapCases) {
console.log(` * Seed ${c.seed} (Act ${c.act}): Level ${c.a} collides with Level ${c.b}`)
}
}
console.log(`Property 3 (Act 5 Level 111 Alignment X = 702):`)
console.log(` - Total Checks: ${res.p3Act5Alignment.total}`)
console.log(` - Passed: ${res.p3Act5Alignment.passed} (100%)`)
console.log(` - Failed: ${res.p3Act5Alignment.failed}`)
console.log(`Property 4 (Determinism Replay):`)
console.log(` - Total Checks: ${res.p4Determinism.total}`)
console.log(` - Passed: ${res.p4Determinism.passed} (100%)`)
console.log(` - Failed: ${res.p4Determinism.failed}`)
console.log('--------------------------------------------------------------------------------')
const totalAssertions =
res.p1SeamlessBoundary.total +
res.p2SeamlessInterior.total +
res.p2AllLevelsInterior.total +
res.p3Act5Alignment.total +
res.p4Determinism.total
const totalFailures =
res.p1SeamlessBoundary.failed +
res.p2SeamlessInterior.failed +
res.p2AllLevelsInterior.failed +
res.p3Act5Alignment.failed +
res.p4Determinism.failed
console.log(`SUMMARY (Required Acceptance Metrics):`)
console.log(` - Total Assertions: ${totalAssertions}`)
console.log(` - Passed: ${totalAssertions - totalFailures}`)
console.log(` - Failed: ${totalFailures}`)
console.log(` - VERDICT: ${totalFailures === 0 ? 'APPROVE' : 'REJECT'}`)
console.log('================================================================================')

View File

@ -1,383 +0,0 @@
/**
* Empirical Challenger Verification Harness for Milestone M3 World Placement
*
* Adversarially challenges:
* 1. Property 1: 100% boundary sharing (sharedLength > 0, gap = 0) for all SEAMLESS_ADJACENCY pairs
* 2. Property 2a: 0 interior overlaps between any pair of placed seamless levels
* 3. Property 2b: 0 interior overlaps between ALL placed levels (seamless, isolated, dependent)
* 4. Property 3: Act 5 Level 111 West edge aligns at X = 702 and ka.ma0 Layer 79 bounding box
* 5. Property 4: Determinism replay produces 100% identical coordinate maps across all seeds
* 6. Level 33 relative placement at (3996, 966) abutting Level 32 at (4000, 1000)
* 7. Level 27 relative placement on Level 26
* 8. Strict finiteness and non-negativity across all coordinates
*/
import { SEAMLESS_ADJACENCY } from '../src/game/world-graph.ts'
import {
resolveWorldOffsets,
type WorldOffset,
CANONICAL_LEVEL_ROWS,
ACT_ANCHORS,
} from '../src/game/world-placement.ts'
export function generateTestSeeds(): number[] {
const seedSet = new Set<number>([
0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999,
0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919,
])
let state = 0x8546291a
while (seedSet.size < 100) {
state = (state * 1664525 + 1013904223) >>> 0
seedSet.add(state)
}
return Array.from(seedSet)
}
export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean {
return (
a.offsetX < b.offsetX + b.sizeX &&
a.offsetX + a.sizeX > b.offsetX &&
a.offsetY < b.offsetY + b.sizeY &&
a.offsetY + a.sizeY > b.offsetY
)
}
export interface BoundaryCheckResult {
readonly touching: boolean
readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none'
readonly sharedLength: number
readonly gapX: number
readonly gapY: number
}
export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult {
const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX
const overlapY = Math.max(
0,
Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY),
)
if (touchLR && overlapY > 0) {
return {
touching: true,
type: 'vertical-boundary',
sharedLength: overlapY,
gapX: 0,
gapY: 0,
}
}
const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY
const overlapX = Math.max(
0,
Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX),
)
if (touchTB && overlapX > 0) {
return {
touching: true,
type: 'horizontal-boundary',
sharedLength: overlapX,
gapX: 0,
gapY: 0,
}
}
const gapX = a.offsetX + a.sizeX <= b.offsetX
? b.offsetX - (a.offsetX + a.sizeX)
: b.offsetX + b.sizeX <= a.offsetX
? a.offsetX - (b.offsetX + b.sizeX)
: 0
const gapY = a.offsetY + a.sizeY <= b.offsetY
? b.offsetY - (a.offsetY + a.sizeY)
: b.offsetY + b.sizeY <= a.offsetY
? a.offsetY - (b.offsetY + b.sizeY)
: 0
return {
touching: false,
type: 'none',
sharedLength: 0,
gapX,
gapY,
}
}
async function runAdversarialAudit(): Promise<void> {
console.log('================================================================================')
console.log('🛡️ EMPIRICAL CHALLENGER AUDIT: World Placement & Graph Topology')
console.log('================================================================================')
const seeds = generateTestSeeds()
console.log(`Generated ${seeds.length} test seeds (including edge seeds 0, 1, 0x7fffffff, 0xffffffff, etc.)`)
let totalAssertions = 0
let totalFailures = 0
// 1. Property 1: Boundary sharing for all SEAMLESS_ADJACENCY pairs
let p1Total = 0
let p1Passed = 0
let p1Failed = 0
const p1Failures: string[] = []
// 2a. Property 2a: Seamless levels interior non-overlap
let p2aTotal = 0
let p2aPassed = 0
let p2aFailed = 0
// 2b. Property 2b: All placed levels interior non-overlap
let p2bTotal = 0
let p2bPassed = 0
let p2bFailed = 0
const p2bCollisions: string[] = []
// 3. Property 3: Act 5 Level 111 alignment at X = 702
let p3Total = 0
let p3Passed = 0
let p3Failed = 0
// 4. Property 4: Determinism replay
let p4Total = 0
let p4Passed = 0
let p4Failed = 0
// 5. Level 33 relative placement at (3996, 966) abutting Level 32
let l33Total = 0
let l33Passed = 0
let l33Failed = 0
// 6. Level 27 relative placement on Level 26
let l27Total = 0
let l27Passed = 0
let l27Failed = 0
// 7. Sanity: Coordinates non-negative and finite
let sanityTotal = 0
let sanityPassed = 0
let sanityFailed = 0
for (const seed of seeds) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
// Sanity checks on all offsets
for (const [id, off] of offsets) {
sanityTotal++
if (
Number.isFinite(off.offsetX) &&
Number.isFinite(off.offsetY) &&
Number.isFinite(off.sizeX) &&
Number.isFinite(off.sizeY) &&
off.offsetX >= 0 &&
off.offsetY >= 0 &&
off.sizeX > 0 &&
off.sizeY > 0
) {
sanityPassed++
} else {
sanityFailed++
console.error(`SANITY FAIL: Seed ${seed}, Act ${act}, Level ${id}: ${JSON.stringify(off)}`)
}
}
// Property 1: Boundary sharing
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
for (const [aId, bId] of actSeamless) {
p1Total++
const a = offsets.get(aId)!
const b = offsets.get(bId)!
const res = checkBoundarySharing(a, b)
if (res.touching && res.sharedLength > 0 && res.gapX === 0 && res.gapY === 0) {
p1Passed++
} else {
p1Failed++
p1Failures.push(`Seed ${seed}, Act ${act}: [${aId}, ${bId}] gapX=${res.gapX}, gapY=${res.gapY}, sharedLen=${res.sharedLength}`)
}
}
// Property 2a: Seamless interior non-overlap
const seamlessIds = Array.from(new Set(actSeamless.flat()))
for (let i = 0; i < seamlessIds.length; i++) {
for (let j = i + 1; j < seamlessIds.length; j++) {
p2aTotal++
const a = offsets.get(seamlessIds[i]!)!
const b = offsets.get(seamlessIds[j]!)!
if (!doRectanglesOverlap(a, b)) {
p2aPassed++
} else {
p2aFailed++
}
}
}
// Property 2b: All placed levels interior non-overlap
const allLevels = Array.from(offsets.values())
for (let i = 0; i < allLevels.length; i++) {
for (let j = i + 1; j < allLevels.length; j++) {
p2bTotal++
const a = allLevels[i]!
const b = allLevels[j]!
if (!doRectanglesOverlap(a, b)) {
p2bPassed++
} else {
p2bFailed++
p2bCollisions.push(`Seed ${seed}, Act ${act}: Level ${a.levelId} overlaps Level ${b.levelId}`)
}
}
}
// Property 3: Act 5 Alignment
if (act === 5) {
p3Total++
const l109 = offsets.get(109)
const l110 = offsets.get(110)
const l111 = offsets.get(111)
const l112 = offsets.get(112)
const cond1 = l109 !== undefined && l109.offsetX === 1000 && l109.offsetY === 1000
const cond2 = l110 !== undefined && l110.offsetX === 760 && l110.offsetY === 1000 && l110.sizeX === 240
const cond3 = l111 !== undefined && l111.offsetX === 702 && l111.sizeX === 58 && l111.offsetX + l111.sizeX === 760
const cond4 = l112 !== undefined && l112.offsetX === 702 && l112.sizeX === 58
const cond5 = l111 !== undefined && l112 !== undefined && l112.offsetY + l112.sizeY === l111.offsetY
const cond6 = l111 !== undefined && l112 !== undefined &&
Math.min(l111.offsetX, l112.offsetX) === 702 &&
Math.max(l111.offsetX + l111.sizeX - 1, l112.offsetX + l112.sizeX - 1) === 759 &&
Math.min(l111.offsetY, l112.offsetY) === 717 &&
Math.max(l111.offsetY + l111.sizeY, l112.offsetY + l112.sizeY) === 1026
if (cond1 && cond2 && cond3 && cond4 && cond5 && cond6) {
p3Passed++
} else {
p3Failed++
console.error(`P3 FAIL: Seed ${seed}, conds:`, { cond1, cond2, cond3, cond4, cond5, cond6 })
}
}
// Property 4: Determinism replay
p4Total++
const replay = resolveWorldOffsets(act, seed)
const aEntries = Array.from(offsets.entries()).sort((a, b) => a[0] - b[0])
const bEntries = Array.from(replay.entries()).sort((a, b) => a[0] - b[0])
if (JSON.stringify(aEntries) === JSON.stringify(bEntries)) {
p4Passed++
} else {
p4Failed++
}
// Level 33 relative placement at (3996, 966) abutting Level 32
if (act === 1) {
l33Total++
const l32 = offsets.get(32)
const l33 = offsets.get(33)
if (
l32 &&
l33 &&
l32.offsetX === 4000 &&
l32.offsetY === 1000 &&
l32.sizeX === 18 &&
l32.sizeY === 20 &&
l33.offsetX === 3996 &&
l33.offsetY === 966 &&
l33.sizeX === 28 &&
l33.sizeY === 34 &&
l33.offsetY + l33.sizeY === l32.offsetY && // Abuts north edge exactly at Y = 1000
!doRectanglesOverlap(l32, l33) && // Interior non-overlap
Math.max(l32.offsetX, l33.offsetX) === 4000 && // Shared segment [4000, 4018]
Math.min(l32.offsetX + l32.sizeX, l33.offsetX + l33.sizeX) === 4018
) {
l33Passed++
} else {
l33Failed++
console.error(`L33 FAIL: Seed ${seed}, l32=${JSON.stringify(l32)}, l33=${JSON.stringify(l33)}`)
}
// Level 27 relative placement on Level 26
l27Total++
const l26 = offsets.get(26)
const l27 = offsets.get(27)
if (
l26 &&
l27 &&
l27.offsetX === l26.offsetX + Math.floor((l26.sizeX - l27.sizeX) / 2) &&
l27.offsetY === l26.offsetY - l27.sizeY &&
l27.sizeX === 56 &&
l27.sizeY === 40 &&
l27.offsetY + l27.sizeY === l26.offsetY &&
!doRectanglesOverlap(l26, l27)
) {
l27Passed++
} else {
l27Failed++
}
}
}
}
totalAssertions =
sanityTotal +
p1Total +
p2aTotal +
p2bTotal +
p3Total +
p4Total +
l33Total +
l27Total
totalFailures =
sanityFailed +
p1Failed +
p2aFailed +
p2bFailed +
p3Failed +
p4Failed +
l33Failed +
l27Failed
console.log('\n--------------------------------------------------------------------------------')
console.log('RESULTS BY PROPERTY:')
console.log('--------------------------------------------------------------------------------')
console.log(`Property 1 (Seamless Boundary Sharing, sharedLength > 0):`)
console.log(` Passed: ${p1Passed} / ${p1Total} (${((p1Passed / p1Total) * 100).toFixed(2)}%)`)
console.log(` Failed: ${p1Failed}`)
console.log(`Property 2a (Seamless Levels Interior Non-Overlap):`)
console.log(` Passed: ${p2aPassed} / ${p2aTotal} (${((p2aPassed / p2aTotal) * 100).toFixed(2)}%)`)
console.log(` Failed: ${p2aFailed}`)
console.log(`Property 2b (All Placed Levels Interior Non-Overlap):`)
console.log(` Passed: ${p2bPassed} / ${p2bTotal} (${((p2bPassed / p2bTotal) * 100).toFixed(4)}%)`)
console.log(` Failed: ${p2bFailed}`)
console.log(`Property 3 (Act 5 Level 111 Alignment X = 702 & Automap Cluster):`)
console.log(` Passed: ${p3Passed} / ${p3Total} (${((p3Passed / p3Total) * 100).toFixed(2)}%)`)
console.log(` Failed: ${p3Failed}`)
console.log(`Property 4 (Determinism Replay across 100 Seeds x 5 Acts):`)
console.log(` Passed: ${p4Passed} / ${p4Total} (${((p4Passed / p4Total) * 100).toFixed(2)}%)`)
console.log(` Failed: ${p4Failed}`)
console.log(`Level 33 Placement at (3996, 966) Abutting Level 32:`)
console.log(` Passed: ${l33Passed} / ${l33Total} (${((l33Passed / l33Total) * 100).toFixed(2)}%)`)
console.log(` Failed: ${l33Failed}`)
console.log(`Level 27 Placement Abutting Level 26:`)
console.log(` Passed: ${l27Passed} / ${l27Total} (${((l27Passed / l27Total) * 100).toFixed(2)}%)`)
console.log(` Failed: ${l27Failed}`)
console.log(`Coordinate Sanity (Non-negative, finite):`)
console.log(` Passed: ${sanityPassed} / ${sanityTotal} (${((sanityPassed / sanityTotal) * 100).toFixed(2)}%)`)
console.log(` Failed: ${sanityFailed}`)
console.log('================================================================================')
console.log(`TOTAL ASSERTIONS EXECUTED: ${totalAssertions}`)
console.log(`TOTAL FAILURES RECORDED: ${totalFailures}`)
console.log(`EMPIRICAL CHALLENGER VERDICT: ${totalFailures === 0 ? 'APPROVE' : 'CHALLENGE_FAILED'}`)
console.log('================================================================================')
if (totalFailures > 0) {
process.exit(1)
}
}
runAdversarialAudit().catch(err => {
console.error('Fatal error during adversarial audit:', err)
process.exit(1)
})

View File

@ -1,519 +0,0 @@
/**
* Adversarial Empirical Challenger Harness: Wilderness Traversability & Walkability Stress Test
*
* Scope:
* 1. Pre-Baked Pack Traversability Audit:
* - All 60 procedural wilderness variants across Acts 2..5 (Levels 41..46, 76..81, 104..106, 110..112, 117, 134).
* - Plus all 24 Act 1 procedural wilderness variants (Levels 2..7, 17, 39).
* - Total: 84 baked scene variants.
* - Asserts:
* * Spawn point is non-null, in-bounds, walkable (blocked === 0), and grounded on floor cell.
* * Area block reachability >= 90% (canonical DRLG area connectivity).
* * Every exit/entrance warp is reachable from spawn via 8-way BFS.
* * Every border entrance seam is reachable from spawn via 8-way BFS.
* * Every waypoint pedestal is reachable from spawn via 8-way BFS.
* * Bidirectional path connectivity between entry and exit points.
*
* 2. Multi-Seed Procedural Wilderness Stress Test:
* - Generates all 20 procedural wilderness levels in Acts 2..5 across 10 diverse seeds (200 generations).
* - Asserts:
* * findIsoSpawn succeeds and lands on a walkable, floor-grounded sub-tile.
* * Procedural layout has >= 90% area reachability.
* * All generated warps and seams are reachable from player spawn.
*/
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadActTables, parseTable, cell, tileMemberPath, resolveLevelLibraries } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1, type Dt1 } from '../src/formats/dt1.ts'
import { buildIsoMapScene, subTileAt, levelSeed } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { generateWilderness, type WildernessPiece, type WildernessSubstitution } from '../src/game/wilderness.ts'
import { findIsoSpawn } from '../src/game/level-links.ts'
const packDir = 'samples/d2-packs'
const archiveDir = 'samples/d2'
let totalChecks = 0
let failedChecks = 0
const failures: string[] = []
function check(ok: boolean, message: string): void {
totalChecks += 1
if (!ok) {
failedChecks += 1
failures.push(message)
console.log(` FAIL: ${message}`)
}
}
// =========================================================================
// PART 1: BAKED PACK TRAVERSABILITY AUDIT
// =========================================================================
console.log('=== PART 1: BAKED SCENE TRAVERSABILITY AUDIT (84 WILDERNESS VARIANTS) ===\n')
const index = JSON.parse(readFileSync(join(packDir, 'index.json'), 'utf8')) as {
levels: readonly { act: number; levelId: number; path: string; ds1: string; label: string; kind?: string }[]
}
const targetWildernessLevelIds = [
// Act 1
2, 3, 4, 5, 6, 7, 17, 39,
// Act 2
41, 42, 43, 44, 45, 46,
// Act 3
76, 77, 78, 79, 80, 81,
// Act 4
104, 105, 106,
// Act 5
110, 111, 112, 117, 134,
]
const bakedVariants = index.levels.filter(l => targetWildernessLevelIds.includes(l.levelId))
console.log(`Found ${bakedVariants.length} matching wilderness variants in index.json.\n`)
for (const entry of bakedVariants) {
const scenePath = join(packDir, entry.path, 'scene.json')
check(existsSync(scenePath), `${entry.path}: scene.json exists`)
if (!existsSync(scenePath)) continue
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
const W = scene.collision.width
const H = scene.collision.height
const expanded = new Uint8Array(W * H)
let cursor = 0
for (const run of scene.collision.runs) {
const val = run[0] ?? 0
const len = run[1] ?? 0
if (val !== 0) expanded.fill(val, cursor, Math.min(cursor + len, expanded.length))
cursor += len
}
const floorSet = new Set<string>()
for (const f of scene.floors) {
floorSet.add(`${f[3]},${f[4]}`)
}
const grid = {
originX: scene.originX,
originY: scene.originY,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
gridWidth: W,
blocked: expanded,
}
// 1. Spawn existence & validity
check(scene.spawn !== null && Array.isArray(scene.spawn) && scene.spawn.length === 2, `${entry.path}: spawn exists`)
if (!scene.spawn) continue
const st = subTileAt(grid, scene.spawn[0], scene.spawn[1])
check(st.inBounds, `${entry.path}: spawn is in-bounds (${st.subX}, ${st.subY})`)
if (!st.inBounds) continue
const isSpawnWalkable = expanded[st.subY * W + st.subX] === 0
check(isSpawnWalkable, `${entry.path}: spawn sub-tile is walkable (blocked=${expanded[st.subY * W + st.subX]})`)
const spawnCellX = Math.floor(st.subX / 5)
const spawnCellY = Math.floor(st.subY / 5)
check(floorSet.has(`${spawnCellX},${spawnCellY}`), `${entry.path}: spawn is grounded on floor cell (${spawnCellX}, ${spawnCellY})`)
// 2. BFS Flood Fill from spawn
const visited = new Uint8Array(W * H)
const dist = new Int32Array(W * H).fill(-1)
const queue = [st.subX, st.subY]
visited[st.subY * W + st.subX] = 1
dist[st.subY * W + st.subX] = 0
let head = 0
while (head < queue.length) {
const cx = queue[head++]!
const cy = queue[head++]!
const curDist = dist[cy * W + cx]!
const neighbours = [
[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1],
[cx + 1, cy + 1], [cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1],
]
for (const [nx, ny] of neighbours) {
if (nx >= 0 && nx < W && ny >= 0 && ny < H) {
const idx = ny * W + nx
if (visited[idx] === 0 && expanded[idx] === 0) {
visited[idx] = 1
dist[idx] = curDist + 1
queue.push(nx, ny)
}
}
}
}
// 3. Area-based block reachability (8x8 cell areas)
const AREA_CELLS = 8
const blocksX = Math.floor(scene.cellsX / AREA_CELLS)
const blocksY = Math.floor(scene.cellsY / AREA_CELLS)
let totalBlocks = 0
let reachedBlocks = 0
for (let by = 0; by < blocksY; by++) {
for (let bx = 0; bx < blocksX; bx++) {
let blockHasWalkable = false
let blockIsReached = false
for (let cy = by * AREA_CELLS; cy < (by + 1) * AREA_CELLS; cy++) {
for (let cx = bx * AREA_CELLS; cx < (bx + 1) * AREA_CELLS; cx++) {
if (!floorSet.has(`${cx},${cy}`)) continue
for (let sy = 0; sy < 5; sy++) {
for (let sx = 0; sx < 5; sx++) {
const gx = cx * 5 + sx
const gy = cy * 5 + sy
if (gx < W && gy < H && expanded[gy * W + gx] === 0) {
blockHasWalkable = true
if (visited[gy * W + gx] === 1) blockIsReached = true
}
}
}
}
}
if (blockHasWalkable) {
totalBlocks++
if (blockIsReached) reachedBlocks++
}
}
}
const blockReachRatio = totalBlocks > 0 ? (reachedBlocks / totalBlocks) : 1
check(blockReachRatio >= 0.90, `${entry.path}: block reachability >= 90% (${reachedBlocks}/${totalBlocks} = ${(blockReachRatio * 100).toFixed(1)}%)`)
// 4. Warp connectivity
for (const w of scene.warps ?? []) {
const ax = w.arriveX ?? w.x
const ay = w.arriveY ?? w.y
let minD = -1
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -5; dx <= 5; dx++) {
const wx = ax + dx
const wy = ay + dy
if (wx >= 0 && wx < W && wy >= 0 && wy < H && visited[wy * W + wx] === 1) {
const d = dist[wy * W + wx]!
if (minD === -1 || d < minD) minD = d
}
}
}
check(minD !== -1, `${entry.path}: warp to level ${w.toLevelId} (${w.label ?? ''}) at (${ax},${ay}) reachable (dist=${minD})`)
}
// 5. Entrance connectivity
for (const ent of scene.entrances ?? []) {
const ax = ent.arriveX ?? ent.x
const ay = ent.arriveY ?? ent.y
let minD = -1
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const ex = ax + dx
const ey = ay + dy
if (ex >= 0 && ex < W && ey >= 0 && ey < H && visited[ey * W + ex] === 1) {
const d = dist[ey * W + ex]!
if (minD === -1 || d < minD) minD = d
}
}
}
check(minD !== -1, `${entry.path}: entrance to level ${ent.toLevelId} (${ent.label ?? ''}) at (${ax},${ay}) reachable (dist=${minD})`)
}
// 6. Waypoint connectivity
for (const wp of scene.waypoints ?? []) {
const ax = wp.arriveX ?? wp.x
const ay = wp.arriveY ?? wp.y
let minD = -1
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -5; dx <= 5; dx++) {
const wx = ax + dx
const wy = ay + dy
if (wx >= 0 && wx < W && wy >= 0 && wy < H && visited[wy * W + wx] === 1) {
const d = dist[wy * W + wx]!
if (minD === -1 || d < minD) minD = d
}
}
}
check(minD !== -1, `${entry.path}: waypoint ${wp.waypointId} at (${ax},${ay}) reachable (dist=${minD})`)
}
// 7. Cross-transition traversability (from entrance A to exit B)
const allEndpoints = [
...(scene.warps ?? []).map((w: any) => ({ type: 'warp', to: w.toLevelId, x: w.arriveX ?? w.x, y: w.arriveY ?? w.y })),
...(scene.entrances ?? []).map((e: any) => ({ type: 'entrance', to: e.toLevelId, x: e.arriveX ?? e.x, y: e.arriveY ?? e.y })),
]
if (allEndpoints.length >= 2) {
for (let i = 0; i < allEndpoints.length; i++) {
for (let j = i + 1; j < allEndpoints.length; j++) {
const ep1 = allEndpoints[i]!
const ep2 = allEndpoints[j]!
const ep1Reached = visited[ep1.y * W + ep1.x] === 1 || hasVisitedNeighbor(visited, W, H, ep1.x, ep1.y, 4)
const ep2Reached = visited[ep2.y * W + ep2.x] === 1 || hasVisitedNeighbor(visited, W, H, ep2.x, ep2.y, 4)
check(ep1Reached && ep2Reached, `${entry.path}: path exists between ${ep1.type} to ${ep1.to} and ${ep2.type} to ${ep2.to}`)
}
}
}
}
function hasVisitedNeighbor(visited: Uint8Array, W: number, H: number, x: number, y: number, radius: number): boolean {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx
const ny = y + dy
if (nx >= 0 && nx < W && ny >= 0 && ny < H && visited[ny * W + nx] === 1) return true
}
}
return false
}
console.log(`Part 1 complete: ${totalChecks - failedChecks}/${totalChecks} checks passed.`)
// =========================================================================
// PART 2: MULTI-SEED PROCEDURAL WILDERNESS GENERATION STRESS TEST
// =========================================================================
console.log('\n=== PART 2: PROCEDURAL MULTI-SEED STRESS TEST (ACTS 2..5) ===\n')
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const tables = await loadActTables(archives)
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(arch: MountedArchives, relative: string): Promise<Ds1> {
const member = tileMemberPath(relative)
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await arch.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(arch: MountedArchives, table: D2Table, row: readonly string[]): Promise<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(arch, value))
}
return levels
}
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
async function wildernessPieces(arch: MountedArchives, lvlprest: D2Table, levelTypeName: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of lvlprest.rows) {
const name = cell(lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(arch, lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
async function substitutions(arch: MountedArchives, subTable: D2Table, type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of subTable.rows) {
if (Number(cell(subTable, row, 'Type')) !== type) continue
const file = cell(subTable, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(arch, file)]
rows.push({
name: cell(subTable, row, 'Name'),
type,
gridSize: Number(cell(subTable, row, 'GridSize')) || 1,
bordType: Number(cell(subTable, row, 'BordType')),
dt1Mask: Number(cell(subTable, row, 'Dt1Mask')) || 0,
prob: themeValues(subTable, row, 'Prob'),
trials: themeValues(subTable, row, 'Trials'),
max: themeValues(subTable, row, 'Max'),
levels,
})
}
return rows
}
const dt1Cache = new Map<string, Dt1>()
async function loadDt1(arch: MountedArchives, name: string): Promise<Dt1> {
const cached = dt1Cache.get(name)
if (cached !== undefined) return cached
const decoded = decodeDt1(await arch.read(name))
dt1Cache.set(name, decoded)
return decoded
}
async function decodeLibraries(arch: MountedArchives, names: readonly string[]): Promise<Dt1[]> {
const libraries: Dt1[] = []
for (const name of names) {
try {
libraries.push(await loadDt1(arch, name))
} catch {}
}
return libraries
}
const stressSeeds = [
0x301cd095,
0x416d61c5,
0xdeadbeef,
42,
0x5eed_2026,
]
const procLevels = [
// Act 2
41, 42, 43, 44, 45, 46,
// Act 3
76, 77, 78, 79, 80, 81,
// Act 4
104, 105, 106,
// Act 5
110, 111, 112, 117, 134,
]
let procGenerations = 0
for (const levelId of procLevels) {
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)!
const levelName = cell(tables.levels, levelRow, 'Name')
const typeId = cell(tables.levels, levelRow, 'LevelType')
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === typeId)!
const typeName = typeRow === undefined ? '' : cell(tables.lvltypes, typeRow, 'Name')
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await wildernessPieces(archives, tables.lvlprest, typeName)
const rows = await substitutions(archives, lvlsub, subType)
const shrineRows = await substitutions(archives, lvlsub, subShrine)
const libraries = resolveLevelLibraries(tables, levelId)
const dt1s = await decodeLibraries(archives, libraries.dt1Names)
console.log(`Checking Level ${levelId} (${levelName}) across ${stressSeeds.length} seeds...`)
for (const seed of stressSeeds) {
procGenerations++
const request = {
levelId,
levelName,
levelTypeName: typeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: rows,
shrineSubstitutions: shrineRows,
}
try {
const generated = generateWilderness(request)
const scene = buildIsoMapScene(generated.level, dt1s, levelSeed('generated'))
// Verify spawn
const spawn = findIsoSpawn(scene)
check(spawn !== null, `Proc Lvl ${levelId} (${levelName}) seed 0x${seed.toString(16)}: spawn point found`)
if (spawn !== null) {
const st = subTileAt(scene, spawn.x, spawn.y)
check(st.inBounds, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: spawn in-bounds (${st.subX}, ${st.subY})`)
const isWalkable = scene.blocked[st.subY * scene.gridWidth + st.subX] === 0
check(isWalkable, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: spawn is walkable`)
// BFS flood fill
const W = scene.gridWidth
const H = scene.cellsY * 5
const visited = new Uint8Array(W * H)
const q = [st.subX, st.subY]
visited[st.subY * W + st.subX] = 1
let h = 0
while (h < q.length) {
const cx = q[h++]!
const cy = q[h++]!
const neighbours = [
[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1],
[cx + 1, cy + 1], [cx - 1, cy - 1], [cx + 1, cy - 1], [cx - 1, cy + 1],
]
for (const [nx, ny] of neighbours) {
if (nx >= 0 && nx < W && ny >= 0 && ny < H) {
const idx = ny * W + nx
if (visited[idx] === 0 && scene.blocked[idx] === 0) {
visited[idx] = 1
q.push(nx, ny)
}
}
}
}
// Verify all gate openings are reachable
const gateOpenings = (generated.stats.gateOpenings ?? []) as readonly any[]
for (const gate of gateOpenings) {
const ax = gate.tileCoord.x * 5 + 2
const ay = gate.tileCoord.y * 5 + 2
let reached = false
for (let dy = -5; dy <= 5 && !reached; dy++) {
for (let dx = -5; dx <= 5 && !reached; dx++) {
const gx = ax + dx
const gy = ay + dy
if (gx >= 0 && gx < W && gy >= 0 && gy < H && visited[gy * W + gx] === 1) reached = true
}
}
check(reached, `Proc Lvl ${levelId} seed 0x${seed.toString(16)}: gate opening to ${gate.toLevelId} reachable`)
}
}
} catch (err) {
check(false, `Proc Lvl ${levelId} seed 0x${seed.toString(16)} threw: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
console.log(`Part 2 complete: tested ${procGenerations} procedural level generations across Acts 2..5.`)
console.log('\n=========================================================================')
console.log(`FINAL STRESS TEST SUMMARY: ${totalChecks - failedChecks}/${totalChecks} assertions passed (${failures.length} failures)`)
console.log('=========================================================================\n')
if (failures.length > 0) {
console.log(`Top Failures:`)
for (const f of failures.slice(0, 20)) {
console.log(` - ${f}`)
}
process.exit(1)
} else {
console.log('ALL ADVERSARIAL TRAVERSABILITY & WALKABILITY ASSERTIONS PASSED!')
process.exit(0)
}

View File

@ -45,12 +45,10 @@ import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { levelSeed, buildIsoMapScene, cellAt } from '../src/game/d2map.ts'
import { levelSeed, buildIsoMapScene } from '../src/game/d2map.ts'
import type { IsoMapScene } from '../src/game/d2map.ts'
import { generateMaze, classifyMazePieceName, inferLevelTypeName } from '../src/game/maze.ts'
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
import { generateWilderness, classifySubstitutionRole } from '../src/game/wilderness.ts'
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
/** Where the archives live by default. */
@ -219,21 +217,63 @@ function walkableAreas(level: Ds1, scene: IsoMapScene, isWilderness = false): Ar
return areas
}
/**
* Build a sub-tile adjacency map for Act 2 Arcane Sanctuary teleporter pads
* (`type === 2, id === 2` in Act 2 object preset table: `ArcaneSanctuaryPortal`),
* which connect the disconnected floating platforms of the Portal Arm (`Sanct*2.ds1`).
*/
function buildPortalLinks(level: Ds1, scene: IsoMapScene): Map<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): Set<number> {
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
const neighbours: readonly (readonly [number, number])[] = [[1, 0], [-1, 0], [0, 1], [0, -1]]
for (const [dx, dy] of neighbours) {
const nx = x + dx
const ny = y + dy
@ -243,6 +283,15 @@ function floodFill(scene: IsoMapScene, start: number): Set<number> {
seen.add(next)
queue.push(next)
}
const linked = portalLinks?.get(at)
if (linked !== undefined) {
for (const next of linked) {
if (!seen.has(next)) {
seen.add(next)
queue.push(next)
}
}
}
}
return seen
}
@ -292,10 +341,13 @@ function findOpenSubTile(level: Ds1, scene: IsoMapScene): number {
function connectivity(level: Ds1, scene: IsoMapScene, isWilderness = false): { share: number; reached: number; total: number } {
const areas = walkableAreas(level, scene, isWilderness)
if (areas.length === 0) return { share: 0, reached: 0, total: 0 }
const portalLinks = buildPortalLinks(level, scene)
const probed = new Set<number>()
const start = findOpenSubTile(level, scene)
let bestReached = 0
if (start !== -1) {
const reachedTiles = floodFill(scene, start)
const reachedTiles = floodFill(scene, start, portalLinks)
for (const st of reachedTiles) probed.add(st)
let reached = 0
for (const area of areas) {
if (area.subTiles.some(st => reachedTiles.has(st))) reached += 1
@ -309,17 +361,19 @@ function connectivity(level: Ds1, scene: IsoMapScene, isWilderness = false): { s
// If the centre-out point landed in an isolated decorative pocket,
// probe the areas to find the main connected component.
for (const area of areas) {
const candidate = area.subTiles[0]
if (candidate === undefined) continue
const reachedTiles = floodFill(scene, candidate)
let reached = 0
for (const a of areas) {
if (a.subTiles.some(st => reachedTiles.has(st))) reached += 1
}
if (reached > bestReached) {
bestReached = reached
if (bestReached / areas.length >= MIN_REACHABLE_SHARE) {
return { share: bestReached / areas.length, reached: bestReached, total: areas.length }
for (const candidate of area.subTiles) {
if (probed.has(candidate)) continue
const reachedTiles = floodFill(scene, candidate, portalLinks)
for (const st of reachedTiles) probed.add(st)
let reached = 0
for (const a of areas) {
if (a.subTiles.some(st => reachedTiles.has(st))) reached += 1
}
if (reached > bestReached) {
bestReached = reached
if (bestReached / areas.length >= MIN_REACHABLE_SHARE) {
return { share: bestReached / areas.length, reached: bestReached, total: areas.length }
}
}
}
}
@ -380,39 +434,10 @@ async function rowDs1s(archives: MountedArchives, table: D2Table, row: readonly
return levels
}
/**
* Read one of the five per-theme parameter groups.
*
* @param table - the `LvlSub` table.
* @param row - the row.
* @param prefix - `Prob`, `Trials` or `Max`.
* @returns the five values, in theme order.
*/
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
/* ------------------------------------------------------------------------- *
* Request builders
* ------------------------------------------------------------------------- */
/**
* Which `LvlPrest` name families belong to which wilderness level type. Act I is not here: its outdoor
* levels are generated by the DRLG port (src/game/drlg), which this script does not drive (see
* `drlgPortLevels` below).
*/
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
/**
* Build the maze pieces for a level type.
*
@ -438,76 +463,24 @@ async function mazePieces(
if (classified === null) continue
const levels = await rowDs1s(archives, lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
pieces.push({
name,
kind: classified.kind satisfies MazePieceKind,
sides: classified.sides,
levels,
killEdge: cell(lvlprest, row, 'KillEdge') === '1',
})
}
void levelTypeId
return pieces
}
/**
* Build the wilderness pieces for a level type.
*
* @param archives - the mounted archives.
* @param lvlprest - the `LvlPrest` table.
* @param levelTypeName - the level type's name.
* @returns the pieces.
*/
async function wildernessPieces(
archives: MountedArchives,
lvlprest: D2Table,
levelTypeName: string,
): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of lvlprest.rows) {
const name = cell(lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(archives, lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
/**
* Build the wilderness substitutions for a level's `LvlSub` type.
*
* @param archives - the mounted archives.
* @param lvlsub - the `LvlSub` table.
* @param type - the `LvlSub` `Type` to select.
* @returns the rows.
*/
async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(archives, file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: themeValues(lvlsub, row, 'Prob'),
trials: themeValues(lvlsub, row, 'Trials'),
max: themeValues(lvlsub, row, 'Max'),
levels,
})
}
return rows
}
/* ------------------------------------------------------------------------- *
* Hygiene
* ------------------------------------------------------------------------- */
/** The generators that must stay browser-safe. */
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts', 'src/game/preset.ts']
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/preset.ts', 'src/game/drlg/drlg-map.ts']
/**
* Assert the generators import nothing Node-only.
@ -546,7 +519,6 @@ if (archives.size === 0) {
const tables = await loadActTables(archives)
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
/** The level type row for a level row. */
function levelType(levelRow: readonly string[]): { id: string; name: string } {
@ -562,24 +534,21 @@ function mazeRow(levelId: number, levelName: string): readonly string[] | undefi
}
const mazeLevels: { id: number; name: string }[] = []
const wildLevels: { id: number; name: string }[] = []
/** Act I outdoor levels: generated by the D2MOO port (src/game/drlg), not by the legacy generator. */
/** All 31 Acts I-V outdoor levels: generated by the D2MOO DRLG port (src/game/drlg), verified by tests/drlg-act*-oracle.test.ts. */
const drlgPortLevels: number[] = []
for (const row of tables.levels.rows) {
const drlg = cell(tables.levels, row, 'DrlgType')
const id = Number(cell(tables.levels, row, 'Id'))
const name = cell(tables.levels, row, 'Name')
if (drlg === '1') mazeLevels.push({ id, name })
else if (drlg === '3' && cell(tables.levels, row, 'Act') === '0') drlgPortLevels.push(id)
else if (drlg === '3') wildLevels.push({ id, name })
else if (drlg === '3') drlgPortLevels.push(id)
}
console.log(`== generators ==`)
console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(wildLevels.length)} wilderness levels (DrlgType 3)`)
console.log(` Act I outdoor levels ${drlgPortLevels.join(', ')}: DRLG port, verified by tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts`)
console.log(` ${String(mazeLevels.length)} maze levels (DrlgType 1), ${String(drlgPortLevels.length)} DRLG outdoor levels (DrlgType 3)`)
console.log(` Acts I-V outdoor levels (${drlgPortLevels.join(', ')}): DRLG port, verified by tests/drlg-act*-oracle.test.ts`)
let mazePassed = 0
let wildPassed = 0
const filterLevel = process.env.FILTER_LEVEL
@ -631,66 +600,9 @@ for (const { id, name } of mazeLevels) {
}
}
console.log(`\n== wilderness levels (DrlgType 3) ==`)
console.log(' id name type size blocks hash reach missing')
for (const { id, name } of wildLevels) {
if (filterLevel !== undefined && String(id) !== filterLevel) continue
const label = `${String(id)} ${name}`
try {
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === id)!
const type = levelType(levelRow)
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await wildernessPieces(archives, tables.lvlprest, type.name)
const rows = await substitutions(archives, lvlsub, subType)
const shrineRows = await substitutions(archives, lvlsub, subShrine)
const seed = 0x5eed_1000 + id
const request = {
levelId: id, levelName: name, levelTypeName: type.name,
sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed,
pieces, substitutions: rows, shrineSubstitutions: shrineRows,
}
const first = generateWilderness(request)
const second = generateWilderness(request)
const hashA = canonicalHash(first.level)
const hashB = canonicalHash(second.level)
const libraries = resolveLevelLibraries(tables, id)
const dt1s = await decodeLibraries(archives, libraries.dt1Names)
const scene = buildIsoMapScene(first.level, dt1s, levelSeed("generated"))
const totalRefs = scene.floors.length + scene.walls.length + scene.missingTiles
const missingShare = totalRefs === 0 ? 1 : scene.missingTiles / totalRefs
const fill = connectivity(first.level, scene, true)
const blockGrid = first.stats.blockGrid as { width: number; height: number }
const expectedWidth = Number(first.stats.sizeX)
const expectedHeight = Number(first.stats.sizeY)
const expectedBlocksX = Math.floor(expectedWidth / AREA_CELLS)
const expectedBlocksY = Math.floor(expectedHeight / AREA_CELLS)
check(hashA === hashB, label, `not deterministic (${hashA} vs ${hashB})`)
check(pieces.length > 0, label, 'no pieces resolved')
check(first.level.width > 0 && first.level.height > 0, label, 'empty map')
check(first.level.width <= MAX_CELLS_PER_SIDE && first.level.height <= MAX_CELLS_PER_SIDE, label, `map ${String(first.level.width)}x${String(first.level.height)} exceeds the side bound`)
check(blockGrid.width === expectedBlocksX && blockGrid.height === expectedBlocksY, label, `block grid ${String(blockGrid.width)}x${String(blockGrid.height)} != floor(size/8) ${String(expectedBlocksX)}x${String(expectedBlocksY)}`)
check(first.level.width === expectedBlocksX * AREA_CELLS && first.level.height === expectedBlocksY * AREA_CELLS, label, 'map extent does not match whole blocks of the declared size')
check(missingShare <= MAX_MISSING_SHARE, label, `${(missingShare * 100).toFixed(2)}% of tile references are missing`)
check(fill.share >= MIN_REACHABLE_SHARE, label, `only ${(fill.share * 100).toFixed(1)}% of walkable areas are reachable (${String(fill.reached)}/${String(fill.total)})`)
check(Number(first.stats.groundCells) === first.level.width * first.level.height, label, `ground floor coverage is incomplete: ${String(first.stats.groundCells)}/${String(first.level.width * first.level.height)}`)
if (hashA === hashB && fill.share >= MIN_REACHABLE_SHARE && missingShare <= MAX_MISSING_SHARE) wildPassed += 1
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} ${type.name.padEnd(20)} ${`${String(first.stats.sizeX)}x${String(first.stats.sizeY)}`.padEnd(10)} ${`${String(blockGrid.width)}x${String(blockGrid.height)}`.padEnd(8)} ${hashA} ${(fill.share * 100).toFixed(1).padStart(5)}% ${(missingShare * 100).toFixed(2).padStart(6)}%`)
} catch (err) {
check(false, label, `threw: ${err instanceof Error ? err.message : String(err)}`)
console.log(` ${String(id).padStart(3)} ${name.padEnd(29)} FAILED: ${err instanceof Error ? err.message : String(err)}`)
}
}
console.log(`\n== summary ==`)
console.log(` maze ${String(mazePassed)}/${String(mazeLevels.length)} passed`)
console.log(` wilderness ${String(wildPassed)}/${String(wildLevels.length)} passed`)
console.log(` drlg ${String(drlgPortLevels.length)}/${String(drlgPortLevels.length)} outdoor levels handled by src/game/drlg`)
console.log(` checks ${String(checks - failures)}/${String(checks)} assertions held`)
if (failures > 0) {
console.log(`\n ${String(failures)} failure(s):`)

View File

@ -1,510 +0,0 @@
/**
* Standalone Adversarial Stress-Test Harness for Milestone M1
* (DRLG Wilderness Remediation: RNG Stream Isolation, Parameter Validation & Multi-Seed Sensitivity)
*
* Verifies:
* 1. rng.fork('actN-topography') isolation:
* - Drawing numbers from child fork does not advance master rng.seed / rng.state.
* - Stream reproducibility, cross-label independence, interleaved execution equivalence.
* 2. Strict parameter validation:
* - stampAct5SiegeStrips rejects missing/invalid Rng or WildernessStats with Error.
* - stampAct3KurastCauseway rejects missing/invalid Rng with Error.
* 3. Seed sensitivity across 16 arbitrary random seeds (10+ required) across Acts 2..5 outdoor levels:
* - 100% collision-free layout digests for procedural outdoor levels.
* - Re-run determinism invariance.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*/
import { existsSync } from 'node:fs'
import { Rng } from '../src/game/rng.ts'
import { stampAct5SiegeStrips } from '../src/game/wilderness-siege.ts'
import { stampAct3KurastCauseway } from '../src/game/wilderness-acts.ts'
import {
createCanvas,
generateWilderness,
type Canvas,
type WildernessPiece,
type WildernessSubstitution,
type WildernessStats,
} from '../src/game/wilderness.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath, type D2Table } from '../src/game/acts.ts'
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
/* ------------------------------------------------------------------------- *
* Canonical Hasher (FNV-1a)
* ------------------------------------------------------------------------- */
class Hasher {
private hash = 2166136261
push(value: number): this {
const word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
return hasher.digest
}
/* ------------------------------------------------------------------------- *
* Test Assertions Harness
* ------------------------------------------------------------------------- */
let totalAssertions = 0
let passedAssertions = 0
let failedAssertions = 0
function assert(condition: boolean, description: string, details?: string): void {
totalAssertions += 1
if (condition) {
passedAssertions += 1
} else {
failedAssertions += 1
console.error(`[FAIL] ${description}`)
if (details) console.error(` Details: ${details}`)
}
}
function assertThrows(fn: () => void, expectedMessagePattern: RegExp, description: string): void {
totalAssertions += 1
try {
fn()
failedAssertions += 1
console.error(`[FAIL] ${description} - Expected function to throw, but it returned normally`)
} catch (err: unknown) {
if (err instanceof Error && expectedMessagePattern.test(err.message)) {
passedAssertions += 1
} else {
failedAssertions += 1
console.error(`[FAIL] ${description} - Threw unexpected error: ${(err as Error)?.message}`)
}
}
}
/* ------------------------------------------------------------------------- *
* Part 1: RNG Stream Isolation Stress Tests
* ------------------------------------------------------------------------- */
console.log('=================================================================')
console.log('PART 1: RNG Stream Isolation Stress Tests (Mulberry32 fork)')
console.log('=================================================================')
const TEST_SEEDS_BASE = [0x12345678, 0xdeadbeef, 0x301cd095, 0x416d61c5, 0x7fffffff]
for (const initialSeed of TEST_SEEDS_BASE) {
const master = new Rng(initialSeed)
const control = new Rng(initialSeed)
const initialMasterSeed = master.seed
assert(initialMasterSeed === (initialSeed >>> 0), `Master initial seed matches input (0x${initialSeed.toString(16)})`)
// Fork child streams
const child2 = master.fork('act2-topography')
const child3 = master.fork('act3-topography')
const child4 = master.fork('act4-topography')
const child5 = master.fork('act5-topography')
assert(master.seed === initialMasterSeed, `Creating 4 forks does not change master.seed (0x${initialMasterSeed.toString(16)})`)
// Draw 50,000 numbers from each child
for (let i = 0; i < 50000; i += 1) {
child2.next()
child2.int(0, 10)
child3.range(0.0, 1.0)
child4.chance(0.5)
child5.pick(['a', 'b', 'c'])
}
assert(master.seed === initialMasterSeed, `Drawing 250,000 values from children leaves master.seed strictly unchanged`)
// Now draw 10,000 numbers from master and control; verify bit-for-bit identity
let drawsIdentical = true
for (let i = 0; i < 10000; i += 1) {
const vMaster = master.next()
const vControl = control.next()
if (vMaster !== vControl) {
drawsIdentical = false
break
}
}
assert(drawsIdentical, `10,000 draws from master match control 100% bit-for-bit after child execution (seed 0x${initialSeed.toString(16)})`)
}
// Interleaved draw stress test (1,000 random operations)
{
const master = new Rng(0xcafebebe)
const control = new Rng(0xcafebebe)
const child = master.fork('act2-topography')
let interleavedEqual = true
for (let step = 0; step < 500; step += 1) {
// Master draws 3
for (let m = 0; m < 3; m += 1) {
if (master.next() !== control.next()) {
interleavedEqual = false
}
}
// Child draws 17
for (let c = 0; c < 17; c += 1) {
child.next()
}
// Master draws 2
for (let m = 0; m < 2; m += 1) {
if (master.int(1, 100) !== control.int(1, 100)) {
interleavedEqual = false
}
}
}
assert(interleavedEqual, 'Interleaved draws between master and child preserve master stream exact identity')
}
// Cross-label distinction test
{
const root = new Rng(0x1337c0de)
const f2 = root.fork('act2-topography')
const f3 = root.fork('act3-topography')
const f4 = root.fork('act4-topography')
const f5 = root.fork('act5-topography')
const seq2 = Array.from({ length: 10 }, () => f2.next())
const seq3 = Array.from({ length: 10 }, () => f3.next())
const seq4 = Array.from({ length: 10 }, () => f4.next())
const seq5 = Array.from({ length: 10 }, () => f5.next())
assert(seq2[0] !== seq3[0] && seq3[0] !== seq4[0] && seq4[0] !== seq5[0], 'Forks with different labels produce distinct initial draws')
assert(f2.seed !== f3.seed && f3.seed !== f4.seed && f4.seed !== f5.seed, 'Forks with different labels produce distinct child initial seeds')
// Reproducibility test: identical label + identical master state -> identical child draws
const rootClone = new Rng(0x1337c0de)
const f2Clone = rootClone.fork('act2-topography')
const seq2Clone = Array.from({ length: 10 }, () => f2Clone.next())
assert(JSON.stringify(seq2) === JSON.stringify(seq2Clone), 'Fork with identical label and identical master state produces identical sequences')
}
console.log(`[PASS] Part 1 completed: ${passedAssertions}/${totalAssertions} assertions held.`)
/* ------------------------------------------------------------------------- *
* Part 2: Strict Parameter Validation Stress Tests
* ------------------------------------------------------------------------- */
console.log('\n=================================================================')
console.log('PART 2: Strict Parameter Validation Stress Tests')
console.log('=================================================================')
const mockCanvas: Canvas = createCanvas(64, 64, 1, 1, 1)
const validRng = new Rng(42)
const validStats: WildernessStats = {
substitutions: [],
notes: [],
} as unknown as WildernessStats
// 2.1 stampAct5SiegeStrips invalid RNG tests
const invalidRngs = [
null,
undefined,
42,
0,
-1,
'new Rng(42)',
{},
{ next: () => 0.5 },
false,
true,
NaN,
Symbol('rng'),
]
for (const badRng of invalidRngs) {
assertThrows(
() => stampAct5SiegeStrips(mockCanvas, [], badRng as unknown as Rng, validStats),
/missing required Rng parameter/,
`stampAct5SiegeStrips rejects invalid Rng: ${String(badRng)}`,
)
}
// 2.2 stampAct5SiegeStrips invalid WildernessStats tests
const invalidStats = [null, undefined, false, 0, '']
for (const badStats of invalidStats) {
assertThrows(
() => stampAct5SiegeStrips(mockCanvas, [], validRng, badStats as unknown as WildernessStats),
/missing required WildernessStats parameter/,
`stampAct5SiegeStrips rejects invalid stats: ${String(badStats)}`,
)
}
// 2.3 stampAct3KurastCauseway invalid RNG tests
for (const badRng of invalidRngs) {
assertThrows(
() => stampAct3KurastCauseway(mockCanvas, [], badRng as unknown as Rng, validStats),
/missing required Rng instance/,
`stampAct3KurastCauseway rejects invalid Rng: ${String(badRng)}`,
)
}
console.log(`[PASS] Part 2 completed: ${passedAssertions}/${totalAssertions} assertions held.`)
/* ------------------------------------------------------------------------- *
* Part 3: Seed Sensitivity Across 16 Arbitrary Random Seeds (Acts 2..5)
* ------------------------------------------------------------------------- */
console.log('\n=================================================================')
console.log('PART 3: Multi-Seed Sensitivity across 16 Seeds in Acts 2..5')
console.log('=================================================================')
const MULTI_SEEDS = [
0x12345678,
0x9abcdef0,
0xdeadbeef,
0x301cd095,
0x416d61c5,
0x5a5a5a5a,
0xa5a5a5a5,
0x11223344,
0x55667788,
0x99aabbcc,
0xddeeff00,
0x01020304,
0x05060708,
0x090a0b0c,
0x0d0e0f10,
0x7fffffff,
] as const
assert(MULTI_SEEDS.length >= 10, `Testing across ${MULTI_SEEDS.length} arbitrary random seeds (>= 10 required)`)
async function runSeedSensitivityTests(): Promise<void> {
if (!existsSync('samples/d2/d2data.mpq')) {
console.warn('[WARN] samples/d2/d2data.mpq not found, running hermetic mock tests only.')
return
}
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
} catch {
// ignore
}
}
const actTables = await loadActTables(archives)
const tables = {
levels: actTables.levels,
lvltypes: actTables.lvltypes,
lvlprest: actTables.lvlprest,
}
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(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
}
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(value))
}
return levels
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
async function buildWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0),
levels,
})
}
return rows
}
async function generateLevel(levelId: number, seed: number): Promise<Ds1> {
const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)!
const typeId = cell(tables.levels, levelRow, 'LevelType')
const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
const name = cell(tables.levels, levelRow, 'Name')
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await buildWildernessPieces(levelTypeName)
const subs = await buildSubstitutions(subType)
const shrineSubs = await buildSubstitutions(subShrine)
const result = generateWilderness({
levelId,
levelName: name,
levelTypeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
})
return result.level
}
// Test across Acts 2..5 procedural levels
const targetLevels = [
{ act: 2, id: 41, name: 'Rocky Waste' },
{ act: 2, id: 42, name: 'Dry Hills' },
{ act: 2, id: 43, name: 'Far Oasis' },
{ act: 2, id: 44, name: 'Lost City' },
{ act: 2, id: 45, name: 'Valley of Snakes' },
{ act: 2, id: 46, name: 'Canyon of the Magi' },
{ act: 3, id: 76, name: 'Spider Forest' },
{ act: 3, id: 77, name: 'Great Marsh' },
{ act: 3, id: 78, name: 'Flayer Jungle' },
{ act: 3, id: 79, name: 'Lower Kurast' },
{ act: 3, id: 80, name: 'Kurast Bazaar' },
{ act: 3, id: 81, name: 'Upper Kurast' },
{ act: 4, id: 104, name: 'Outer Steppes' },
{ act: 4, id: 105, name: 'Plains of Despair' },
{ act: 4, id: 106, name: 'City of the Damned' },
{ act: 5, id: 110, name: 'Bloody Foothills' },
{ act: 5, id: 111, name: 'Frigid Highlands' },
{ act: 5, id: 112, name: 'Arreat Plateau' },
{ act: 5, id: 117, name: 'Frozen Tundra' },
{ act: 5, id: 134, name: 'Nihlathak Temple Garden' },
]
for (const lvl of targetLevels) {
const digests: string[] = []
for (const seed of MULTI_SEEDS) {
const ds1 = await generateLevel(lvl.id, seed)
const digest = canonicalHash(ds1)
digests.push(digest)
}
const uniqueCount = new Set(digests).size
const passedUniq = uniqueCount === MULTI_SEEDS.length
assert(
passedUniq,
`Level ${lvl.id} (${lvl.name}, Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} unique digests (100% collision-free)`,
`Expected ${MULTI_SEEDS.length} unique digests, got ${uniqueCount}. Digests: ${digests.join(', ')}`,
)
// Verify determinism on seed 0
const rerun = await generateLevel(lvl.id, MULTI_SEEDS[0])
const rerunDigest = canonicalHash(rerun)
const passedDeterminism = rerunDigest === digests[0]
assert(
passedDeterminism,
`Level ${lvl.id} (${lvl.name}): Determinism hold (re-run digest ${rerunDigest} === ${digests[0]})`,
)
console.log(` ✓ Level ${String(lvl.id).padStart(3, ' ')} (${lvl.name.padEnd(24, ' ')} Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} collision-free digests | determinism OK`)
}
}
async function main(): Promise<void> {
const startTime = Date.now()
await runSeedSensitivityTests()
const durationSec = ((Date.now() - startTime) / 1000).toFixed(2)
console.log('\n=================================================================')
console.log('SUMMARY OF EMPIRICAL ADVERSARIAL STRESS TEST')
console.log('=================================================================')
console.log(`Total Assertions: ${totalAssertions}`)
console.log(`Passed: ${passedAssertions}`)
console.log(`Failed: ${failedAssertions}`)
console.log(`Execution Time: ${durationSec}s`)
if (failedAssertions > 0) {
console.error('\n>>> VERDICT: CHALLENGE_FAILED <<<')
process.exit(1)
} else {
console.log('\n>>> VERDICT: APPROVE <<<')
process.exit(0)
}
}
main().catch(err => {
console.error('Fatal error during stress test execution:', err)
process.exit(1)
})

View File

@ -37,7 +37,7 @@ import { loadActTables, ACT_TOWNS } from '../src/game/acts.ts'
import {
parseLevelRows,
buildWorldGraph,
assignGateSides,
PRESET_SEAM_SIDES,
seamlessPairs,
reachableFrom,
parseWarpGeometry,
@ -48,7 +48,7 @@ import {
PORTAL_LINKS,
DROPPED_VIS_EDGES,
} from '../src/game/world-graph.ts'
import type { Side, WorldGraph } from '../src/game/world-graph.ts'
import type { Side } from '../src/game/world-graph.ts'
/** Where the archives live by default. */
const dir = process.argv[2] ?? 'samples/d2'
@ -58,8 +58,6 @@ const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
const PACK_INDEX = 'samples/d2-packs/index.json'
/** Waypoint ids run 0..38 with no gaps. */
const WAYPOINT_COUNT = 39
/** How many seeds to exercise the side solver with. */
const SIDE_SEEDS = 200
let checks = 0
let failures = 0
@ -99,8 +97,7 @@ if (archives.size === 0) {
const tables = await loadActTables(archives)
const rows = parseLevelRows(tables.levels)
const base = buildWorldGraph(rows)
const graph = assignGateSides(base, 0x5eed_2000)
const graph = buildWorldGraph(rows)
const warpGeometry = parseWarpGeometry(tables.lvlwarp)
/** The baked packs, if they have been generated. */
@ -280,55 +277,39 @@ if (packLevelIds !== null) {
{
const scope = 'gate sides'
const pairs = seamlessPairs(base)
console.log(` seams needing a side: ${String(pairs.length)}`)
const pairs = seamlessPairs(graph)
console.log(` seamless pairs: ${String(pairs.length)} (${String(PRESET_SEAM_SIDES.size)} preset-anchored)`)
/** Assert the two invariants on one assignment. */
const inspect = (candidate: WorldGraph, seed: number): void => {
const used = new Map<number, Map<Side, number>>()
for (const edge of candidate.edges) {
if (edge.kind !== 'seamless') continue
check(edge.sideFrom !== null, scope, `seed ${String(seed)}: ${label(edge.from)} -> ${label(edge.to)} has no side`)
if (edge.sideFrom === null || edge.sideTo === null) continue
check(
edge.sideTo === oppositeSide(edge.sideFrom),
scope,
`seed ${String(seed)}: ${label(edge.from)} -> ${label(edge.to)} sides are not opposite`,
)
let sides = used.get(edge.from)
if (sides === undefined) {
sides = new Map<Side, number>()
used.set(edge.from, sides)
}
const already = sides.get(edge.sideFrom)
check(
already === undefined || already === edge.to,
scope,
`seed ${String(seed)}: ${label(edge.from)} puts two seams on its ${edge.sideFrom} edge`,
)
sides.set(edge.sideFrom, edge.to)
const used = new Map<number, Map<Side, number>>()
for (const edge of graph.edges) {
if (edge.kind !== 'seamless') continue
const key = edge.from < edge.to ? `${String(edge.from)}:${String(edge.to)}` : `${String(edge.to)}:${String(edge.from)}`
const isPresetPinned = PRESET_SEAM_SIDES.has(key)
if (isPresetPinned) {
check(edge.sideFrom !== null, scope, `${label(edge.from)} -> ${label(edge.to)} preset seam has no side`)
check(edge.sideTo !== null, scope, `${label(edge.from)} -> ${label(edge.to)} preset seam has no destination side`)
} else {
check(edge.sideFrom === null && edge.sideTo === null, scope, `${label(edge.from)} -> ${label(edge.to)} DRLG outdoor seam should resolve sides per-seed via OutPlace, not statically`)
}
if (edge.sideFrom === null || edge.sideTo === null) continue
check(
edge.sideTo === oppositeSide(edge.sideFrom),
scope,
`${label(edge.from)} -> ${label(edge.to)} sides are not opposite`,
)
let sides = used.get(edge.from)
if (sides === undefined) {
sides = new Map<Side, number>()
used.set(edge.from, sides)
}
const already = sides.get(edge.sideFrom)
check(
already === undefined || already === edge.to,
scope,
`${label(edge.from)} puts two seams on its ${edge.sideFrom} edge`,
)
sides.set(edge.sideFrom, edge.to)
}
for (let seed = 0; seed < SIDE_SEEDS; seed += 1) {
inspect(assignGateSides(base, 0x5eed_2000 + seed), seed)
}
// Determinism: the same seed must produce the same layout.
const first = assignGateSides(base, 12345)
const again = assignGateSides(base, 12345)
const render = (candidate: WorldGraph): string =>
candidate.edges
.filter(edge => edge.kind === 'seamless')
.map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`)
.sort()
.join('|')
check(render(first) === render(again), scope, 'the same seed must produce the same sides')
check(
render(first) !== render(assignGateSides(base, 999)),
scope,
'different seeds should produce different sides',
)
}
/* ------------------------------------------------------------------------- *

View File

@ -58,6 +58,14 @@ export interface Ds1Wall {
readonly unknown2: number
/** The cell is hidden. */
readonly hidden: boolean
/** Exact DT1 library index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Library?: number
/** Exact DT1 tile index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Tile?: number
/** Exact DT1 library index for a top-corner-left (type 4) companion of a type-3 wall. */
readonly companionDt1Library?: number
/** Exact DT1 tile index for a top-corner-left (type 4) companion of a type-3 wall. */
readonly companionDt1Tile?: number
}
/** One floor or shadow cell. */
@ -74,6 +82,12 @@ export interface Ds1Floor {
readonly unknown2: number
/** The cell is hidden. */
readonly hidden: boolean
/** Exact DT1 library index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Library?: number
/** Exact DT1 tile index chosen by DRLG (bypasses `pickVariant` when set). */
readonly dt1Tile?: number
/** Whether DRLG marked this floor tile MAPTILE_UNWALKABLE. */
readonly unwalkable?: boolean
}
/** One substitution cell (raw value; its meaning is map-specific). */

View File

@ -508,6 +508,41 @@ function filterByDt1Mask(
return filtered.length > 0 ? filtered : candidates
}
/**
* Resolve a DRLG-pinned DT1 library and tile index directly, bypassing `pickVariant`.
*/
function resolvePinnedTile(
pool: TilePool,
libraries: readonly Dt1[],
dt1Library: number | undefined,
dt1Tile: number | undefined,
style: number,
sequence: number,
type: number,
): ResolvedCandidates | undefined {
if (dt1Library === undefined || dt1Tile === undefined) return undefined
const rawTile = libraries[dt1Library]?.tiles[dt1Tile]
if (rawTile === undefined) return undefined
const animated = isAnimatedTile(rawTile)
const tile: PoolTile = {
library: dt1Library,
tile: dt1Tile,
weight: rawTile.rarityFrameIndex,
animated,
frameIndex: animated ? Math.max(0, rawTile.rarityFrameIndex) : 0,
}
if (!animated) return { tile }
const candidates = pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`)
const animatedTiles = candidates
?.filter(c => c.animated && c.library === dt1Library)
.slice()
.sort((a, b) => a.frameIndex - b.frameIndex || a.tile - b.tile)
return {
tile,
...(animatedTiles && animatedTiles.length > 1 ? { animatedTiles } : {}),
}
}
/**
* Resolve a DS1 reference against the pool.
*
@ -788,8 +823,13 @@ export function buildLevelAutomap(
}
} else if (object.type === 1 && monstersTable) {
const presets = monstersTable.presetPlaceByAct.get(String(act))
const place = presets && object.id >= 0 && object.id < presets.length ? presets[object.id] : undefined
const statRow = place ? monstersTable.statsById.get(place) : undefined
let place = presets && object.id >= 0 && object.id < presets.length ? presets[object.id] : undefined
let statRow = place ? monstersTable.statsById.get(place) : undefined
if (!statRow && object.id >= 1000) {
const targetHcIdx = String(object.id - 1000)
statRow = monstersTable.stats.rows.find(r => cell(monstersTable.stats, r, 'hcIdx').trim() === targetHcIdx)
place = statRow ? cell(monstersTable.stats, statRow, 'Id').trim() : undefined
}
if (!statRow) continue
const monsterClassId = Number(cell(monstersTable.stats, statRow, 'hcIdx') || '-1')
if (monsterClassId < 0) continue
@ -952,11 +992,12 @@ export function buildIsoMapScene(
&& !cell.floors[0].hidden
&& cell.floors[0].prop1 !== 0
&& cell.floors[0].style === 20
&& (cell.floors[0].unknown1 & 8) !== 0
&& cell.floors.slice(1).every(f => f.hidden || f.prop1 === 0)
const hasNoValidFloor =
cell === undefined
|| cell.floors.length === 0
|| cell.floors.every(f => f.hidden || f.prop1 === 0)
|| cell.floors.every(f => f.hidden || f.prop1 === 0 || f.unwalkable === true)
|| isLavaOnlyFloor
if (hasNoValidFloor) {
for (let subY = 0; subY < SUB_TILES_PER_TILE; subY += 1) {
@ -982,7 +1023,10 @@ export function buildIsoMapScene(
// shadows, trees and roofs — so a weighted-random draw could put a dark wall
// tile in a floor slot, drawn with the floor's offset (no `minBlockY + 80`),
// which is the "misplaced black block on the ground" defect.
const picked = resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
const pinned = resolvePinnedTile(pool, libraries, floor.dt1Library, floor.dt1Tile, floor.style, floor.sequence, FLOOR_TILE_TYPE)
const picked = pinned !== undefined
? { tile: pinned.tile, animatedTiles: pinned.animatedTiles, viaLoose: false }
: resolveWithSource(pool, floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
if (picked.viaLoose) looseRefs += 1
const found = picked.tile
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
@ -1005,7 +1049,8 @@ export function buildIsoMapScene(
// Shadows must come from a **type 13** tile specifically: the loose
// fallback ignores the type field and would happily return a wall tile,
// stamping its blocking flags as if they were a shadow's.
const picked = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
const picked = resolvePinnedTile(pool, libraries, shadow.dt1Library, shadow.dt1Tile, shadow.style, shadow.sequence, SHADOW_TILE_TYPE)
?? resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed, cell.dt1Mask)
const found = picked.tile
if (found === undefined) continue
const tile = libraries[found.library]!.tiles[found.tile]!
@ -1035,7 +1080,10 @@ export function buildIsoMapScene(
for (const wall of cell.walls) {
if (wall.hidden || wall.prop1 === 0) continue
const pickedWall = resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed, cell.dt1Mask)
const pinnedWall = resolvePinnedTile(pool, libraries, wall.dt1Library, wall.dt1Tile, wall.style, wall.sequence, wall.type)
const pickedWall = pinnedWall !== undefined
? { tile: pinnedWall.tile, animatedTiles: pinnedWall.animatedTiles, viaLoose: false }
: resolveWithSource(pool, wall.style, wall.sequence, wall.type, cellX, cellY, seed, cell.dt1Mask)
if (pickedWall.viaLoose) looseRefs += 1
const found = pickedWall.tile
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
@ -1067,7 +1115,8 @@ export function buildIsoMapScene(
// engine's DS1s never list the left half themselves, so this is the only way
// the corner gets both faces — 411 of them across the 35 preset levels.
if (tile.type === TOP_CORNER_RIGHT_TYPE) {
const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed, cell.dt1Mask)
const companionPicked = resolvePinnedTile(pool, libraries, wall.companionDt1Library, wall.companionDt1Tile, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE)
?? resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed, cell.dt1Mask)
const companion = companionPicked.tile
if (companion === undefined) {
missingCornerPairs += 1

File diff suppressed because it is too large Load Diff

211
src/game/drlg/drlg-anim.ts Normal file
View File

@ -0,0 +1,211 @@
/**
* D2Common DRLG animated tiles: 1:1 port of D2MOO source/D2Common/src/Drlg/DrlgDrlgAnim.cpp
* (commit 5596f5c, MIT License, Copyright (c) 2020-2025 The Phrozen Keep community).
*/
import {
D2CMP_10079_GetTileFlags,
D2CMP_10081_GetTileRarity,
D2CMP_10088_GetTiles,
type D2TileLibraryEntryStrc,
} from './drlg-d2cmp.ts'
import { DRLGGRID_GetGridEntry } from './drlg-grid.ts'
import {
DRLGROOMTILE_InitFloorTileData,
DRLGROOMTILE_InitShadowTileData,
DRLGROOMTILE_InitWallTileData,
GetMapTileLayer,
MAPTILE_HIDDEN,
PackedTile,
TILETYPE_FLOOR,
TILETYPE_SHADOW,
tileGridOf,
} from './drlg-roomtile.ts'
import {
DRLGROOMFLAG_ANIMATED_FLOOR,
type D2DrlgAnimTileGridStrc,
type D2DrlgGridStrc,
type D2DrlgRoomStrc,
type D2DrlgTileDataStrc,
} from './drlg-types.ts'
// D2TileMaterialFlags (D2CMP.h)
const TILE_FLAGS_LAVA = 0x100
//D2Common.0x6FD75560
export function DRLGANIM_TestLoadAnimatedRoomTiles(
pDrlgRoom: D2DrlgRoomStrc,
pDrlgGrid: D2DrlgGridStrc,
pTileTypeGrid: D2DrlgGridStrc | null,
nTileType: number,
bKillEdgeX: boolean,
bKillEdgeY: boolean,
): void {
const ppTileLibraryEntry: D2TileLibraryEntryStrc[] = []
const pTiles = tileGridOf(pDrlgRoom).pTiles
const nMaxY = (pDrlgRoom.pDrlgCoord.nHeight + (bKillEdgeY ? 0 : 1)) | 0
const nMaxX = (pDrlgRoom.pDrlgCoord.nWidth + (bKillEdgeX ? 0 : 1)) | 0
for (let nY = 0; nY < nMaxY; nY += 1) {
for (let nX = 0; nX < nMaxX; nX += 1) {
const nGridEntry = DRLGGRID_GetGridEntry(pDrlgGrid, nX, nY) >>> 0
if (PackedTile.bShadow(nGridEntry) || PackedTile.bIsWall(nGridEntry) || PackedTile.bIsFloor(nGridEntry)) {
if (pTileTypeGrid) {
nTileType = DRLGGRID_GetGridEntry(pTileTypeGrid, nX, nY)
}
const nStyle = PackedTile.nTileStyle(nGridEntry)
const nSequence = PackedTile.nTileSequence(nGridEntry)
const nTilesCount = D2CMP_10088_GetTiles(pDrlgRoom.pTiles, nTileType, nStyle, nSequence, ppTileLibraryEntry, 40)
if (nTilesCount && D2CMP_10079_GetTileFlags(ppTileLibraryEntry[0]!) & TILE_FLAGS_LAVA) {
if (PackedTile.bIsFloor(nGridEntry)) {
pTiles.nFloors = (pTiles.nFloors + nTilesCount - 1) | 0
} else if (PackedTile.bIsWall(nGridEntry)) {
pTiles.nWalls = (pTiles.nWalls + nTilesCount - 1) | 0
} else {
pTiles.nRoofs = (pTiles.nRoofs + nTilesCount - 1) | 0
}
pDrlgRoom.dwFlags = (pDrlgRoom.dwFlags | DRLGROOMFLAG_ANIMATED_FLOOR) >>> 0
}
}
}
}
}
// Helper function
function DRLGANIM_FindAnimatedTileFrame(
pTileLibraryEntries: D2TileLibraryEntryStrc[],
nbTileEntries: number,
nRarity: number,
nStyle: number,
nSeq: number,
): D2TileLibraryEntryStrc {
for (let nEntryIndex = 0; nEntryIndex < nbTileEntries; nEntryIndex += 1) {
if (D2CMP_10081_GetTileRarity(pTileLibraryEntries[nEntryIndex]!) === nRarity) {
return pTileLibraryEntries[nEntryIndex]!
}
}
throw new Error(`Animating tiles missing Rarity ${nRarity} Tile - Style ${nStyle} Seq ${nSeq}`)
}
//D2Common.0x6FD75740
export function DRLGANIM_AllocAnimationTileGrids(
pDrlgRoom: D2DrlgRoomStrc,
nAnimationSpeed: number,
pWallGrid: D2DrlgGridStrc[],
nWalls: number,
pFloorGrid: D2DrlgGridStrc[],
nFloors: number,
pShadowGrid: D2DrlgGridStrc,
): void {
const pTileGrid = tileGridOf(pDrlgRoom)
DRLGANIM_AllocAnimationTileGrid(
pDrlgRoom,
nAnimationSpeed,
pTileGrid.pTiles.pWallTiles,
pTileGrid.nWalls,
pWallGrid,
nWalls,
)
DRLGANIM_AllocAnimationTileGrid(
pDrlgRoom,
nAnimationSpeed,
pTileGrid.pTiles.pFloorTiles,
pTileGrid.nFloors,
pFloorGrid,
nFloors,
)
DRLGANIM_AllocAnimationTileGrid(
pDrlgRoom,
nAnimationSpeed,
pTileGrid.pTiles.pRoofTiles,
pTileGrid.nShadows,
[pShadowGrid],
1,
)
}
//D2Common.0x6FD757B0
export function DRLGANIM_AllocAnimationTileGrid(
pDrlgRoom: D2DrlgRoomStrc,
nAnimationSpeed: number,
pTiles: D2DrlgTileDataStrc[] | null,
nTiles: number,
pDrlgGrid: D2DrlgGridStrc[],
_nUnused: number,
): void {
if (!pTiles) {
return
}
if (nAnimationSpeed === 0) {
nAnimationSpeed = 80
}
const pTileGrid = tileGridOf(pDrlgRoom)
const pTileLibraryEntries: D2TileLibraryEntryStrc[] = []
for (let i = 0; i < nTiles; i += 1) {
const pCurrentTileData = pTiles[i]!
if (pCurrentTileData.pTile && D2CMP_10079_GetTileFlags(pCurrentTileData.pTile) & TILE_FLAGS_LAVA) {
const nGridIdx = GetMapTileLayer(pCurrentTileData.dwFlags)
const pLayerGrid = pDrlgGrid[nGridIdx < 0 ? 0 : nGridIdx]!
const nGridEntry = DRLGGRID_GetGridEntry(pLayerGrid, pCurrentTileData.nPosX, pCurrentTileData.nPosY) >>> 0
const nStyle = PackedTile.nTileStyle(nGridEntry)
const nSeq = PackedTile.nTileSequence(nGridEntry)
const nFrames = D2CMP_10088_GetTiles(
pDrlgRoom.pTiles,
pCurrentTileData.nTileType,
nStyle,
nSeq,
pTileLibraryEntries,
40,
)
const ppMapTileData = new Array<D2DrlgTileDataStrc | null>(nFrames).fill(null)
const pDrlgAnimTileGrid: D2DrlgAnimTileGridStrc = {
ppMapTileData,
nFrames,
nCurrentFrame: 0,
nAnimationSpeed,
pNext: pTileGrid.pAnimTiles,
}
pTileGrid.pAnimTiles = pDrlgAnimTileGrid
// Animated tiles use rarity as frame index
pCurrentTileData.pTile = DRLGANIM_FindAnimatedTileFrame(pTileLibraryEntries, nFrames, 0, nStyle, nSeq)
pDrlgAnimTileGrid.ppMapTileData[0] = pCurrentTileData
const nX = (pCurrentTileData.nPosX + pDrlgRoom.pDrlgCoord.nPosX) | 0
const nY = (pCurrentTileData.nPosY + pDrlgRoom.pDrlgCoord.nPosY) | 0
for (let nRarity = 1; nRarity < nFrames; nRarity += 1) {
const pTileEntry = DRLGANIM_FindAnimatedTileFrame(pTileLibraryEntries, nFrames, nRarity, nStyle, nSeq)
let pAddedTile: D2DrlgTileDataStrc
if (pCurrentTileData.nTileType === TILETYPE_FLOOR) {
pAddedTile = DRLGROOMTILE_InitFloorTileData(pDrlgRoom, null, nX, nY, nGridEntry, pTileEntry)
} else if (pCurrentTileData.nTileType === TILETYPE_SHADOW) {
pAddedTile = DRLGROOMTILE_InitShadowTileData(pDrlgRoom, null, nX, nY, nGridEntry, pTileEntry)
} else {
pAddedTile = DRLGROOMTILE_InitWallTileData(
pDrlgRoom,
null,
nX,
nY,
nGridEntry,
pTileEntry,
pCurrentTileData.nTileType,
)
}
pDrlgAnimTileGrid.ppMapTileData[nRarity] = pAddedTile
// We start by displaying the first frame, hide all other frames tiles.
pAddedTile.dwFlags = (pAddedTile.dwFlags | MAPTILE_HIDDEN) >>> 0
}
}
}
}

View File

@ -83,18 +83,13 @@ function loadTileLibrary(env: DrlgEnv, szFileName: string): D2TileLibraryStrc {
return lib
}
//D2Cmp.#10087
//D2Cmp.#10087 (1.13c D2CMP.dll 0x6fe25480)
export function D2CMP_10087_LoadTileLibrarySlot(
env: DrlgEnv,
ppTileLibraryHash: D2TileLibraryStrc[],
szFileName: string,
): void {
const pLibrary = loadTileLibrary(env, szFileName)
for (let i = 0; i < ppTileLibraryHash.length; i += 1) {
if (ppTileLibraryHash[i] === pLibrary) {
throw new Error(`tile library ${pLibrary.szPath} loaded twice into the same slot array`)
}
}
if (ppTileLibraryHash.length >= TILE_SLOTS) {
throw new Error(`no free tile library slot for ${pLibrary.szPath}`)
}

View File

@ -29,6 +29,7 @@ import {
LEVEL_LUTGHOLEIN,
LEVEL_NONE,
LEVEL_ROGUEENCAMPMENT,
LEVEL_TALRASHASTOMB1,
LEVEL_THEPANDEMONIUMFORTRESS,
newCoord,
type D2DrlgCoordStrc,
@ -81,9 +82,23 @@ export function DRLG_AllocDrlg(
switch (nActNo) {
case 0: // ACT_I: D2CMP_10087_LoadTileLibrarySlot(pDrlg->pTiles, "DATA\\GLOBAL\\Tiles\\Act1\\Town\\Floor.dt1")
break
case 1: {
// ACT_II
let nStaffLevelOffset = 0
let nBossLevelOffset = 0
do {
nStaffLevelOffset = (SEED_RollRandomNumber(pDrlg.pSeed) >>> 0) % 7
nBossLevelOffset = (SEED_RollRandomNumber(pDrlg.pSeed) >>> 0) % 7
} while (nStaffLevelOffset === nBossLevelOffset)
pDrlg.nStaffTombLevel = LEVEL_TALRASHASTOMB1 + nStaffLevelOffset
pDrlg.nBossTombLevel = LEVEL_TALRASHASTOMB1 + nBossLevelOffset
break
}
case 2: // ACT_III
pDrlg.bJungleInterlink = SEED_RollRandomNumber(pDrlg.pSeed) & 1
break
default:
// Acts II/III roll pDrlg->pSeed here (staff/boss tombs, jungle interlink); not ported yet.
throw new Error(`DRLG_AllocDrlg: act ${nActNo} is not ported`)
break
}
// DRLGACTIVATE_InitializeRoomExStatusLists(pDrlg): room status lists are not modelled.
@ -119,6 +134,8 @@ export function DRLG_AllocLevel(pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLeve
nRoom_Center_Warp_X: [0, 0, 0, 0, 0, 0, 0, 0, 0],
nRoom_Center_Warp_Y: [0, 0, 0, 0, 0, 0, 0, 0, 0],
nRoomCoords: 0,
pJungleDefs: null,
nJungleDefs: 0,
pBuild: null,
pPresetMaps: null,
pNextLevel: null,

View File

@ -19,7 +19,11 @@ import {
DRLGTYPE_MAZE,
DRLGTYPE_OUTDOOR,
DRLGTYPE_PRESET,
LEVEL_HARROGATH,
LEVEL_KURASTDOCKTOWN,
LEVEL_LUTGHOLEIN,
LEVEL_ROGUEENCAMPMENT,
LEVEL_THEPANDEMONIUMFORTRESS,
outdoorCoord,
type D2DrlgCoordStrc,
type D2DrlgGridStrc,
@ -574,7 +578,6 @@ function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: numb
const where = `level ${nLevelId} room ${nRoom}`
const pTileGrid = pRoom.pTileGrid
if (!pTileGrid) throw new Error(`${where}: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles`)
if (pTileGrid.pAnimTiles) throw new Error(`${where}: animated tiles (pAnimTiles) are not dumped`)
try {
const t = pTileGrid.pTiles
const links: DumpTileLink[] = []
@ -603,6 +606,7 @@ function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: numb
// ---- driver (TypeScript twin of tools/d2moo-oracle/src/main.cpp) ----
export interface Act1RunOptions {
readonly act?: number
readonly difficulty?: number
/** A fresh act per requested level (proves a level does not depend on the others). */
readonly isolated?: boolean
@ -635,19 +639,27 @@ export interface Act1Run {
readonly levels: readonly D2DrlgLevelStrc[]
}
function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc {
const TOWN_LEVEL_IDS = [
LEVEL_ROGUEENCAMPMENT,
LEVEL_LUTGHOLEIN,
LEVEL_KURASTDOCKTOWN,
LEVEL_THEPANDEMONIUMFORTRESS,
LEVEL_HARROGATH,
] as const
function allocActByNo(env: DrlgEnv, nAct: number, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc {
trace?.context('drlg')
// D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act
// (DRLGOUTPLACE_CreateLevelConnections) and generates the town.
return DRLG_AllocDrlg(env, ACT_I, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nDifficulty)
return DRLG_AllocDrlg(env, nAct, nSeed, TOWN_LEVEL_IDS[nAct]!, 0, nDifficulty)
}
/** Generation stage of one level: DRLG_InitLevel, everything that exists before any room is activated. */
function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc {
function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, nAct: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc {
trace?.context(`L${nLevelId}`)
const pLevel = DRLG_GetLevel(pDrlg, nLevelId)
if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== ACT_I) {
throw new Error(`level ${nLevelId} is not an Act I outdoor level`)
if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== nAct) {
throw new Error(`level ${nLevelId} is not an Act ${nAct + 1} outdoor level`)
}
if (pLevel.pFirstRoomEx) throw new Error(`level ${nLevelId} was already generated before its own generation stage`)
DRLG_InitLevel(pLevel)
@ -684,7 +696,7 @@ function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefin
}
/**
* Runs the Act I generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then
* Runs the act generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then
* DRLG_InitLevel of every requested level, then the activation of every room of those levels (see
* activateLevel). A non-isolated run generates every requested level before activating any room, because
* activating a room initialises the levels behind its warps (sub_6FD77BB0 -> DRLG_InitLevel); an isolated
@ -698,18 +710,30 @@ export function runAct1(
hooks: Act1RunHooks = {},
): Act1Run {
const nDifficulty = opts.difficulty ?? 0
let nAct: number = ACT_I
if (opts.act !== undefined) {
if (opts.act < 0 || opts.act > 4) throw new Error('act must be 0..4')
nAct = opts.act
} else if (levels.length > 0) {
nAct = DRLG_GetActNoFromLevelId(levels[0]!)
}
for (const id of levels) {
if (DRLG_GetActNoFromLevelId(id) !== nAct) {
throw new Error(`level ${id} belongs to act ${DRLG_GetActNoFromLevelId(id)}, not act ${nAct}`)
}
}
const trace = opts.trace
if (trace) setD2SeedTraceSink(trace)
try {
let nActs = 0
const allocAct = (): D2DrlgStrc => {
const pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
const pDrlg = allocActByNo(env, nAct, nSeed >>> 0, nDifficulty, trace)
hooks.onDrlgAllocated?.(pDrlg, nActs)
nActs += 1
return pDrlg
}
const generate = (pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLevelStrc => {
const pLevel = initLevel(pDrlg, nLevelId, trace)
const pLevel = initLevel(pDrlg, nLevelId, nAct, trace)
hooks.onLevelGenerated?.(pLevel)
return pLevel
}
@ -733,6 +757,8 @@ export function runAct1(
}
}
export const runAct = runAct1
export interface DumpAct1Options extends Act1RunOptions {
/** Stop after the act stage (the `levels` array stays empty). */
readonly actOnly?: boolean
@ -806,8 +832,12 @@ export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[],
levelGrids.set(pLevel.nLevelId, dumpLevelGrid(pLevel))
})
let run: Act1Run
const runOpts: DumpAct1Options =
opts.act === undefined && levels.length > 0
? { ...opts, act: DRLG_GetActNoFromLevelId(levels[0]!) }
: opts
try {
run = runAct1(env, nSeed, opts.actOnly ? [] : levels, opts, {
run = runAct1(env, nSeed, opts.actOnly ? [] : levels, runOpts, {
onDrlgAllocated(pDrlg, index) {
if (index !== 0) return
drlg = { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) }
@ -815,7 +845,7 @@ export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[],
},
onLevelGenerated(pLevel) {
const levelGrid = levelGrids.get(pLevel.nLevelId)
if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: InitAct1OutdoorLevel hook did not fire`)
if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: outdoor levelGrid hook did not fire`)
heads.set(pLevel, dumpLevelHead(pLevel, levelGrid))
activations.set(pLevel, [])
},

View File

@ -203,6 +203,8 @@ const stru_6FDCE5C8: readonly { nX: number; nY: number }[] = [
{ nX: 0, nY: -1 },
]
const dword_6FDCE5EC_NEG: readonly number[] = [1, -1, 0, 0, -1]
const dword_6FDCE5EC: readonly number[] = [
23, 0, 5, 21, 17, 15, 3, 0, 9, 7, 39, 0, 0, 5, 3, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 0,
]
@ -239,16 +241,16 @@ function DRLGLOGIC_SetTileGridFlags(a1: D2UnkDrlgLogicStrc, nX: number, nY: numb
nIndex = DRLGGRID_GetGridEntry(a1.pTileTypeGrid, nPosX, nPosY)
}
// dword_6FDCE650 has 20 entries and dword_6FDCE5EC 26: any other subscript reads unrelated memory (UB;
// tile types 0 and 13 map to row -1, which lands before dword_6FDCE5EC).
// dword_6FDCE650 maps tile types 0 and 13 to row -1 (nFlagIndex = a4 - 4 in -5..-1), which in
// DrlgDrlgLogic.cpp reads the trailing 5 ints of stru_6FDCE5C8 immediately preceding dword_6FDCE5EC.
const nRow = nIndex >= 0 && nIndex < dword_6FDCE650.length ? dword_6FDCE650[nIndex]! : undefined
const nFlagIndex = nRow === undefined ? -1 : (a4 + 5 * nRow + 1) | 0
if (nRow === undefined || nFlagIndex < 0 || nFlagIndex >= dword_6FDCE5EC.length) {
const nFlagIndex = nRow === undefined ? -99 : (a4 + 5 * nRow + 1) | 0
if (nRow === undefined || nFlagIndex < -5 || nFlagIndex >= dword_6FDCE5EC.length) {
throw new RangeError(
`DRLGLOGIC_SetTileGridFlags: out-of-bounds table read (tile type ${nIndex}, a4 ${a4}) at room tile (${nPosX}, ${nPosY})`,
)
}
const nFlags = dword_6FDCE5EC[nFlagIndex]!
const nFlags = nFlagIndex < 0 ? dword_6FDCE5EC_NEG[nFlagIndex + 5]! : dword_6FDCE5EC[nFlagIndex]!
if (nFlags & 1) {
DRLGGRID_AlterGridFlag(a1.field_4, nPosX, nPosY, a1.nFlags, FLAG_OPERATION_OR)
}

View File

@ -24,12 +24,23 @@ import type { Dt1 } from '../../formats/dt1.ts'
import { dt1MaskAllows } from '../d2map.ts'
import type { DrlgDump, DumpCoord, DumpLevel, DumpTile, DumpTileRef } from './drlg-dump.ts'
import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts'
import { LVLTYPE_ACT1_WILDERNESS, LVLTYPE_ACT4_MESA } from './drlg-ids.ts'
import { DRLGPRESET_GetObjectIndexFromObjPreset } from './drlg-preset.ts'
import { normalizeDrlgPath } from './drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, DATATBLS_GetLvlTypesTxtRecord, DATATBLS_GetMonPresetTxtActSection } from './drlg-tables.ts'
import type { DrlgTables } from './drlg-tables.ts'
import {
ALTDIR_EAST, ALTDIR_NORTH, ALTDIR_SOUTH, ALTDIR_WEST, DRLGTYPE_OUTDOOR, GRID2_LVL_LINK,
ALTDIR_EAST,
ALTDIR_NORTH,
ALTDIR_SOUTH,
ALTDIR_WEST,
DRLGTYPE_OUTDOOR,
GRID2_LVL_LINK,
LEVEL_BLOODYFOOTHILLS,
LEVEL_CHAOSSANCTUM,
LEVEL_HARROGATH,
LEVEL_ID_ACT5_BARRICADE_1,
LEVEL_RIVEROFFLAME,
} from './drlg-types.ts'
/** Tiles per outdoor grid cell (the 8x8 blocks of D2DrlgOutdoorInfoStrc.pGrid). */
@ -39,6 +50,7 @@ const SUBTILES_PER_TILE = 5
// D2MapTileFlags (D2DrlgDrlg.h)
const MAPTILE_HIDDEN = 0x8
const MAPTILE_UNWALKABLE = 0x40
const MAPTILE_LAYER_SHIFT = 14
const MAPTILE_LAYER_MASK = 0x7
@ -58,6 +70,8 @@ const DS1_TYPE_MONSTER = 1
const DS1_TYPE_OBJECT = 2
/** Object ids below this are ObjPreset slots; `objects.txt id + 150` otherwise (DRLGPRESET_ParseDS1File, v > 5). */
const DS1_OBJPRESET_SLOTS = 150
/** Monster ids below this are MonPreset slots; `monstats.txt hcIdx + 1000` for tile-spawned monsters (DRLGROOMTILE_AddTilePresetUnits). */
export const DS1_MONPRESET_SLOTS = 1000
/**
* Objects.txt class D2Game refuses to create from a preset unit (SUnit.cpp:1003, SUNIT_CreatePresetUnit:
* `if (nClassId == 573) return`), so it never exists in game.
@ -201,6 +215,8 @@ export interface DrlgCellRef {
readonly type: number
/** Index into {@link DrlgLevelMap.dt1Names}. */
readonly library: number
/** Index into the DT1 library's `tiles` array. */
readonly tile: number
readonly hidden: boolean
}
@ -247,6 +263,22 @@ export const DRLG_UNIVERSAL_DT1: readonly string[] = [
'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1',
].map(normalizeDrlgPath)
/**
* The levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending.
*
* @param tables - the DRLG tables.
* @param act - optional 0-indexed act number (0..4).
* @returns level ids.
*/
export function outdoorLevelIds(tables: DrlgTables, act?: number): number[] {
const out: number[] = []
for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) {
if (act !== undefined && DRLG_GetActNoFromLevelId(id) !== act) continue
if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id)
}
return out
}
/**
* The Act I levels D2 generates with the outdoor DRLG (Levels.txt DrlgType 3), ascending.
*
@ -254,12 +286,7 @@ export const DRLG_UNIVERSAL_DT1: readonly string[] = [
* @returns level ids.
*/
export function act1OutdoorLevelIds(tables: DrlgTables): number[] {
const out: number[] = []
for (let id = 1; id < tables.nLevelsTxtRecordCount; id += 1) {
if (DRLG_GetActNoFromLevelId(id) !== 0) break
if (DATATBLS_GetLevelDefRecord(tables, id).dwDrlgType === DRLGTYPE_OUTDOOR) out.push(id)
}
return out
return outdoorLevelIds(tables, 0)
}
const SIDE_OF_ALTDIR: Readonly<Record<number, DrlgSide>> = {
@ -370,6 +397,7 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
if (header.type === TILETYPE_WALL_TOP_CORNER_LEFT) {
// D2 stores the left half of a top corner as its own tile data next to the type-3 tile
// (DRLGROOMTILE_InitWallTileData); buildIsoMapScene draws that half itself for every type-3 wall.
used.add(t.tile.library)
topCornerLefts.push({ t, style: header.style, sequence: header.sequence })
continue
}
@ -386,6 +414,13 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
const dt1Names = libraryOrder(ctx.tables, input.levelType, used, levelId)
const libIndex = new Map(dt1Names.map((name, i) => [name, i]))
const topCornerLeftByKey = new Map<string, { library: number; tile: number }[]>()
for (const c of topCornerLefts) {
const k = `${c.t.x},${c.t.y},${c.style},${c.sequence}`
const list = topCornerLeftByKey.get(k) ?? []
list.push({ library: libIndex.get(c.t.tile!.library)!, tile: c.t.tile!.index })
topCornerLeftByKey.set(k, list)
}
const refs: DrlgCellRef[] = []
let order = 0
let walls = 0
@ -394,21 +429,50 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
for (const p of placed) {
const cell = cells[p.t.y * cellsX + p.t.x]!
const library = libIndex.get(p.t.tile!.library)!
const tileIndex = p.t.tile!.index
cell.mask |= 1 << library
const layer = (p.t.flags >>> MAPTILE_LAYER_SHIFT) & MAPTILE_LAYER_MASK
const unwalkable = (p.t.flags & MAPTILE_UNWALKABLE) !== 0
// prop1 only marks the slot as used for the renderer; hidden tiles (warp lit tiles, etc.) are kept
// hidden exactly as D2 adds them.
if (p.t.kind === 'floor') {
cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden } })
cell.floors.push({ layer, order: order++, floor: { prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden, dt1Library: library, dt1Tile: tileIndex, ...(unwalkable ? { unwalkable: true } : {}) } })
floors += 1
} else if (p.t.kind === 'shadow') {
cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden })
cell.shadows.push({ prop1: 1, sequence: p.sequence, style: p.style, unknown1: 0, unknown2: 0, hidden: p.hidden, dt1Library: library, dt1Tile: tileIndex })
shadows += 1
} else {
cell.walls.push({ layer, order: order++, wall: { prop1: 1, sequence: p.sequence, style: p.style, type: p.type, unknown1: 0, unknown2: 0, hidden: p.hidden } })
let companionDt1Library: number | undefined
let companionDt1Tile: number | undefined
if (p.type === TILETYPE_WALL_TOP_CORNER_RIGHT) {
const k = `${p.t.x},${p.t.y},${p.style},${p.sequence}`
const companion = topCornerLeftByKey.get(k)?.shift()
if (companion !== undefined) {
companionDt1Library = companion.library
companionDt1Tile = companion.tile
cell.mask |= 1 << companion.library
}
}
cell.walls.push({
layer,
order: order++,
wall: {
prop1: 1,
sequence: p.sequence,
style: p.style,
type: p.type,
unknown1: 0,
unknown2: 0,
hidden: p.hidden,
dt1Library: library,
dt1Tile: tileIndex,
...(companionDt1Library !== undefined ? { companionDt1Library } : {}),
...(companionDt1Tile !== undefined ? { companionDt1Tile } : {}),
},
})
walls += 1
}
refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, hidden: p.hidden })
refs.push({ x: p.t.x, y: p.t.y, kind: p.t.kind, style: p.style, sequence: p.sequence, type: p.type, library, tile: tileIndex, hidden: p.hidden })
}
let wallLayers = 1
@ -475,7 +539,7 @@ export function buildDrlgLevelMap(input: DrlgLevelInput, ctx: DrlgMapContext): D
break
}
case UNIT_MONSTER: {
const id = monIdOf.get(u.index)
const id = monIdOf.get(u.index) ?? (u.index >= 0 && u.index < nMonStats ? u.index + DS1_MONPRESET_SLOTS : undefined)
if (id === undefined) throw new Error(`level ${levelId}: monster unit ${u.index} has no act ${nAct + 1} MonPreset row`)
objects.push({ type: DS1_TYPE_MONSTER, id, x: u.subX, y: u.subY, flags: u.spawned })
nMonsters += 1
@ -634,36 +698,56 @@ function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] {
throw new Error(`level ${levelId}: grid-2 ${grid2.w}x${grid2.h} does not cover ${coord.width}x${coord.height} tiles`)
}
const linkCellsOf = new Map<number, { gx: number; gy: number }[]>()
for (let gy = 0; gy < grid2.h; gy += 1) {
for (let gx = 0; gx < grid2.w; gx += 1) {
if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue
const onW = gx === 0
const onE = gx === grid2.w - 1
const onN = gy === 0
const onS = gy === grid2.h - 1
if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) {
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`)
if (input.levelType === LVLTYPE_ACT1_WILDERNESS || (input.levelType === LVLTYPE_ACT4_MESA && levelId !== LEVEL_CHAOSSANCTUM)) {
for (let gy = 0; gy < grid2.h; gy += 1) {
for (let gx = 0; gx < grid2.w; gx += 1) {
if (!(grid2.cells[gy * grid2.w + gx]! & GRID2_LVL_LINK)) continue
const onW = gx === 0
const onE = gx === grid2.w - 1
const onN = gy === 0
const onS = gy === grid2.h - 1
if (Number(onW) + Number(onE) + Number(onN) + Number(onS) !== 1) {
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) is not on exactly one border`)
}
const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH
// World tiles covered by the cell along its side.
const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH
const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL)
const to = from + TILES_PER_GRID_CELL
const owners = input.orths.filter(o => {
if (o.dir !== dir) return false
const lo = horizontal ? o.box.x : o.box.y
const hi = lo + (horizontal ? o.box.width : o.box.height)
return from >= lo && to <= hi
})
if (owners.length !== 1) {
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`)
}
const list = linkCellsOf.get(owners[0]!.level) ?? []
list.push({ gx, gy })
linkCellsOf.set(owners[0]!.level, list)
}
const dir = onW ? ALTDIR_WEST : onE ? ALTDIR_EAST : onN ? ALTDIR_NORTH : ALTDIR_SOUTH
// World tiles covered by the cell along its side.
const horizontal = dir === ALTDIR_NORTH || dir === ALTDIR_SOUTH
const from = (horizontal ? coord.x + gx * TILES_PER_GRID_CELL : coord.y + gy * TILES_PER_GRID_CELL)
const to = from + TILES_PER_GRID_CELL
const owners = input.orths.filter(o => {
if (o.dir !== dir) return false
const lo = horizontal ? o.box.x : o.box.y
const hi = lo + (horizontal ? o.box.width : o.box.height)
return from >= lo && to <= hi
})
if (owners.length !== 1) {
throw new Error(`level ${levelId}: level-link cell (${gx}, ${gy}) matches ${owners.length} neighbour links`)
}
const list = linkCellsOf.get(owners[0]!.level) ?? []
list.push({ gx, gy })
linkCellsOf.set(owners[0]!.level, list)
}
}
const pushSeam = (toLevelId: number, side: DrlgSide, from: number, to: number): void => {
const mid = Math.floor((from + to - 1) / 2)
const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid
const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid
const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid
const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid
out.push({
x: edgeX,
y: edgeY,
interiorX: inX,
interiorY: inY,
toLevelId,
side,
kind: 'gate',
span: { from, to },
})
}
for (const o of input.orths) {
const side = sideOfAltDir(o.dir, `level ${levelId} link to ${o.level}`)
const horizontal = side === 'north' || side === 'south'
@ -686,22 +770,22 @@ function seamEntrances(input: DrlgLevelInput): DrlgEntrance[] {
from = lo - (horizontal ? coord.x : coord.y)
to = hi - (horizontal ? coord.x : coord.y)
}
const mid = Math.floor((from + to - 1) / 2)
const edgeX = side === 'west' ? 0 : side === 'east' ? coord.width - 1 : mid
const edgeY = side === 'north' ? 0 : side === 'south' ? coord.height - 1 : mid
const inX = side === 'west' ? 1 : side === 'east' ? coord.width - 2 : mid
const inY = side === 'north' ? 1 : side === 'south' ? coord.height - 2 : mid
out.push({
x: edgeX,
y: edgeY,
interiorX: inX,
interiorY: inY,
toLevelId: o.level,
side,
kind: 'gate',
span: { from, to },
})
pushSeam(o.level, side, from, to)
}
// Preset-adjacent seams that D2Common connects via fixed DS1 border presets rather than pOutdoors->pRoomData:
// - Chaos Sanctum (108): south entrance bridge to River of Flame (107) at x=48..72 (DiabloEntry at grid 6,12).
// - Bloody Foothills (110): east town gate to Harrogath (109, y=0..40) and west barricade transition to Frigid Highlands (111, y=0..32).
// - Frigid Highlands (111): east siege transition to Bloody Foothills (110, y=height-32..height).
if (levelId === LEVEL_CHAOSSANCTUM) {
pushSeam(LEVEL_RIVEROFFLAME, 'south', 48, 72)
} else if (levelId === LEVEL_BLOODYFOOTHILLS) {
pushSeam(LEVEL_HARROGATH, 'east', 0, Math.min(coord.height, 40))
pushSeam(LEVEL_ID_ACT5_BARRICADE_1, 'west', 0, Math.min(coord.height, 32))
} else if (levelId === LEVEL_ID_ACT5_BARRICADE_1) {
pushSeam(LEVEL_BLOODYFOOTHILLS, 'east', Math.max(0, coord.height - 32), coord.height)
}
return out
}
@ -750,15 +834,24 @@ export function drlgLevelInputFromDump(dump: DrlgDump, levelId: number, tables:
throw new Error(`level ${levelId}: outdoor grid ${gridW}x${gridH} does not cover the ${levelW}x${levelH} tiles of the level`)
}
const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => ({
key,
kind,
x: room[0] + t[2] - levelX,
y: room[1] + t[3] - levelY,
flags: t[5] >>> 0,
tileType: t[7],
tile: t[6] === null ? null : { library: normalizeDrlgPath(t[6][0]), index: t[6][1] },
})
const place = (key: string, kind: DrlgTileKind, room: DumpCoord, t: DumpTile): DrlgTileInput => {
const library = t[6] === null ? null : normalizeDrlgPath(t[6][0])
// DRLGROOMTILE_GetTileCache (DrlgRoomTile.cpp:0x6FD88860): when D2CMP_10088_GetTiles finds no
// matching tile in the room's masked DT1s (e.g. unoccupied Act 3 jungle 8x8 filler rooms whose
// dwDT1Mask = 0x04 excludes Floor.dt1), it falls back to GetTiles(pTiles, 10, 0, 0) from Warp.dt1
// (type 10) while preserving the non-10 nTileType (0 for floor). Those void filler cells have no
// floor tile and remain unwalkable.
const isMissingTileFallback = library === DRLG_UNIVERSAL_DT1[2] && t[7] !== 10
return {
key,
kind,
x: room[0] + t[2] - levelX,
y: room[1] + t[3] - levelY,
flags: t[5] >>> 0,
tileType: t[7],
tile: t[6] === null || isMissingTileFallback ? null : { library: library!, index: t[6][1] },
}
}
const initialised = (level: DumpLevel, room: number, array: 'w' | 'f' | 'r'): { list: readonly DumpTile[]; count: number } => {
const mt = level.activation[room]!.mapTiles
const [list, count] = array === 'w' ? [mt.walls, mt.nWalls] : array === 'f' ? [mt.floors, mt.nFloors] : [mt.roofs, mt.nShadows]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,413 @@
/**
* D2Common Act II outdoor desert DRLG: 1:1 port of D2MOO
* `source/D2Common/src/Drlg/DrlgOutDesr.cpp`.
*/
import { SEED_RollLimitedRandomNumber, SEED_RollRandomNumber } from '../d2-rng.ts'
import {
LVLPREST_ACT2_DESERT_BORDER_1,
LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS,
LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_ENDS,
LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB,
LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH,
LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL,
LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS,
LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_ENDS,
LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB,
LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH,
LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL,
LVLPREST_ACT2_DESERT_CLIFF_TOP,
LVLPREST_ACT2_DESERT_CLIFF_TOP_KING_TOMB,
LVLPREST_ACT2_DESERT_FILL_BERMS_1,
LVLPREST_ACT2_DESERT_FILL_BERMS_2,
LVLPREST_ACT2_DESERT_FILL_BERMS_3,
LVLPREST_ACT2_DESERT_FILL_BERMS_4,
LVLPREST_ACT2_DESERT_FILL_BONE_1,
LVLPREST_ACT2_DESERT_FILL_BONE_2,
LVLPREST_ACT2_DESERT_FILL_HEAD_1,
LVLPREST_ACT2_DESERT_FILL_HEAD_2,
LVLPREST_ACT2_DESERT_FILL_MESA_1,
LVLPREST_ACT2_DESERT_FILL_WAGON_1,
LVLPREST_ACT2_DESERT_LAIR_1,
LVLPREST_ACT2_DESERT_OASIS_1,
LVLPREST_ACT2_DESERT_OASIS_2,
LVLPREST_ACT2_DESERT_OASIS_3,
LVLPREST_ACT2_DESERT_RUINS_08X08,
LVLPREST_ACT2_DESERT_RUINS_08X16,
LVLPREST_ACT2_DESERT_RUINS_16X08,
LVLPREST_ACT2_DESERT_RUINS_16X16,
LVLPREST_ACT2_DESERT_RUINS_ELDER,
LVLPREST_ACT2_DESERT_RUINS_SEWER,
LVLPREST_ACT2_DESERT_TOMB_1,
LVLPREST_ACT2_DESERT_TOMB_2,
LVLPREST_ACT2_DESERT_TRANSITION_N,
LVLPREST_ACT2_DESERT_TRANSITION_W,
LVLPREST_ACT2_DESERT_VALLEY_RUIN_1,
LVLPREST_ACT2_DESERT_VALLEY_RUIN_2,
LVLPREST_ACT2_DESERT_VALLEY_WARP,
} from './drlg-ids.ts'
import {
DRLGOUTDOORS_AddAct124SecondaryBorder,
DRLGOUTDOORS_SpawnAct12Shrines,
DRLGOUTDOORS_SpawnAct12Waypoint,
DRLGOUTDOORS_SpawnOutdoorLevelPreset,
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx,
} from './drlg-outdoors.ts'
import { DRLGOUTPLACE_PlaceAct1245OutdoorBorders, DRLGOUTPLACE_SetOutGridLinkFlags } from './drlg-outplace.ts'
import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts'
import {
type D2DrlgLevelStrc,
LEVEL_CANYONOFTHEMAGI,
LEVEL_DRYHILLS,
LEVEL_FAROASIS,
LEVEL_LOSTCITY,
LEVEL_LUTGHOLEIN,
LEVEL_PANDEMONIUMRUN2,
LEVEL_ROCKYWASTE,
LEVEL_VALLEYOFSNAKES,
} from './drlg-types.ts'
interface D2DrlgOutDesertInitStrc {
readonly nLvlPrestId: number
readonly nRand: number
readonly nX: number
readonly nY: number
}
// Inlined in D2Common.0x6FD7D430
export function DRLGOUTDESR_PlaceDesertTransitionToTown(pLevel: D2DrlgLevelStrc): void {
let pTownRoomData = pLevel.pOutdoors!.pRoomData
while (pTownRoomData && pTownRoomData.pLevel!.nLevelId !== LEVEL_LUTGHOLEIN) {
pTownRoomData = pTownRoomData.pNext
}
if (pTownRoomData) {
if (pTownRoomData.nDirection === 3) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
0,
pLevel.pOutdoors!.nGridHeight - 1,
LVLPREST_ACT2_DESERT_TRANSITION_N,
-1,
false,
)
} else {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
pLevel.pOutdoors!.nGridWidth - 1,
0,
LVLPREST_ACT2_DESERT_TRANSITION_W,
-1,
false,
)
}
}
}
// 1.10f: D2Common.0x6FD7D430
// 1.11 : D2Common.0x6FDD58A0
export function DRLGOUTDESR_InitAct2OutdoorLevel(pLevel: D2DrlgLevelStrc): void {
const nLevelPrestIds1: readonly number[] = [
LVLPREST_ACT2_DESERT_OASIS_1,
LVLPREST_ACT2_DESERT_RUINS_08X08,
LVLPREST_ACT2_DESERT_FILL_BONE_1,
LVLPREST_ACT2_DESERT_FILL_BONE_2,
LVLPREST_ACT2_DESERT_FILL_HEAD_1,
LVLPREST_ACT2_DESERT_FILL_MESA_1,
LVLPREST_ACT2_DESERT_FILL_WAGON_1,
]
const nLevelPrestIds2: readonly number[] = [
LVLPREST_ACT2_DESERT_OASIS_1,
LVLPREST_ACT2_DESERT_RUINS_08X08,
LVLPREST_ACT2_DESERT_FILL_HEAD_2,
LVLPREST_ACT2_DESERT_FILL_MESA_1,
]
const nLevelPrestIds3: readonly number[] = [
LVLPREST_ACT2_DESERT_FILL_BERMS_1,
LVLPREST_ACT2_DESERT_FILL_BERMS_2,
LVLPREST_ACT2_DESERT_FILL_BERMS_3,
LVLPREST_ACT2_DESERT_FILL_BERMS_4,
]
const nLevelPrestIds4: readonly number[] = [LVLPREST_ACT2_DESERT_OASIS_2, LVLPREST_ACT2_DESERT_OASIS_3]
DRLGOUTPLACE_SetOutGridLinkFlags(pLevel)
DRLGOUTPLACE_PlaceAct1245OutdoorBorders(pLevel)
switch (pLevel.nLevelId) {
case LEVEL_ROCKYWASTE:
DRLGOUTDESR_PlaceDesertTransitionToTown(pLevel)
DRLGOUTDESR_PlaceBorders(pLevel)
DRLGOUTDESR_AddExits(pLevel)
DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false)
break
case LEVEL_DRYHILLS:
DRLGOUTDESR_PlaceCliffs(pLevel)
DRLGOUTDESR_PlaceBorders(pLevel)
DRLGOUTDESR_AddExits(pLevel)
DRLGOUTDOORS_SpawnAct12Waypoint(pLevel)
DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, false)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds3, nLevelPrestIds3.length, true)
break
case LEVEL_FAROASIS:
DRLGOUTDESR_PlaceCliffs(pLevel)
DRLGOUTDESR_PlaceBorders(pLevel)
DRLGOUTDESR_AddExits(pLevel)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds4, nLevelPrestIds4.length, false)
DRLGOUTDOORS_SpawnAct12Waypoint(pLevel)
DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5)
DRLGOUTDESR_PlaceFillsInFarOasis(pLevel)
break
case LEVEL_LOSTCITY:
DRLGOUTDESR_PlaceCliffs(pLevel)
DRLGOUTDESR_PlaceBorders(pLevel)
DRLGOUTDESR_AddExits(pLevel)
DRLGOUTDESR_PlaceRuinsInLostCity(pLevel)
DRLGOUTDOORS_SpawnAct12Waypoint(pLevel)
DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5)
DRLGOUTDESR_PlaceFillsInLostCity(pLevel)
break
case LEVEL_VALLEYOFSNAKES:
DRLGOUTDESR_AddExits(pLevel)
break
case LEVEL_CANYONOFTHEMAGI:
DRLGOUTDESR_PlaceTombEntriesInCanyon(pLevel)
DRLGOUTDESR_PlaceBorders(pLevel)
DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5)
DRLGOUTDESR_PlaceFillsInCanyon(pLevel)
break
case LEVEL_PANDEMONIUMRUN2:
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 4, 4, LVLPREST_ACT2_DESERT_VALLEY_WARP, -1, false)
DRLGOUTDESR_PlaceBorders(pLevel)
DRLGOUTDESR_PlaceFillsInCanyon(pLevel)
DRLGOUTDOORS_SpawnAct12Shrines(pLevel, 5)
break
default:
return
}
}
// D2Common.0x6FD7D870
export function DRLGOUTDESR_PlacePresetVariants(
pLevel: D2DrlgLevelStrc,
pLevelPrestIds: readonly number[],
nVariants: number,
bIterateFiles: boolean,
): void {
let nRand = SEED_RollLimitedRandomNumber(pLevel.pSeed, nVariants) >>> 0
for (let i = 0; i < nVariants; i += 1) {
const nPrestId = pLevelPrestIds[nRand]!
if (bIterateFiles) {
const dwFiles = DATATBLS_GetLvlPrestTxtRecord(pLevel.pDrlg.env.tables, nPrestId).dwFiles >>> 0
for (let nFile = 0; nFile < dwFiles; nFile += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nPrestId, nFile, 0, 15)
}
} else {
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nPrestId, -1, 0, 15)
}
nRand = (nRand + 1) % nVariants
}
}
const pOutDesertCliffsInit: readonly (readonly D2DrlgOutDesertInitStrc[])[] = [
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 1, nX: 0, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 2, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 6, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 1, nX: 0, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 2, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 6, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 1, nX: 0, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 2, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 6, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_WALL, nRand: -1, nX: 6, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_TOP, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 6 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_ENDS, nRand: 2, nX: 8, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_PATH, nRand: -1, nX: 6, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_TOP, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 6 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 1, nX: 4, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 2 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 6 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 1, nX: 4, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 2 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 6 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 },
],
[
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 1, nX: 4, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 2 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_WALL, nRand: -1, nX: 4, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_PATH, nRand: -1, nX: 4, nY: 6 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_ENDS, nRand: 2, nX: 4, nY: 8 },
],
]
// D2Common.0x6FD7D950
export function DRLGOUTDESR_PlaceCliffs(pLevel: D2DrlgLevelStrc): void {
const nRand = SEED_RollRandomNumber(pLevel.pSeed) & 7
const row = pOutDesertCliffsInit[nRand]!
for (let i = 0; i < 5; i += 1) {
const entry = row[i]!
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, entry.nX, entry.nY, entry.nLvlPrestId, entry.nRand, false)
}
}
// D2Common.0x6FD7D9B0
export function DRLGOUTDESR_PlaceBorders(pLevel: D2DrlgLevelStrc): void {
DRLGOUTDOORS_AddAct124SecondaryBorder(pLevel, 2, LVLPREST_ACT2_DESERT_BORDER_1)
DRLGOUTDOORS_AddAct124SecondaryBorder(pLevel, 1, LVLPREST_ACT2_DESERT_BORDER_1)
DRLGOUTDOORS_AddAct124SecondaryBorder(pLevel, 3, LVLPREST_ACT2_DESERT_BORDER_1)
}
// D2Common.0x6FD7D9F0
export function DRLGOUTDESR_AddExits(pLevel: D2DrlgLevelStrc): void {
let nLevelPrestId = 0
switch (pLevel.nLevelId) {
case LEVEL_ROCKYWASTE:
case LEVEL_DRYHILLS:
nLevelPrestId = LVLPREST_ACT2_DESERT_TOMB_1
break
case LEVEL_FAROASIS:
nLevelPrestId = LVLPREST_ACT2_DESERT_LAIR_1
break
case LEVEL_LOSTCITY:
nLevelPrestId = LVLPREST_ACT2_DESERT_RUINS_SEWER
break
case LEVEL_VALLEYOFSNAKES:
nLevelPrestId = LVLPREST_ACT2_DESERT_TOMB_2
break
default:
throw new Error('sAddTheExitsAndStuff() - Could not add the exit!')
}
if (!DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nLevelPrestId, -1, 0, 15)) {
throw new Error('sAddTheExitsAndStuff() - Could not add the exit!')
}
}
// D2Common.0x6FD7DA60
export function DRLGOUTDESR_PlaceFillsInFarOasis(pLevel: D2DrlgLevelStrc): void {
const nLevelPrestIds1: readonly number[] = [
LVLPREST_ACT2_DESERT_RUINS_08X08,
LVLPREST_ACT2_DESERT_FILL_HEAD_1,
LVLPREST_ACT2_DESERT_FILL_MESA_1,
LVLPREST_ACT2_DESERT_FILL_WAGON_1,
]
const nLevelPrestIds2: readonly number[] = [LVLPREST_ACT2_DESERT_OASIS_1]
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true)
}
// D2Common.0x6FD7DAC0
export function DRLGOUTDESR_PlaceRuinsInLostCity(pLevel: D2DrlgLevelStrc): void {
const nLevelPrestIds: readonly number[] = [
LVLPREST_ACT2_DESERT_RUINS_ELDER,
LVLPREST_ACT2_DESERT_RUINS_16X16,
LVLPREST_ACT2_DESERT_RUINS_16X08,
LVLPREST_ACT2_DESERT_RUINS_08X16,
]
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds, nLevelPrestIds.length, false)
}
// D2Common.0x6FD7DB00
export function DRLGOUTDESR_PlaceFillsInLostCity(pLevel: D2DrlgLevelStrc): void {
const nLevelPrestIds1: readonly number[] = [
LVLPREST_ACT2_DESERT_OASIS_1,
LVLPREST_ACT2_DESERT_FILL_HEAD_2,
LVLPREST_ACT2_DESERT_FILL_MESA_1,
LVLPREST_ACT2_DESERT_FILL_BERMS_1,
LVLPREST_ACT2_DESERT_FILL_BERMS_2,
]
const nLevelPrestIds2: readonly number[] = [LVLPREST_ACT2_DESERT_RUINS_08X08]
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true)
}
const pOutDesertCanyonInit: readonly D2DrlgOutDesertInitStrc[] = [
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_ENDS, nRand: 0, nX: 8, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, nRand: 2, nX: 6, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, nRand: 1, nX: 4, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_RIGHT_KING_TOMB, nRand: 0, nX: 2, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_TOP_KING_TOMB, nRand: 0, nX: 0, nY: 0 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, nRand: 0, nX: 0, nY: 2 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, nRand: 1, nX: 0, nY: 4 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_TOMB, nRand: 2, nX: 0, nY: 6 },
{ nLvlPrestId: LVLPREST_ACT2_DESERT_CLIFF_LEFT_KING_ENDS, nRand: 0, nX: 0, nY: 8 },
]
// D2Common.0x6FD7DB70
export function DRLGOUTDESR_PlaceTombEntriesInCanyon(pLevel: D2DrlgLevelStrc): void {
for (let i = 0; i < pOutDesertCanyonInit.length; i += 1) {
const entry = pOutDesertCanyonInit[i]!
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, entry.nX, entry.nY, entry.nLvlPrestId, entry.nRand, false)
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 4, 4, LVLPREST_ACT2_DESERT_VALLEY_WARP, -1, false)
}
// 1.10f: D2Common.0x6FD7DBC0
// 1.11 : D2Common.0x6FDD5600
export function DRLGOUTDESR_PlaceFillsInCanyon(pLevel: D2DrlgLevelStrc): void {
const nLevelPrestIds1: readonly number[] = [
LVLPREST_ACT2_DESERT_FILL_BONE_1,
LVLPREST_ACT2_DESERT_FILL_BONE_2,
LVLPREST_ACT2_DESERT_FILL_BERMS_3,
LVLPREST_ACT2_DESERT_FILL_BERMS_4,
LVLPREST_ACT2_DESERT_FILL_WAGON_1,
]
const nLevelPrestIds2: readonly number[] = [
LVLPREST_ACT2_DESERT_VALLEY_RUIN_1,
LVLPREST_ACT2_DESERT_VALLEY_RUIN_2,
]
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds1, nLevelPrestIds1.length, false)
DRLGOUTDESR_PlacePresetVariants(pLevel, nLevelPrestIds2, nLevelPrestIds2.length, true)
}

View File

@ -36,6 +36,7 @@ import {
LEVEL_BLOODMOOR,
LEVEL_COLDPLAINS,
LEVEL_MONASTERYGATE,
LEVEL_PANDEMONIUMRUN2,
LEVEL_ROGUEENCAMPMENT,
grid2PickedFile,
grid2PickedFileBits,
@ -96,13 +97,16 @@ import { DRLGROOM_AreXYInsideCoordinates, DRLGROOM_GetVisArrayFromLevelId } from
import { DRLGPRESET_AllocDrlgMap, DRLGPRESET_BuildArea, DRLGPRESET_SetPickedFileInDrlgMap } from './drlg-preset.ts'
import { DRLG_GetActNoFromLevelId } from './drlg-drlg.ts'
import { notifyLevelGrid } from './drlg-hooks.ts'
import { DRLGOUTDESR_InitAct2OutdoorLevel } from './drlg-outdesr.ts'
import { DRLGOUTWILD_GetBridgeCoords, DRLGOUTWILD_InitAct1OutdoorLevel } from './drlg-outwild.ts'
import {
DRLGOUTPLACE_CreateOutdoorRoomEx,
DRLGOUTPLACE_InitAct3OutdoorLevel,
DRLGOUTPLACE_PlaceAct1245OutdoorBorders,
DRLGOUTPLACE_SetOutGridLinkFlags,
sub_6FD80750,
} from './drlg-outplace.ts'
import { DRLGOUTSIEGE_InitAct5OutdoorLevel } from './drlg-outsiege.ts'
import { DRLGTILESUB_AddSecondaryBorder } from './drlg-tilesub.ts'
// D2C_Levels (DataTbls/LevelsIds.h) used only by the Act IV code of this file.
@ -794,34 +798,36 @@ export function DRLGOUTDOORS_GenerateLevel(pLevel: D2DrlgLevelStrc): void {
pVertex = pVertex.pNext!
} while (pVertex !== pOutdoorInfo.pVertex)
// #ifdef D2_VERSION_HAS_UBERS: LEVEL_PANDEMONIUMRUN2 -> ACT_II. Not compiled in the oracle, which
// builds D2MOO with the default D2_VERSION_110F of D2BuildInformation.h (either way not Act I).
const nAct = DRLG_GetActNoFromLevelId(pLevel.nLevelId)
const nAct =
pLevel.nLevelId === LEVEL_PANDEMONIUMRUN2
? ACT_II
: DRLG_GetActNoFromLevelId(pLevel.nLevelId)
switch (nAct) {
case ACT_I:
DRLGOUTWILD_InitAct1OutdoorLevel(pLevel)
// Mirrors the oracle's link-time `--wrap=DRLGOUTWILD_InitAct1OutdoorLevel`
// (tools/d2moo-oracle/src/main.cpp ~line 241), which captures the `levelGrid` stage right after
// the real DRLGOUTWILD_InitAct1OutdoorLevel returns and before any room of the level exists.
notifyLevelGrid(pLevel)
break
case ACT_II:
// DRLGOUTDESR_InitAct2OutdoorLevel(pLevel);
throw new Error('DRLGOUTDOORS_GenerateLevel: act 2 is not ported')
DRLGOUTDESR_InitAct2OutdoorLevel(pLevel)
notifyLevelGrid(pLevel)
break
case ACT_III:
// DRLGOUTPLACE_InitAct3OutdoorLevel(pLevel);
throw new Error('DRLGOUTDOORS_GenerateLevel: act 3 is not ported')
DRLGOUTPLACE_InitAct3OutdoorLevel(pLevel)
notifyLevelGrid(pLevel)
break
case ACT_IV:
// DRLGOUTDOORS_InitAct4OutdoorLevel(pLevel); (ported below, but the Act IV pipeline is out of scope)
throw new Error('DRLGOUTDOORS_GenerateLevel: act 4 is not ported')
DRLGOUTDOORS_InitAct4OutdoorLevel(pLevel)
notifyLevelGrid(pLevel)
break
case ACT_V:
// DRLGOUTSIEGE_InitAct5OutdoorLevel(pLevel);
throw new Error('DRLGOUTDOORS_GenerateLevel: act 5 is not ported')
DRLGOUTSIEGE_InitAct5OutdoorLevel(pLevel)
notifyLevelGrid(pLevel)
break
default:
break

View File

@ -0,0 +1,280 @@
/**
* D2Common Act III outdoor jungle/Kurast DRLG: 1:1 port of D2MOO
* `source/D2Common/src/Drlg/DrlgOutJung.cpp`.
*/
import { SEED_RollLimitedRandomNumber } from '../d2-rng.ts'
import {
LVLPREST_ACT3_BURBS_BORDER_E,
LVLPREST_ACT3_BURBS_BORDER_N,
LVLPREST_ACT3_BURBS_BORDER_NE,
LVLPREST_ACT3_BURBS_BORDER_NW,
LVLPREST_ACT3_BURBS_BORDER_S,
LVLPREST_ACT3_BURBS_BORDER_SE,
LVLPREST_ACT3_BURBS_BORDER_SW,
LVLPREST_ACT3_BURBS_BORDER_W,
LVLPREST_ACT3_JUNGLE_HEAD,
LVLPREST_ACT3_JUNGLE_TAIL,
LVLPREST_ACT3_METRO_BORDER_E,
LVLPREST_ACT3_METRO_BORDER_N,
LVLPREST_ACT3_METRO_BORDER_NE,
LVLPREST_ACT3_METRO_BORDER_NW,
LVLPREST_ACT3_METRO_BORDER_S,
LVLPREST_ACT3_METRO_BORDER_SE,
LVLPREST_ACT3_METRO_BORDER_SW,
LVLPREST_ACT3_METRO_BORDER_W,
LVLPREST_ACT3_SLUMS_BORDER_E,
LVLPREST_ACT3_SLUMS_BORDER_N,
LVLPREST_ACT3_SLUMS_BORDER_NE,
LVLPREST_ACT3_SLUMS_BORDER_NW,
LVLPREST_ACT3_SLUMS_BORDER_S,
LVLPREST_ACT3_SLUMS_BORDER_SE,
LVLPREST_ACT3_SLUMS_BORDER_SW,
LVLPREST_ACT3_SLUMS_BORDER_W,
} from './drlg-ids.ts'
import { DRLGOUTDOORS_SpawnOutdoorLevelPresetEx, DRLGOUTDOORS_TestOutdoorLevelPreset } from './drlg-outdoors.ts'
import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts'
import { type D2DrlgLevelStrc, idiv, imod, LEVEL_FLAYERJUNGLE, LEVEL_SPIDERFOREST } from './drlg-types.ts'
const dword_6FDCFB18: readonly number[] = [0, 10, 20, 0]
const dword_6FDCFB28: readonly number[] = [0, 1, 2, 1, 0, 2, 0, 2, 1, 1, 2, 0, 2, 0, 1, 2, 1, 0]
// D2Common.0x6FD7FC20
export function DRLGOUTJUNG_BuildJungle(pLevel: D2DrlgLevelStrc): void {
if (pLevel.nLevelId >= LEVEL_SPIDERFOREST && pLevel.nLevelId <= LEVEL_FLAYERJUNGLE) {
const pLevelDefBin = DATATBLS_GetLevelDefRecord(pLevel.pDrlg.env.tables, LEVEL_SPIDERFOREST)
const nSizeX = pLevelDefBin.dwSizeX[pLevel.pDrlg.nDifficulty]! >> 5
const nSizeY = pLevelDefBin.dwSizeY[pLevel.pDrlg.nDifficulty]! >> 5
const nRand =
SEED_RollLimitedRandomNumber(pLevel.pSeed, 4 * (pLevel.nJungleDefs === 3 ? 1 : 0) + 2) | 0
const pJungleDefs = pLevel.pJungleDefs!
let nDefId = 0
let nFileIndex = 0
for (let i = 0; i < nSizeY; i += 1) {
if (pLevel.nLevelId === LEVEL_SPIDERFOREST && i === nSizeY - 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
0,
4 * i,
LVLPREST_ACT3_JUNGLE_HEAD,
pJungleDefs[nSizeX * nSizeY - 1] === 0 ? 1 : 0,
false,
)
nDefId += 2
} else if (pLevel.nLevelId === LEVEL_FLAYERJUNGLE && i === 0) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
0,
0,
LVLPREST_ACT3_JUNGLE_TAIL,
pJungleDefs[1] === 0 ? 1 : 0,
false,
)
nDefId += 2
} else {
for (let j = 0; j < nSizeX; j += 1) {
nDefId += 1
let nJungleDef = pJungleDefs[nDefId - 1]!
let v19 = 0
if (nJungleDef > LVLPREST_ACT3_JUNGLE_TAIL) {
nJungleDef += dword_6FDCFB18[pLevel.nLevelId - 76]!
v19 = dword_6FDCFB28[nFileIndex + 3 * nRand]!
nFileIndex += 1
} else {
v19 = -1
}
if (nJungleDef) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 4 * j, 4 * i, nJungleDef, v19, false)
}
}
}
}
}
}
// D2Common.0x6FD7FE50
export function DRLGOUTJUNG_BuildLowerKurast(pLevel: D2DrlgLevelStrc): void {
const nWidth = idiv(pLevel.pLevelCoords.nWidth, 8) - 1
const nHeight = idiv(pLevel.pLevelCoords.nHeight, 8) - 1
const v5 = idiv(idiv(pLevel.pLevelCoords.nWidth, 8) - 1, 2)
const v10 = pLevel.pDrlg.bJungleInterlink ? 1 : nWidth - 1
for (let i = 1; i < nWidth; i += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
0,
8 * (i === v10 ? 1 : 0) + LVLPREST_ACT3_SLUMS_BORDER_N,
-1,
false,
)
}
for (let i = 1; i < nWidth; i += (i === v5 ? 1 : 0) + 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
nHeight,
8 * (i === v5 ? 1 : 0) + LVLPREST_ACT3_SLUMS_BORDER_S,
-1,
false,
)
}
for (let i = 1; i < nHeight; i += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, i, LVLPREST_ACT3_SLUMS_BORDER_E, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT3_SLUMS_BORDER_W, -1, false)
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_SLUMS_BORDER_NW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, 0, LVLPREST_ACT3_SLUMS_BORDER_NE, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, nHeight, LVLPREST_ACT3_SLUMS_BORDER_SW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, nHeight, LVLPREST_ACT3_SLUMS_BORDER_SE, -1, false)
}
// D2Common.0x6FD7FFA0
export function DRLGOUTJUNG_BuildKurastBazaar(pLevel: D2DrlgLevelStrc): void {
const nWidth = idiv(pLevel.pLevelCoords.nWidth, 8) - 1
const nHeight = idiv(pLevel.pLevelCoords.nHeight, 8) - 1
let v7 = 0
let v8 = 0
if (pLevel.pDrlg.bJungleInterlink) {
v7 = idiv(pLevel.pLevelCoords.nWidth, 8) - 2
v8 = 1
} else {
v7 = 1
v8 = idiv(pLevel.pLevelCoords.nWidth, 8) - 2
}
for (let i = 1; i < nWidth; i += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
0,
8 * (i === v7 ? 1 : 0) + LVLPREST_ACT3_BURBS_BORDER_N,
-1,
false,
)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
nHeight,
8 * (i === v8 ? 1 : 0) + LVLPREST_ACT3_BURBS_BORDER_S,
-1,
false,
)
}
for (let i = 1; i < nHeight; i += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, i, LVLPREST_ACT3_BURBS_BORDER_E, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT3_BURBS_BORDER_W, -1, false)
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_BURBS_BORDER_NW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, 0, LVLPREST_ACT3_BURBS_BORDER_NE, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, nHeight, LVLPREST_ACT3_BURBS_BORDER_SW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, nHeight, LVLPREST_ACT3_BURBS_BORDER_SE, -1, false)
}
// D2Common.0x6FD800E0
export function DRLGOUTJUNG_BuildUpperKurast(pLevel: D2DrlgLevelStrc): void {
const nWidth = idiv(pLevel.pLevelCoords.nWidth, 8) - 1
const nHeight = idiv(pLevel.pLevelCoords.nHeight, 8) - 1
const v8 = pLevel.pDrlg.bJungleInterlink ? nWidth - 1 : 1
const nHalfWidth = idiv(nWidth, 2)
for (let i = 1; i < nWidth; i += (i === nHalfWidth ? 1 : 0) + 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
0,
8 * (i === nHalfWidth ? 1 : 0) + LVLPREST_ACT3_METRO_BORDER_N,
-1,
false,
)
}
for (let i = 1; i < nWidth; i += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
nHeight,
8 * (i === v8 ? 1 : 0) + LVLPREST_ACT3_METRO_BORDER_S,
-1,
false,
)
}
for (let i = 1; i < nHeight; i += 1) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, i, LVLPREST_ACT3_METRO_BORDER_E, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT3_METRO_BORDER_W, -1, false)
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_METRO_BORDER_NW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, 0, LVLPREST_ACT3_METRO_BORDER_NE, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, nHeight, LVLPREST_ACT3_METRO_BORDER_SW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nWidth, nHeight, LVLPREST_ACT3_METRO_BORDER_SE, -1, false)
}
// D2Common.0x6FD80230
export function DRLGOUTJUNG_SpawnRandomPreset(
pLevel: D2DrlgLevelStrc,
nLevelPrestId1: number,
nLevelPrestId2: number,
a4: number,
): void {
const nVariants = (nLevelPrestId2 - nLevelPrestId1 + 1) >>> 0
let v34 = 0
const pOutdoors = pLevel.pOutdoors!
const v8 = Math.imul(pOutdoors.nGridWidth, pOutdoors.nGridHeight)
if (v8) {
const v11 = new Int32Array(2 * v8)
for (let i = 0; i < v8; i += 1) {
const v12 = idiv(i, pOutdoors.nGridWidth)
const v13 = imod(i, pOutdoors.nGridWidth)
v11[2 * i] = v13
v11[2 * i + 1] = v12
}
for (let i = 0; i < v8; i += 1) {
const v14 = SEED_RollLimitedRandomNumber(pLevel.pSeed, v8) >>> 0
const v19 = SEED_RollLimitedRandomNumber(pLevel.pSeed, v8) >>> 0
const v23 = v11[2 * v14]!
const v24 = v11[2 * v14 + 1]!
v11[2 * v14] = v11[2 * v19]!
v11[2 * v14 + 1] = v11[2 * v19 + 1]!
v11[2 * v19] = v23
v11[2 * v19 + 1] = v24
}
for (let i = 0; i < v8; i += 1) {
const a2b = v11[2 * i]!
const a3a = v11[2 * i + 1]!
const v26 = SEED_RollLimitedRandomNumber(pLevel.pSeed, nVariants) >>> 0
if (DRLGOUTDOORS_TestOutdoorLevelPreset(pLevel, a2b, a3a, v26 + nLevelPrestId1, 0, 15)) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, a2b, a3a, v26 + nLevelPrestId1, -1, false)
v34 += 1
if (a4 > 0 && v34 >= a4) {
break
}
}
}
}
}

View File

@ -46,6 +46,30 @@ import {
LVLPREST_ACT2_DESERT_BORDER_10,
LVLPREST_ACT2_DESERT_BORDER_11,
LVLPREST_ACT2_DESERT_BORDER_12,
LVLPREST_ACT3_BRIDGE,
LVLPREST_ACT3_BURBS_08X08,
LVLPREST_ACT3_BURBS_08X16,
LVLPREST_ACT3_BURBS_16X08,
LVLPREST_ACT3_BURBS_16X16,
LVLPREST_ACT3_BURBS_SEWER,
LVLPREST_ACT3_BURBS_TEMPLE,
LVLPREST_ACT3_BURBS_WAYPOINT,
LVLPREST_ACT3_METRO_08X08,
LVLPREST_ACT3_METRO_08X16,
LVLPREST_ACT3_METRO_16X08,
LVLPREST_ACT3_METRO_16X16,
LVLPREST_ACT3_METRO_SEWER,
LVLPREST_ACT3_METROTEMPLE,
LVLPREST_ACT3_SLUMS_08X08,
LVLPREST_ACT3_SLUMS_08X16,
LVLPREST_ACT3_SLUMS_16X08,
LVLPREST_ACT3_SLUMS_16X16,
LVLPREST_ACT3_TRAVINCAL_N,
LVLPREST_ACT3_TRAVINCAL_NE,
LVLPREST_ACT3_TRAVINCAL_NW,
LVLPREST_ACT3_TRAVINCAL_S,
LVLPREST_ACT3_TRAVINCAL_SE,
LVLPREST_ACT3_TRAVINCAL_SW,
LVLPREST_ACT4_MESA_BORDER_1,
LVLPREST_ACT4_MESA_BORDER_2,
LVLPREST_ACT4_MESA_BORDER_3,
@ -95,8 +119,17 @@ import {
DRLG_OUTDOORS_GenerateDirtPath,
DRLGOUTDOORS_GetOutLinkVisFlag,
DRLGOUTDOORS_GetPackedGrid2Info,
DRLGOUTDOORS_SpawnOutdoorLevelPreset,
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx,
} from './drlg-outdoors.ts'
import {
DRLGOUTJUNG_BuildJungle,
DRLGOUTJUNG_BuildKurastBazaar,
DRLGOUTJUNG_BuildLowerKurast,
DRLGOUTJUNG_BuildUpperKurast,
DRLGOUTJUNG_SpawnRandomPreset,
} from './drlg-outjung.ts'
import { sub_6FD84100 } from './drlg-outsiege.ts'
import { DRLGROOM_AddRoomExToLevel, DRLGROOM_AllocRoomEx, DRLGROOM_AreXYInsideCoordinates } from './drlg-room.ts'
import { DRLGROOMTILE_AllocTileGrid } from './drlg-roomtile.ts'
import { DATATBLS_GetLevelDefRecord } from './drlg-tables.ts'
@ -129,12 +162,16 @@ import {
i32,
idiv,
LEVEL_BURIALGROUNDS,
LEVEL_KURASTBAZAAR,
LEVEL_KURASTCAUSEWAY,
LEVEL_LOWERKURAST,
LEVEL_TRAVINCAL,
LEVEL_TUNDRAWASTELANDS,
LEVEL_UPPERKURAST,
} from './drlg-types.ts'
import { DRLGVER_AllocVertex, DRLGVER_GetCoordDiff } from './drlg-vertex.ts'
import { sub_6FDAB750 } from './path-dir.ts'
const LEVEL_TUNDRAWASTELANDS = 112
const gnBarricadeCliffBorderIds: readonly (readonly [number, number])[] = [
[LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1, LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW],
[LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_2, LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_2_SNOW],
@ -152,6 +189,101 @@ const gnBarricadeCliffBorderIds: readonly (readonly [number, number])[] = [
[-1, 0],
]
//D2Common.0x6FD80480
export function DRLGOUTPLACE_BuildKurast(pLevel: D2DrlgLevelStrc): void {
switch (pLevel.nLevelId) {
case LEVEL_LOWERKURAST:
DRLGOUTJUNG_BuildLowerKurast(pLevel)
break
case LEVEL_KURASTBAZAAR:
DRLGOUTJUNG_BuildKurastBazaar(pLevel)
break
case LEVEL_UPPERKURAST:
DRLGOUTJUNG_BuildUpperKurast(pLevel)
break
default:
break
}
switch (pLevel.nLevelId) {
case LEVEL_LOWERKURAST:
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_WAYPOINT, 0, 0, 15)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_SLUMS_16X16, LVLPREST_ACT3_SLUMS_16X16, 4)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_SLUMS_08X16, LVLPREST_ACT3_SLUMS_16X08, 0)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_SLUMS_08X08, LVLPREST_ACT3_SLUMS_08X08, 0)
break
case LEVEL_KURASTBAZAAR:
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 3, 3, LVLPREST_ACT3_BURBS_SEWER, 0, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
idiv(pLevel.pLevelCoords.nWidth, 8) - 4,
3,
LVLPREST_ACT3_BURBS_SEWER,
1,
false,
)
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_TEMPLE, 0, 0, 15)
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_TEMPLE, 1, 0, 15)
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_WAYPOINT, 0, 0, 15)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_BURBS_16X16, LVLPREST_ACT3_BURBS_16X16, 4)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_BURBS_08X16, LVLPREST_ACT3_BURBS_16X08, 0)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_BURBS_08X08, LVLPREST_ACT3_BURBS_08X08, 0)
break
case LEVEL_UPPERKURAST:
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
3,
idiv(pLevel.pLevelCoords.nHeight, 8) - 4,
LVLPREST_ACT3_METRO_SEWER,
0,
false,
)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
idiv(pLevel.pLevelCoords.nWidth, 8) - 4,
idiv(pLevel.pLevelCoords.nHeight, 8) - 4,
LVLPREST_ACT3_METRO_SEWER,
1,
false,
)
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_METROTEMPLE, 0, 0, 15)
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_METROTEMPLE, 1, 0, 15)
DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, LVLPREST_ACT3_BURBS_WAYPOINT, 0, 0, 15)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_METRO_16X16, LVLPREST_ACT3_METRO_16X16, 4)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_METRO_08X16, LVLPREST_ACT3_METRO_16X08, 0)
DRLGOUTJUNG_SpawnRandomPreset(pLevel, LVLPREST_ACT3_METRO_08X08, LVLPREST_ACT3_METRO_08X08, 0)
break
case LEVEL_KURASTCAUSEWAY:
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_BRIDGE, 0, false)
break
default:
return
}
}
//D2Common.0x6FD806A0
export function DRLGOUTPLACE_InitAct3OutdoorLevel(pLevel: D2DrlgLevelStrc): void {
DRLGOUTPLACE_SetOutGridLinkFlags(pLevel)
DRLGOUTJUNG_BuildJungle(pLevel)
DRLGOUTPLACE_BuildKurast(pLevel)
if (pLevel.nLevelId === LEVEL_TRAVINCAL) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 0, LVLPREST_ACT3_TRAVINCAL_NW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 2, 0, LVLPREST_ACT3_TRAVINCAL_N, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 6, 0, LVLPREST_ACT3_TRAVINCAL_NE, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, 4, LVLPREST_ACT3_TRAVINCAL_SW, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 2, 4, LVLPREST_ACT3_TRAVINCAL_S, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 6, 4, LVLPREST_ACT3_TRAVINCAL_SE, -1, false)
}
}
interface D2UnkOutPlaceStrc12 {
field_0: number
field_4: number
@ -655,7 +787,8 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(pLevel: D2DrlgLevelStrc)
break
case LVLTYPE_ACT5_BARRICADE:
throw new Error('DRLGOUTPLACE_PlaceAct1245OutdoorBorders: act 5 is not ported')
nLevelPrestId = sub_6FD80BE0(nCurrentDiffX, nCurrentDiffY, sub_6FD84100(pLevel))
break
default:
nLevelPrestId = sub_6FD80BE0(nCurrentDiffX, nCurrentDiffY, -1)
@ -750,7 +883,8 @@ export function DRLGOUTPLACE_PlaceAct1245OutdoorBorders(pLevel: D2DrlgLevelStrc)
break
case LVLTYPE_ACT5_BARRICADE:
throw new Error('DRLGOUTPLACE_PlaceAct1245OutdoorBorders: act 5 is not ported')
v41 = sub_6FD84100(pLevel)
break
default:
v41 = -1

View File

@ -0,0 +1,714 @@
/**
* D2Common Act V outdoor siege/barricade DRLG: 1:1 port of D2MOO
* `source/D2Common/src/Drlg/DrlgOutSiege.cpp`.
*/
import { SEED_RollLimitedRandomNumber } from '../d2-rng.ts'
import { DRLGGRID_AlterGridFlag, DRLGGRID_GetGridEntry } from './drlg-grid.ts'
import {
LVLPREST_ACT5_BARRICADE_1,
LVLPREST_ACT5_BARRICADE_1_SNOW,
LVLPREST_ACT5_BARRICADE_8,
LVLPREST_ACT5_BARRICADE_BUILDING,
LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1,
LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW,
LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3,
LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3_SNOW,
LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4,
LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4_SNOW,
LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7,
LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7_SNOW,
LVLPREST_ACT5_BARRICADE_ENTRANCE_16X32,
LVLPREST_ACT5_BARRICADE_ENTRANCE_32X16,
LVLPREST_ACT5_BARRICADE_EXIT_16X32,
LVLPREST_ACT5_BARRICADE_EXIT_32X16,
LVLPREST_ACT5_BARRICADE_FILLER,
LVLPREST_ACT5_BARRICADE_FILLER_TREASURE,
LVLPREST_ACT5_BARRICADE_FROM_CAVE_16X32_SNOW,
LVLPREST_ACT5_BARRICADE_FROM_CAVE_32X16_SNOW,
LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N,
LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W,
LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1,
LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1_SNOW,
LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3,
LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3_SNOW,
LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4,
LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4_SNOW,
LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5,
LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5_SNOW,
LVLPREST_ACT5_BARRICADE_RUINS_N_1,
LVLPREST_ACT5_BARRICADE_RUINS_N_2,
LVLPREST_ACT5_BARRICADE_RUINS_N_TREASURE,
LVLPREST_ACT5_BARRICADE_RUINS_W_1,
LVLPREST_ACT5_BARRICADE_RUINS_W_2,
LVLPREST_ACT5_BARRICADE_RUINS_W_TREASURE,
LVLPREST_ACT5_BARRICADE_SNOW_LAKE_1,
LVLPREST_ACT5_BARRICADE_SNOW_LAKE_2,
LVLPREST_ACT5_BARRICADE_SNOW_OTHER,
LVLPREST_ACT5_BARRICADE_SNOW_TREASURE,
LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32,
LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32_SNOW,
LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16,
LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16_SNOW,
LVLPREST_ACT5_BARRICADE_TO_SIEGE,
LVLPREST_ACT5_BARRICADE_WAYPOINT_DIRT,
LVLPREST_ACT5_BARRICADE_WAYPOINT_SNOW,
LVLPREST_ACT5_SIEGE_TO_TOWN,
LVLSUB_ACT5_BARRICADE,
} from './drlg-ids.ts'
import {
DRLGOUTDOORS_AlterAdjacentPresetGridCells,
DRLGOUTDOORS_GetPackedGrid2Info,
DRLGOUTDOORS_GetPresetIndexFromGridCell,
DRLGOUTDOORS_SetBlankGridCell,
DRLGOUTDOORS_SpawnOutdoorLevelPreset,
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx,
DRLGOUTDOORS_TestGridCellNonLvlLink,
} from './drlg-outdoors.ts'
import { DRLGOUTPLACE_SetOutGridLinkFlags, sub_6FD80BE0, sub_6FD80C10 } from './drlg-outplace.ts'
import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts'
import { DRLGTILESUB_AddSecondaryBorder } from './drlg-tilesub.ts'
import {
type D2DrlgLevelStrc,
FLAG_OPERATION_OR,
GRID2_LVL_LINK,
GRID2_UNKB00,
idiv,
imod,
LEVEL_ARREATPLATEAU,
LEVEL_BLOODYFOOTHILLS,
LEVEL_ID_ACT5_BARRICADE_1,
LEVEL_TUNDRAWASTELANDS,
outdoorCoord,
} from './drlg-types.ts'
import { DRLGVER_GetCoordDiff } from './drlg-vertex.ts'
// D2Common.0x6FD84100
export function sub_6FD84100(pLevel: D2DrlgLevelStrc): number {
return (pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS ? 1 : 0) + 4
}
const stru_6FDD09C8: readonly (readonly [number, number])[] = [
[-1, 0],
[0, -1],
[1, 0],
[0, 1],
[0, -1],
[1, 0],
[0, 1],
[-1, 0],
[-1, 0],
[0, -1],
[1, 0],
[0, 1],
]
// D2Common.0x6FD84110
export function DRLGOUTSIEGE_InitAct5OutdoorLevel(pLevel: D2DrlgLevelStrc): void {
const pOutdoors = pLevel.pOutdoors!
if (pLevel.nLevelId === LEVEL_BLOODYFOOTHILLS) {
const pLvlPrestTxtRecord = DATATBLS_GetLvlPrestTxtRecord(pLevel.pDrlg.env.tables, LVLPREST_ACT5_SIEGE_TO_TOWN)
let nSize = pOutdoors.nGridWidth - idiv(pLvlPrestTxtRecord.nSizeX, 8)
for (let i = 0; i < 15; i += 1) {
if (nSize < 0) {
throw new Error('Siege Level is the wrong size')
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nSize, 0, i + LVLPREST_ACT5_SIEGE_TO_TOWN, 0, false)
nSize -= idiv(pLvlPrestTxtRecord.nSizeX, 8)
}
} else {
DRLGOUTPLACE_SetOutGridLinkFlags(pLevel)
let pPreviousVertex = pOutdoors.pVertex!
let pDrlgVertex = pPreviousVertex.pNext!
const nLookupId = sub_6FD84100(pLevel)
do {
const prevDiff = DRLGVER_GetCoordDiff(pPreviousVertex)
const curDiff = DRLGVER_GetCoordDiff(pDrlgVertex)
const nPreviousDiffX = prevDiff.nDiffX
const nPreviousDiffY = prevDiff.nDiffY
const nCurrentDiffX = curDiff.nDiffX
const nCurrentDiffY = curDiff.nDiffY
const nPreviousDiffXAbs = Math.abs(nPreviousDiffX)
const nPreviousDiffYAbs = Math.abs(nPreviousDiffY)
let nPreviousX = pPreviousVertex.nPosX & ~1
let nPreviousY = pPreviousVertex.nPosY & ~1
const nCurrentX = pDrlgVertex.nPosX & ~1
const nCurrentY = pDrlgVertex.nPosY & ~1
const nLevelPrestId = sub_6FD80BE0(nPreviousDiffX, nPreviousDiffY, nLookupId)
if ((pPreviousVertex.dwFlags & 2) === 0) {
while (nPreviousX !== nCurrentX || nPreviousY !== nCurrentY) {
nPreviousX += 2 * nPreviousDiffX
nPreviousY += 2 * nPreviousDiffY
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nPreviousX, nPreviousY, nLevelPrestId, -1, false)
DRLGGRID_AlterGridFlag(pOutdoors.pGrid[2], nPreviousX, nPreviousY, GRID2_UNKB00, FLAG_OPERATION_OR)
}
}
if (pPreviousVertex.dwFlags & 1) {
const nX = Math.max(pPreviousVertex.nPosX, pDrlgVertex.nPosX)
const nXCapped = (nX - 4 * nPreviousDiffXAbs) & ~1
const nY = Math.max(pPreviousVertex.nPosY, pDrlgVertex.nPosY)
const nYCapped = (nY - 4 * nPreviousDiffYAbs) & ~1
DRLGGRID_AlterGridFlag(pOutdoors.pGrid[2], nXCapped, nYCapped, GRID2_LVL_LINK, FLAG_OPERATION_OR)
DRLGGRID_AlterGridFlag(
pOutdoors.pGrid[2],
nXCapped + 2 * nPreviousDiffXAbs,
nYCapped + 2 * nPreviousDiffYAbs,
GRID2_LVL_LINK,
FLAG_OPERATION_OR,
)
}
const nRand = sub_6FD80C10(
2 * nPreviousDiffX,
2 * nPreviousDiffY,
2 * nCurrentDiffX,
2 * nCurrentDiffY,
nLookupId,
)
if (nRand) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nCurrentX, nCurrentY, nRand, -1, false)
DRLGGRID_AlterGridFlag(pOutdoors.pGrid[2], nCurrentX, nCurrentY, GRID2_UNKB00, FLAG_OPERATION_OR)
}
pPreviousVertex = pDrlgVertex
pDrlgVertex = pDrlgVertex.pNext!
} while (pPreviousVertex !== pOutdoors.pVertex)
if (pLevel.nLevelId === LEVEL_ID_ACT5_BARRICADE_1) {
sub_6FD846C0(pLevel)
}
const nLastX = pOutdoors.nGridWidth - 2
const nLastY = pOutdoors.nGridHeight - 2
let nX = nLastX
let nY = 0
const nLevelPrestIdCliff =
pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS
? LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW
: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1
const nLevelPrestIdRavine =
pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS
? LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1_SNOW
: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1
while (nX !== 0 || nY !== nLastY) {
const nIndex = DRLGGRID_GetGridEntry(pOutdoors.pGrid[0], nX, nY) - nLevelPrestIdCliff
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, nIndex + nLevelPrestIdRavine, -1, false)
nX += 2 * stru_6FDD09C8[nIndex]![0]
nY += 2 * stru_6FDD09C8[nIndex]![1]
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
pOutdoors.nGridWidth - 2,
0,
pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS
? LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7_SNOW
: LVLPREST_ACT5_BARRICADE_CLIFF_RAVINE_BORDER_7,
-1,
false,
)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
0,
pOutdoors.nGridHeight - 2,
pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS
? LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5_SNOW
: LVLPREST_ACT5_BARRICADE_RAVINE_CLIFF_BORDER_5,
-1,
false,
)
DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits(pLevel)
DRLGOUTSIEGE_PlaceCaves(pLevel)
if (pLevel.nLevelId === LEVEL_ID_ACT5_BARRICADE_1) {
DRLGOUTSIEGE_ConnectBarricadeAndSiege(pLevel)
}
DRLGOUTSIEGE_AddACt5SecondaryBorder(pLevel)
DRLGOUTSIEGE_PlacePrisons(pLevel)
DRLGOUTSIEGE_PlaceSpecialPresets(pLevel)
}
}
interface D2DrlgOutSiegeInitStrc {
readonly nLevelId: number
readonly field_4: number
readonly field_8: number
readonly nLevelPrestId1: number
readonly nLevelPrestId2: number
}
const stru_6FDD0988: readonly D2DrlgOutSiegeInitStrc[] = [
{
nLevelId: LEVEL_ARREATPLATEAU,
field_4: 0,
field_8: 0,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
field_4: 0,
field_8: 1,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_FROM_CAVE_32X16_SNOW,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_FROM_CAVE_16X32_SNOW,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
field_4: 0,
field_8: 0,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_TO_CAVE_32X16_SNOW,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_TO_CAVE_16X32_SNOW,
},
]
// D2Common.0x6FD844F0
export function DRLGOUTSIEGE_PlaceCaves(pLevel: D2DrlgLevelStrc): void {
let nLevelPrestId = 0
let nX = 0
let nY = 0
for (let i = 0; i < stru_6FDD0988.length; i += 1) {
const entry = stru_6FDD0988[i]!
if (pLevel.nLevelId === entry.nLevelId) {
if (pLevel.pLevelCoords.nWidth <= pLevel.pLevelCoords.nHeight) {
nLevelPrestId = entry.nLevelPrestId1
nX = 2
if (entry.field_8) {
nY = pLevel.pOutdoors!.nGridHeight - 2
} else {
nY = 0
}
} else {
nLevelPrestId = entry.nLevelPrestId2
nY = 2
if (entry.field_8) {
nX = pLevel.pOutdoors!.nGridWidth - 2
} else {
nX = 0
}
}
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, nLevelPrestId, entry.field_4, false)
}
}
}
// D2Common.0x6FD84580
export function DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits(pLevel: D2DrlgLevelStrc): void {
const pOutdoors = pLevel.pOutdoors!
const nPickedFile = 2 * (pLevel.nLevelId === LEVEL_BLOODYFOOTHILLS ? 1 : 0) - 1
for (let i = 0; i < pOutdoors.nGridWidth; i += 1) {
if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, i, 0) & GRID2_LVL_LINK) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, i, 0, LVLPREST_ACT5_BARRICADE_EXIT_32X16, nPickedFile, false)
break
}
}
for (let i = 0; i < pOutdoors.nGridWidth; i += 1) {
if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, i, pOutdoors.nGridHeight - 2) & GRID2_LVL_LINK) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
i,
pOutdoors.nGridHeight - 2,
LVLPREST_ACT5_BARRICADE_ENTRANCE_32X16,
nPickedFile,
false,
)
break
}
}
for (let i = 0; i < pOutdoors.nGridHeight; i += 1) {
if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, 0, i) & GRID2_LVL_LINK) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, i, LVLPREST_ACT5_BARRICADE_EXIT_16X32, nPickedFile, false)
break
}
}
for (let i = 0; i < pOutdoors.nGridHeight; i += 1) {
if (DRLGOUTDOORS_GetPackedGrid2Info(pOutdoors, pOutdoors.nGridWidth - 2, i) & GRID2_LVL_LINK) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(
pLevel,
pOutdoors.nGridWidth - 2,
i,
LVLPREST_ACT5_BARRICADE_ENTRANCE_16X32,
nPickedFile,
false,
)
break
}
}
}
// D2Common.0x6FD846C0
export function sub_6FD846C0(pLevel: D2DrlgLevelStrc): void {
const pOutdoors = pLevel.pOutdoors!
DRLGGRID_AlterGridFlag(
pOutdoors.pGrid[2],
pOutdoors.nGridWidth - 2,
pOutdoors.nGridHeight - 4,
GRID2_LVL_LINK,
FLAG_OPERATION_OR,
)
DRLGGRID_AlterGridFlag(
pOutdoors.pGrid[2],
pOutdoors.nGridWidth - 2,
pOutdoors.nGridHeight - 3,
GRID2_LVL_LINK,
FLAG_OPERATION_OR,
)
}
// D2Common.0x6FD84700
export function DRLGOUTSIEGE_AddACt5SecondaryBorder(pLevel: D2DrlgLevelStrc): void {
if (pLevel.nLevelId !== LEVEL_BLOODYFOOTHILLS) {
const a1 = {
pLevel,
field_4: outdoorCoord(pLevel.pOutdoors!),
pGrid1: pLevel.pOutdoors!.pGrid[0],
pGrid2: pLevel.pOutdoors!.pGrid[2],
nLevelPrestId: 0,
field_14: -1,
nLvlSubId: LVLSUB_ACT5_BARRICADE,
field_1C: null,
field_20: null,
field_24: sub_6FD84820,
field_28: sub_6FD84780,
field_2C: DRLGOUTDOORS_AlterAdjacentPresetGridCells,
field_30: DRLGOUTDOORS_SetBlankGridCell,
field_34: DRLGOUTDOORS_SpawnOutdoorLevelPresetEx,
}
DRLGTILESUB_AddSecondaryBorder(a1)
}
}
interface D2DrlgOutSiegeInitStrc2 {
readonly nStyle: number
readonly field_4: number
readonly field_8: number
readonly nLevelPrestId1: number
readonly nLevelPrestId2: number
}
const stru_6FDD0A28: readonly D2DrlgOutSiegeInitStrc2[] = [
{ nStyle: 49, field_4: 1, field_8: 16, nLevelPrestId1: LVLPREST_ACT5_BARRICADE_1, nLevelPrestId2: LVLPREST_ACT5_BARRICADE_1_SNOW },
{ nStyle: 49, field_4: 31, field_8: 46, nLevelPrestId1: LVLPREST_ACT5_BARRICADE_1, nLevelPrestId2: LVLPREST_ACT5_BARRICADE_1_SNOW },
{
nStyle: 48,
field_4: 1,
field_8: 1,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_3_SNOW,
},
{
nStyle: 48,
field_4: 2,
field_8: 3,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_1_SNOW,
},
{
nStyle: 48,
field_4: 4,
field_8: 4,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_CLIFF_BORDER_4_SNOW,
},
{
nStyle: 48,
field_4: 5,
field_8: 5,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_3_SNOW,
},
{
nStyle: 48,
field_4: 6,
field_8: 7,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_1_SNOW,
},
{
nStyle: 48,
field_4: 8,
field_8: 8,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4_SNOW,
},
{ nStyle: 48, field_4: 30, field_8: 30, nLevelPrestId1: 0, nLevelPrestId2: 0 },
{ nStyle: 48, field_4: 31, field_8: 31, nLevelPrestId1: -5, nLevelPrestId2: -5 },
]
// D2Common.0x6FD84780
export function sub_6FD84780(pLevel: D2DrlgLevelStrc, nStyle: number, a3: number): number {
if (nStyle !== 48 && nStyle !== 49) {
throw new Error(`sub_6FD84780: unexpected nStyle=${nStyle}`)
}
for (let i = 0; i < stru_6FDD0A28.length; i += 1) {
const entry = stru_6FDD0A28[i]!
if (nStyle === entry.nStyle && a3 >= entry.field_4 && a3 <= entry.field_8) {
if (pLevel.nLevelId === LEVEL_TUNDRAWASTELANDS) {
return a3 + entry.nLevelPrestId2 - entry.field_4
} else {
return a3 + entry.nLevelPrestId1 - entry.field_4
}
}
}
throw new Error(`sub_6FD84780: unreachable nStyle=${nStyle} a3=${a3}`)
}
// D2Common.0x6FD84820
export function sub_6FD84820(
pLevel: D2DrlgLevelStrc,
nX: number,
nY: number,
a4: number,
_a5: number,
a6: number,
): boolean {
const v8 = sub_6FD84780(pLevel, (a6 >>> 20) & 0x3f, (a6 >>> 8) & 0xff)
if (v8 === -5) {
return true
} else if (v8 === a4) {
return DRLGOUTDOORS_TestGridCellNonLvlLink(pLevel, nX, nY) !== 0
}
return false
}
interface D2DrlgOutSiegeInitStrc3 {
readonly nLevelId: number
readonly nLevelPrestId1: number
readonly nLevelPrestId2: number
readonly field_C: number
readonly field_10: number
readonly field_14: number
readonly bMustHave: boolean
}
const stru_6FDD0AF8: readonly D2DrlgOutSiegeInitStrc3[] = [
{
nLevelId: LEVEL_ID_ACT5_BARRICADE_1,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W,
field_C: 0,
field_10: 25,
field_14: 1,
bMustHave: true,
},
{
nLevelId: LEVEL_ARREATPLATEAU,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W,
field_C: 0,
field_10: 25,
field_14: 1,
bMustHave: true,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W,
field_C: 1,
field_10: 25,
field_14: 1,
bMustHave: true,
},
{
nLevelId: LEVEL_ARREATPLATEAU,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_WAYPOINT_DIRT,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_WAYPOINT_DIRT,
field_C: -1,
field_10: 25,
field_14: 1,
bMustHave: true,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_WAYPOINT_SNOW,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_WAYPOINT_SNOW,
field_C: -1,
field_10: 25,
field_14: 1,
bMustHave: true,
},
{
nLevelId: LEVEL_ID_ACT5_BARRICADE_1,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RUINS_N_TREASURE,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RUINS_W_TREASURE,
field_C: -1,
field_10: 10,
field_14: 1,
bMustHave: false,
},
{
nLevelId: LEVEL_ID_ACT5_BARRICADE_1,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RUINS_N_1,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RUINS_W_1,
field_C: -1,
field_10: 20,
field_14: 4,
bMustHave: false,
},
{
nLevelId: LEVEL_ID_ACT5_BARRICADE_1,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_RUINS_N_2,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_RUINS_W_2,
field_C: -1,
field_10: 20,
field_14: 4,
bMustHave: false,
},
{
nLevelId: LEVEL_ARREATPLATEAU,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_FILLER_TREASURE,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_FILLER_TREASURE,
field_C: -1,
field_10: 10,
field_14: 1,
bMustHave: false,
},
{
nLevelId: LEVEL_ARREATPLATEAU,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_BUILDING,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_BUILDING,
field_C: -1,
field_10: 8,
field_14: 1,
bMustHave: false,
},
{
nLevelId: LEVEL_ARREATPLATEAU,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_FILLER,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_FILLER,
field_C: -1,
field_10: 15,
field_14: 5,
bMustHave: false,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_1,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_1,
field_C: -1,
field_10: 9,
field_14: 4,
bMustHave: false,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_2,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_LAKE_2,
field_C: -1,
field_10: 9,
field_14: 4,
bMustHave: false,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_OTHER,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_OTHER,
field_C: -1,
field_10: 9,
field_14: 4,
bMustHave: false,
},
{
nLevelId: LEVEL_TUNDRAWASTELANDS,
nLevelPrestId1: LVLPREST_ACT5_BARRICADE_SNOW_TREASURE,
nLevelPrestId2: LVLPREST_ACT5_BARRICADE_SNOW_TREASURE,
field_C: -1,
field_10: 5,
field_14: 3,
bMustHave: false,
},
]
// D2Common.0x6FD84870
export function DRLGOUTSIEGE_PlaceSpecialPresets(pLevel: D2DrlgLevelStrc): void {
for (let i = 0; i < stru_6FDD0AF8.length; i += 1) {
const entry = stru_6FDD0AF8[i]!
if (pLevel.nLevelId === entry.nLevelId) {
const nLevelPrestId =
pLevel.pLevelCoords.nWidth < pLevel.pLevelCoords.nHeight ? entry.nLevelPrestId1 : entry.nLevelPrestId2
let bAdded = false
for (let j = 0; j < entry.field_14; j += 1) {
bAdded = DRLGOUTDOORS_SpawnOutdoorLevelPreset(pLevel, nLevelPrestId, entry.field_C, 0, 15)
}
if (!bAdded && entry.bMustHave) {
throw new Error(`DRLGOUTSIEGE_PlaceSpecialPresets: failed to place required preset ${nLevelPrestId}`)
}
}
}
}
// D2Common.0x6FD84910
export function DRLGOUTSIEGE_PlacePrisons(pLevel: D2DrlgLevelStrc): void {
let nPrisonsPlaced = 0
const nPrisonsToPlace = 3
if (pLevel.nLevelId === LEVEL_ID_ACT5_BARRICADE_1) {
const pOutdoors = pLevel.pOutdoors!
for (let nAttemptNumber = 0; nAttemptNumber < 90 && nPrisonsPlaced < nPrisonsToPlace; nAttemptNumber += 1) {
const nRandX = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridWidth, 2)) | 0)
const nRandY = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridHeight, 2)) | 0)
const nLevelPrestId = DRLGOUTDOORS_GetPresetIndexFromGridCell(pLevel, nRandX, nRandY)
if (nLevelPrestId >= LVLPREST_ACT5_BARRICADE_1 && nLevelPrestId <= LVLPREST_ACT5_BARRICADE_8) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nRandX, nRandY, nLevelPrestId + 16, -1, false)
nPrisonsPlaced += 1
}
}
const nRandX = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridWidth, 2)) | 0)
const nRandY = 2 * (SEED_RollLimitedRandomNumber(pLevel.pSeed, idiv(pOutdoors.nGridHeight, 2)) | 0)
for (let j = 0; j < pOutdoors.nGridHeight && nPrisonsPlaced < nPrisonsToPlace; j += 1) {
for (let i = 0; i < pOutdoors.nGridWidth && nPrisonsPlaced < nPrisonsToPlace; i += 1) {
const nX = imod(i + nRandX, pOutdoors.nGridWidth)
const nY = imod(j + nRandY, pOutdoors.nGridHeight)
const nLevelPrestId = DRLGOUTDOORS_GetPresetIndexFromGridCell(pLevel, nX, nY)
if (nLevelPrestId >= LVLPREST_ACT5_BARRICADE_1 && nLevelPrestId <= LVLPREST_ACT5_BARRICADE_8) {
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, nLevelPrestId + 16, -1, false)
nPrisonsPlaced += 1
}
}
}
}
}
// D2Common.0x6FD84BB0
export function DRLGOUTSIEGE_ConnectBarricadeAndSiege(pLevel: D2DrlgLevelStrc): void {
const pOutdoors = pLevel.pOutdoors!
const pLvlPrestTxtRecord = DATATBLS_GetLvlPrestTxtRecord(pLevel.pDrlg.env.tables, LVLPREST_ACT5_BARRICADE_TO_SIEGE)
const nX = pOutdoors.nGridWidth - idiv(pLvlPrestTxtRecord.nSizeX, 8)
const nY = pOutdoors.nGridHeight - idiv(pLvlPrestTxtRecord.nSizeY, 8)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY, LVLPREST_ACT5_BARRICADE_TO_SIEGE, -1, false)
DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, nX, nY - 2, LVLPREST_ACT5_BARRICADE_RAVINE_BORDER_4, -1, false)
}

View File

@ -11,6 +11,7 @@
*/
import { SEED_RollLimitedRandomNumber, SEED_RollRandomNumber, type D2SeedStrc } from '../d2-rng.ts'
import { DRLGANIM_AllocAnimationTileGrids, DRLGANIM_TestLoadAnimatedRoomTiles } from './drlg-anim.ts'
import {
DATATBLS_GetLvlPrestTxtRecord,
DATATBLS_GetLvlPrestTxtRecordFromLevelId,
@ -1142,11 +1143,6 @@ export function DRLGPRESET_InitPresetRoomGrids(pDrlgRoom: D2DrlgRoomStrc): void
}
}
// DrlgDrlgAnim.cpp is not ported: no Act I LvlPrest.txt row sets Animate (the call sites throw instead).
function DRLGANIM_NotPorted(szFunction: string): never {
throw new Error(`${szFunction} is not ported (LvlPrest.txt Animate is set)`)
}
//D2Common.0x6FD87130
export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): void {
DRLGROOMTILE_AllocTileGrid(pDrlgRoom)
@ -1198,7 +1194,7 @@ export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): voi
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pFloorGrid[i]!, bCheckCoordinatesValidity, bKillEdgeX, bKillEdgeY)
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
DRLGANIM_TestLoadAnimatedRoomTiles(pDrlgRoom, pMaze.pFloorGrid[i]!, null, 0, bKillEdgeX, bKillEdgeY)
}
}
@ -1207,14 +1203,21 @@ export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): voi
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pWallGrid[i]!, false, bKillEdgeX, bKillEdgeY)
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
DRLGANIM_TestLoadAnimatedRoomTiles(
pDrlgRoom,
pMaze.pWallGrid[i]!,
pMaze.pTileTypeGrid[i]!,
TILETYPE_FLOOR,
bKillEdgeX,
bKillEdgeY,
)
}
}
DRLGROOMTILE_CountAllTileTypes(pDrlgRoom, pMaze.pCellGrid, false, bKillEdgeX, bKillEdgeY)
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_TestLoadAnimatedRoomTiles')
DRLGANIM_TestLoadAnimatedRoomTiles(pDrlgRoom, pMaze.pCellGrid, null, TILETYPE_SHADOW, bKillEdgeX, bKillEdgeY)
}
if (bTownOrGraveyard) {
@ -1239,7 +1242,15 @@ export function DRLGPRESET_AddPresetRoomMapTiles(pDrlgRoom: D2DrlgRoomStrc): voi
}
if (pMazeMap.pLvlPrestTxtRecord.dwAnimate) {
DRLGANIM_NotPorted('DRLGANIM_AllocAnimationTileGrids')
DRLGANIM_AllocAnimationTileGrids(
pDrlgRoom,
pMazeMap.pLvlPrestTxtRecord.nAnimSpeed,
pMaze.pWallGrid,
pFile.nWallLayers,
pMaze.pFloorGrid,
pFile.nFloorLayers,
pMaze.pCellGrid,
)
}
const pTileGrid = tileGridOf(pDrlgRoom)

View File

@ -470,40 +470,12 @@ export function sub_6FD77BB0(pDrlgRoom: D2DrlgRoomStrc): void {
pDrlgRoom.ppRoomsNear = ppRoomList
pDrlgRoom.nRoomsNear = nNearRooms
for (let pOrth = pDrlgRoom.pDrlgOrth; pOrth; pOrth = pOrth.pNext) {
if (pOrth.bPreset) {
const pLevel = pOrth.pLevel
if (pLevel) {
for (let pNearRoom = pLevel.pFirstRoomEx; pNearRoom; pNearRoom = pNearRoom.pDrlgRoomNext) {
let nX = pDrlgRoom.pDrlgCoord.nPosX - pNearRoom.pDrlgCoord.nWidth - pNearRoom.pDrlgCoord.nPosX
if (nX < 0) {
nX = pNearRoom.pDrlgCoord.nPosX - (pDrlgRoom.pDrlgCoord.nPosX + pDrlgRoom.pDrlgCoord.nWidth)
}
let nY = pDrlgRoom.pDrlgCoord.nPosY - pNearRoom.pDrlgCoord.nHeight - pNearRoom.pDrlgCoord.nPosY
if (nY < 0) {
nY = pNearRoom.pDrlgCoord.nPosY - (pDrlgRoom.pDrlgCoord.nPosY + pDrlgRoom.pDrlgCoord.nHeight)
}
if (nX < 0) nX = 0
if (nY < 0) nY = 0
if (nX < 6 && nY < 6) {
pDrlgRoom.ppRoomsNear.push(pNearRoom)
pDrlgRoom.nRoomsNear += 1
DRLGROOM_SortRoomListByPosition(pDrlgRoom.ppRoomsNear, pDrlgRoom.nRoomsNear)
}
}
}
}
}
if (pDrlgRoom.dwFlags & DRLGROOMFLAG_HAS_WARP_MASK) {
let nFlags = DRLGROOMFLAG_HAS_WARP_0
let nWarpId = 0
do {
if (nFlags & pDrlgRoom.dwFlags) {
if (nFlags & pDrlgRoom.dwFlags && pDrlgRoom.pLevel.nLevelId !== 133) {
const pSourceVisArray = DRLGROOM_GetVisArrayFromLevelId(pDrlg, pDrlgRoom.pLevel.nLevelId)
const pLevel = DRLG_GetLevel(pDrlg, pSourceVisArray[nWarpId]!)
const pDestinationVisArray = DRLGROOM_GetVisArrayFromLevelId(pDrlg, pSourceVisArray[nWarpId]!)
@ -513,11 +485,22 @@ export function sub_6FD77BB0(pDrlgRoom: D2DrlgRoomStrc): void {
}
if (nWarpDestination !== -1) {
let nSourceVisCount = 0
for (let i = 0; i <= nWarpId; i += 1) {
if (pSourceVisArray[i] === pLevel.nLevelId) {
nSourceVisCount += 1
}
}
let nDestinationVisCount = 0
for (let i = 0; i < 8; i += 1) {
if (pDestinationVisArray[i] === pDrlgRoom.pLevel.nLevelId) {
nDestinationVisCount += 1
}
if (nSourceVisCount === nDestinationVisCount) {
const pNearRoom = pLevel.pFirstRoomEx
const nWarpDest = DRLGWARP_GetWarpDestinationFromArray(pLevel, i)
if (nWarpDest === nWarpId && sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDestination)) {
if (sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDestination)) {
bSkip = true
break
}
@ -529,8 +512,7 @@ export function sub_6FD77BB0(pDrlgRoom: D2DrlgRoomStrc): void {
for (let i = 0; i < 8; i += 1) {
if (pDestinationVisArray[i] === pDrlgRoom.pLevel.nLevelId) {
const pNearRoom = pLevel.pFirstRoomEx
const nWarpDest = DRLGWARP_GetWarpDestinationFromArray(pLevel, i)
if (sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDest)) {
if (sub_6FD77F00(pDrlgRoom, nWarpId, pNearRoom, i, nWarpDestination)) {
break
}
}

View File

@ -274,10 +274,9 @@ function DUNGEON_GameTileToClientCoords(nX: number, nY: number): { nX: number; n
return { nX: Math.imul(80, (nX - nY) | 0), nY: (Math.imul(80, (nX + nY) | 0) / 2) | 0 }
}
// MONSTERS_ValidateMonsterId (D2Common Monsters.cpp) needs the MonStats table logic, which the DRLG port
// does not include; the native oracle stubs it as a hard failure too. Only reached for Act V levels.
function MONSTERS_ValidateMonsterId(nMonsterId: number): number {
throw new Error(`MONSTERS_ValidateMonsterId(${nMonsterId}) is not ported (Act V tile preset units)`)
// MONSTERS_ValidateMonsterId (D2Common Monsters.cpp)
function MONSTERS_ValidateMonsterId(pDrlgRoom: D2DrlgRoomStrc, nMonsterId: number): number {
return nMonsterId >= 0 && nMonsterId < pDrlgRoom.pLevel.pDrlg.env.tables.nMonStatsTxtRecordCount ? nMonsterId : -1
}
// ---------------------------------------------------------------------------------------------
@ -649,7 +648,7 @@ export function DRLGROOMTILE_AddTilePresetUnits(
DRLGROOM_AllocPresetUnit(
pDrlgRoom,
UNIT_MONSTER,
MONSTERS_ValidateMonsterId(nUnitId),
MONSTERS_ValidateMonsterId(pDrlgRoom, nUnitId),
MONMODE_NEUTRAL,
nPosX,
nPosY,
@ -936,8 +935,10 @@ export function DRLGROOMTILE_LoadInitRoomTiles(
pTileCache,
nTileType,
)
// D2_VERSION_HAS_UBERS is not defined for 1.13c.
if (nTileType === TILETYPE_WALL_RIGHT_EXIT || nTileType === TILETYPE_WALL_LEFT_EXIT) {
if (
(nTileType === TILETYPE_WALL_RIGHT_EXIT || nTileType === TILETYPE_WALL_LEFT_EXIT) &&
pDrlgRoom.pLevel.nLevelId !== 133
) {
DRLGROOMTILE_LoadWallWarpTiles(pDrlgRoom, pWallTileData, nTileInformation, nTileType)
}
}
@ -1243,7 +1244,7 @@ const nRemapIndices: readonly number[] = [
3, // [TILETYPE_WALL_TOP_RIGHT ]
4, // [TILETYPE_WALL_BOTTOM_LEFT ]
5, // [TILETYPE_WALL_BOTTOM_RIGHT ]
-2, -2, -1, -1, -1, -2, -1, -1, -1, -1, -1,
-2, -2, -1, -1, -1, -2, -1, -1, -1, -1, -1, 0, 0,
]
//D2Common.0x6FDD1390
@ -1270,7 +1271,7 @@ export function DRLGROOMTILE_LinkedTileDataManager(
): void {
const nTileInformation = nPackedTileInformation >>> 0
if (nTileType < 0 || nTileType >= nRemapIndices.length) {
throw new RangeError(`DRLGROOMTILE_LinkedTileDataManager: nRemapIndices[${nTileType}] is outside the 19-entry table`)
throw new RangeError(`DRLGROOMTILE_LinkedTileDataManager: nRemapIndices[${nTileType}] is outside the table`)
}
const v10 = nRemapIndices[nTileType]!

View File

@ -144,7 +144,7 @@ export const FLAG_OPERATION_OVERWRITE_IF_ZERO = 4
export const FLAG_OPERATION_AND_NEGATED = 5
export type FlagOperation = 0 | 1 | 2 | 3 | 4 | 5
/** D2C_Levels (DataTbls/LevelsIds.h), the ids the Act I DRLG code refers to. */
/** D2C_Levels (DataTbls/LevelsIds.h). */
export const LEVEL_NONE = 0
export const LEVEL_ROGUEENCAMPMENT = 1
export const LEVEL_BLOODMOOR = 2
@ -166,14 +166,122 @@ export const LEVEL_BURIALGROUNDS = 17
export const LEVEL_CRYPT = 18
export const LEVEL_MAUSOLEUM = 19
export const LEVEL_FORGOTTENTOWER = 20
export const LEVEL_TOWERCELLARLEV1 = 21
export const LEVEL_TOWERCELLARLEV2 = 22
export const LEVEL_TOWERCELLARLEV3 = 23
export const LEVEL_TOWERCELLARLEV4 = 24
export const LEVEL_TOWERCELLARLEV5 = 25
export const LEVEL_MONASTERYGATE = 26
export const LEVEL_OUTERCLOISTER = 27
export const LEVEL_BARRACKS = 28
export const LEVEL_JAILLEV1 = 29
export const LEVEL_JAILLEV2 = 30
export const LEVEL_JAILLEV3 = 31
export const LEVEL_INNERCLOISTER = 32
export const LEVEL_CATHEDRAL = 33
export const LEVEL_CATACOMBSLEV1 = 34
export const LEVEL_CATACOMBSLEV2 = 35
export const LEVEL_CATACOMBSLEV3 = 36
export const LEVEL_CATACOMBSLEV4 = 37
export const LEVEL_TRISTRAM = 38
export const LEVEL_MOOMOOFARM = 39
export const LEVEL_LUTGHOLEIN = 40
export const LEVEL_ROCKYWASTE = 41
export const LEVEL_DRYHILLS = 42
export const LEVEL_FAROASIS = 43
export const LEVEL_LOSTCITY = 44
export const LEVEL_VALLEYOFSNAKES = 45
export const LEVEL_CANYONOFTHEMAGI = 46
export const LEVEL_SEWERSLEV1 = 47
export const LEVEL_SEWERSLEV2 = 48
export const LEVEL_SEWERSLEV3 = 49
export const LEVEL_HAREMLEV1 = 50
export const LEVEL_HAREMLEV2 = 51
export const LEVEL_PALACECELLARLEV1 = 52
export const LEVEL_PALACECELLARLEV2 = 53
export const LEVEL_PALACECELLARLEV3 = 54
export const LEVEL_STONYTOMBLEV1 = 55
export const LEVEL_HALLSOFTHEDEADLEV1 = 56
export const LEVEL_HALLSOFTHEDEADLEV2 = 57
export const LEVEL_CLAWVIPERTEMPLELEV1 = 58
export const LEVEL_STONYTOMBLEV2 = 59
export const LEVEL_HALLSOFTHEDEADLEV3 = 60
export const LEVEL_CLAWVIPERTEMPLELEV2 = 61
export const LEVEL_MAGGOTLAIRLEV1 = 62
export const LEVEL_MAGGOTLAIRLEV2 = 63
export const LEVEL_MAGGOTLAIRLEV3 = 64
export const LEVEL_ANCIENTTUNNELS = 65
export const LEVEL_TALRASHASTOMB1 = 66
export const LEVEL_TALRASHASTOMB2 = 67
export const LEVEL_TALRASHASTOMB3 = 68
export const LEVEL_TALRASHASTOMB4 = 69
export const LEVEL_TALRASHASTOMB5 = 70
export const LEVEL_TALRASHASTOMB6 = 71
export const LEVEL_TALRASHASTOMB7 = 72
export const LEVEL_DURIELSLAIR = 73
export const LEVEL_ARCANESANCTUARY = 74
export const LEVEL_KURASTDOCKTOWN = 75
export const LEVEL_SPIDERFOREST = 76
export const LEVEL_GREATMARSH = 77
export const LEVEL_FLAYERJUNGLE = 78
export const LEVEL_LOWERKURAST = 79
export const LEVEL_KURASTBAZAAR = 80
export const LEVEL_UPPERKURAST = 81
export const LEVEL_KURASTCAUSEWAY = 82
export const LEVEL_TRAVINCAL = 83
export const LEVEL_SPIDERCAVE = 84
export const LEVEL_SPIDERCAVERN = 85
export const LEVEL_SWAMPYPITLEV1 = 86
export const LEVEL_SWAMPYPITLEV2 = 87
export const LEVEL_FLAYERDUNGEONLEV1 = 88
export const LEVEL_FLAYERDUNGEONLEV2 = 89
export const LEVEL_SWAMPYPITLEV3 = 90
export const LEVEL_FLAYERDUNGEONLEV3 = 91
export const LEVEL_SEWERSA3LEV1 = 92
export const LEVEL_SEWERSA3LEV2 = 93
export const LEVEL_RUINEDTEMPLE = 94
export const LEVEL_DISUSEDFANE = 95
export const LEVEL_FORGOTTENRELIQUARY = 96
export const LEVEL_FORGOTTENTEMPLE = 97
export const LEVEL_RUINEDFANE = 98
export const LEVEL_DISUSEDRELIQUARY = 99
export const LEVEL_DURANCEOFHATELEV1 = 100
export const LEVEL_DURANCEOFHATELEV2 = 101
export const LEVEL_DURANCEOFHATELEV3 = 102
export const LEVEL_THEPANDEMONIUMFORTRESS = 103
export const LEVEL_OUTERSTEPPES = 104
export const LEVEL_PLAINSOFDESPAIR = 105
export const LEVEL_CITYOFTHEDAMNED = 106
export const LEVEL_RIVEROFFLAME = 107
export const LEVEL_CHAOSSANCTUM = 108
export const LEVEL_HARROGATH = 109
export const LEVEL_BLOODYFOOTHILLS = 110
export const LEVEL_ID_ACT5_BARRICADE_1 = 111
export const LEVEL_ARREATPLATEAU = 112
export const LEVEL_CRYSTALIZEDCAVERNLEV1 = 113
export const LEVEL_CELLAROFPITY = 114
export const LEVEL_CRYSTALIZEDCAVERNLEV2 = 115
export const LEVEL_ECHOCHAMBER = 116
export const LEVEL_TUNDRAWASTELANDS = 117
export const LEVEL_GLACIALCAVESLEV1 = 118
export const LEVEL_GLACIALCAVESLEV2 = 119
export const LEVEL_ROCKYSUMMIT = 120
export const LEVEL_NIHLATHAKSTEMPLE = 121
export const LEVEL_HALLSOFANGUISH = 122
export const LEVEL_HALLSOFDEATHSCALLING = 123
export const LEVEL_HALLSOFVAUGHT = 124
export const LEVEL_HELL1 = 125
export const LEVEL_HELL2 = 126
export const LEVEL_HELL3 = 127
export const LEVEL_THEWORLDSTONEKEEPLEV1 = 128
export const LEVEL_THEWORLDSTONEKEEPLEV2 = 129
export const LEVEL_THEWORLDSTONEKEEPLEV3 = 130
export const LEVEL_THRONEOFDESTRUCTION = 131
export const LEVEL_THEWORLDSTONECHAMBER = 132
export const LEVEL_PANDEMONIUMRUN1 = 133
export const LEVEL_PANDEMONIUMRUN2 = 134
export const LEVEL_PANDEMONIUMRUN3 = 135
export const LEVEL_PANDEMONIUMFINALE = 136
// ---------------------------------------------------------------------------------------------
// Structures
@ -591,6 +699,8 @@ export interface D2DrlgLevelStrc {
/** 0x21C: file round robin of DRLGOUTDOORS_SpawnOutdoorLevelPresetEx (nPickedFile == -1). */
pBuild: D2DrlgBuildStrc | null
pPresetMaps: Int32Array | null
pJungleDefs: Int32Array | null
nJungleDefs: number
pNextLevel: D2DrlgLevelStrc | null
}

View File

@ -254,11 +254,12 @@ export function placeNamedPiece(
const distMap = bfsDistances(ctx.level)
// 2. Phase 1: In-place replacement
const wantedMask = plainSides !== undefined && plainSides !== '' ? sideMaskFromToken(plainSides) : null
const effectivePlainSides = plainSides !== undefined && plainSides !== '' ? plainSides : token
const wantedMask = effectivePlainSides !== '' ? sideMaskFromToken(effectivePlainSides) : null
const candidates = ctx.level.rooms.filter(r => {
if (r.hasDs1) return false
if (wantedMask !== null && roomSideMask(r) !== wantedMask) return false
return true
if (wantedMask !== null) return roomSideMask(r) === wantedMask
return r.orths.length <= 1
})
if (candidates.length > 0) {
@ -288,19 +289,50 @@ export function placeNamedPiece(
return isExit ? db - da : da - db
})
const primaryDir = options.direction ?? DIRECTION_NORTH
const GROW_DIR_FOR_SIDE: Readonly<Record<string, number>> = {
N: DIRECTION_SOUTH,
E: DIRECTION_WEST,
S: DIRECTION_NORTH,
W: DIRECTION_EAST,
NS: DIRECTION_NORTH,
EW: DIRECTION_WEST,
}
const SIDE_FOR_GROW_DIR: Readonly<Record<number, string>> = {
[DIRECTION_SOUTH]: 'N',
[DIRECTION_WEST]: 'E',
[DIRECTION_NORTH]: 'S',
[DIRECTION_EAST]: 'W',
}
const primaryDir = options.direction ?? GROW_DIR_FOR_SIDE[token] ?? DIRECTION_NORTH
const tryDirs = [primaryDir, (primaryDir + 1) % 4, (primaryDir + 2) % 4, (primaryDir + 3) % 4]
for (const parent of parents) {
for (const dir of tryDirs) {
const resolvePieceForSide = (dirSide: string): MazePiece | undefined => {
if (dirSide === token) return piece
if (namePrefix !== undefined) {
const fullName = `${namePrefix} ${dirSide}`
const byPrefix = ctx.pieces.find(p => p.name === fullName)
?? ctx.pieces.find(p => p.name.toLowerCase() === fullName.toLowerCase())
if (byPrefix !== undefined) return byPrefix
}
const bySpecial = findSpecial(ctx.pieces, kind, dirSide)
if (bySpecial !== undefined) return bySpecial
if (token === '' || (token.length > 1 && token.includes(dirSide))) return piece
return undefined
}
for (const dir of tryDirs) {
const dirSide = SIDE_FOR_GROW_DIR[dir] ?? token
const dirPiece = resolvePieceForSide(dirSide) ?? piece
for (const parent of parents) {
const wasHasDs1 = parent.hasDs1
parent.hasDs1 = false
const created = addAdjacentMazeRoom(ctx.level, parent, dir, false, true, ctx.index, ctx.stats)
if (created !== null) {
created.piece = piece
created.piece = dirPiece
created.variant = 0
created.hasDs1 = true
noteSpecial(created, piece, token, ctx.stats)
noteSpecial(created, dirPiece, dirPiece.sides || dirSide, ctx.stats)
return created
}
parent.hasDs1 = wasHasDs1
@ -765,8 +797,13 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
preferFurthest: true,
})
let wpSide: string | undefined
if (levelId === 123) {
const wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)]!
let wpIdx = rng.int(0, ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length - 1)
if (ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[wpIdx] === downSide) {
wpIdx = (wpIdx + 1) % ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING.length
}
wpSide = ACT5_TEMPLE_HARDCODED_PRESETS_REMAPPING[wpIdx]!
placeNamedPiece(ctx, {
kind: 'waypoint',
name: `Act 5 - Temple ${wpSide} Waypoint`,
@ -776,13 +813,15 @@ export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
})
}
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 5 - Temple SE up',
sides: 'SE',
plainSides: 'SE',
preferFurthest: false,
})
if (wpSide !== 'SE') {
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 5 - Temple SE up',
sides: 'SE',
plainSides: 'SE',
preferFurthest: false,
})
}
}
}

View File

@ -1838,11 +1838,12 @@ function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceI
room.variant = variant
// The table's direction is the direction *from the new room to the entrance*,
// so the new room sits on the opposite side.
const { dx, dy } = step(direction, level.first.width, level.first.height)
const opposite = (direction - 2) & 3
const { dx, dy } = step(opposite, level.first.width, level.first.height)
room.x = level.first.x + dx
room.y = level.first.y + dy
if (!checkRoomNotOverlapping(level, room, level.first)) continue
linkRooms(level.first, room, direction)
linkRooms(level.first, room, opposite)
level.rooms.unshift(room)
pickRoomPreset(level.first, index, stats)
const piece = pieces.find(candidate => candidate.name === name)
@ -2386,6 +2387,19 @@ function stamp(level: MazeLevel, stats: MazeStats, killEdge: boolean, effectiveA
for (let x = 0; x < spanX; x += 1) {
const cell = source[x]
if (cell === undefined) continue
if (x >= room.width || y >= room.height) {
const isOverhangLavaOrBlank =
cell.walls.every(w => w.prop1 === 0)
&& cell.floors.every(f => f.hidden || f.prop1 === 0 || (f.style === 20 && (f.unknown1 & 8) !== 0))
const existing = target[originX + x]
if (
isOverhangLavaOrBlank
&& existing !== undefined
&& (existing.floors.some(f => !f.hidden && f.prop1 !== 0) || existing.walls.some(w => w.prop1 !== 0))
) {
continue
}
}
// 1.13c filters the wall list while loading rather than while drawing:
// hidden door and exit tiles are markers that spawn a unit or register a
// warp, and their artwork is never emitted.

View File

@ -398,6 +398,17 @@ export const MONSTER_ART_MAP: Record<string, MonsterArtSpec> = {
"mephistospirit": { token: "M6", weapon: "hth" },
"meshif1": { token: "MS", weapon: "hth" },
"meshif2": { token: "M3", weapon: "hth" },
"minion1": { token: "XX", weapon: "hth" },
"minion2": { token: "XX", weapon: "hth" },
"minion3": { token: "XX", weapon: "hth" },
"minion4": { token: "XX", weapon: "hth" },
"minion5": { token: "XX", weapon: "hth" },
"minion6": { token: "XX", weapon: "hth" },
"minion7": { token: "XX", weapon: "hth" },
"minion8": { token: "XX", weapon: "hth" },
"minion9": { token: "XX", weapon: "hth" },
"minion10": { token: "XX", weapon: "hth" },
"minion11": { token: "XX", weapon: "hth" },
"minionspawner1": { token: "XA", weapon: "hth" },
"minionspawner2": { token: "XA", weapon: "hth" },
"minionspawner3": { token: "XA", weapon: "hth" },
@ -742,7 +753,7 @@ export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
2: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "PW", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "PW", weapon: "1ht" }, { token: "BN", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "SD", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "SK", weapon: "1hs" }, { token: "SK", weapon: "bow" }, { token: "BB", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "WR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "GM", weapon: "2hs" }],
3: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "BB", weapon: "hth" }, { token: "MO", weapon: "hth" }, { token: "TH", weapon: "hth" }, { token: "FE", weapon: "1hs" }, { token: "FC", weapon: "hth" }, { token: "BK", weapon: "hth" }, { token: "FD", weapon: "hth" }, { token: "FW", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "VD", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "ZZ", weapon: "hth" }, { token: "SW", weapon: "hth" }, { token: "ZP", weapon: "hth" }, { token: "SR", weapon: "hth" }, { token: "VA", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "MM", weapon: "hth" }, { token: "FK", weapon: "1hs" }, { token: "WR", weapon: "hth" }, { token: "GY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "SD", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "CR", weapon: "bow" }, { token: "PN", weapon: "hth" }],
4: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "SL", weapon: "hth" }, { token: "VM", weapon: "hth" }, { token: "FR", weapon: "hth" }, { token: "CS", weapon: "hth" }, { token: "UM", weapon: "fgt" }, { token: "DM", weapon: "hth" }, { token: "WW", weapon: "hth" }, { token: "BH", weapon: "hth" }, { token: "BT", weapon: "hth" }, { token: "UM", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "PN", weapon: "hth" }, { token: "UM", weapon: "mgk" }],
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }],
5: [{ token: "FA", weapon: "hth" }, { token: "ZM", weapon: "hth" }, { token: "SI", weapon: "hth" }, { token: "YE", weapon: "hth" }, { token: "SK", weapon: "hth" }, { token: "CW", weapon: "hth" }, { token: "M5", weapon: "hth" }, { token: "OS", weapon: "hth" }, { token: "IP", weapon: "hth" }, { token: "SK", weapon: "bow" }, { token: "BH", weapon: "hth" }, { token: "OX", weapon: "hth" }, { token: "F0", weapon: "hth" }, { token: "0B", weapon: "hth" }, { token: "L3", weapon: "hth" }, { token: "IO", weapon: "hth" }, { token: "RE", weapon: "hth" }, { token: "0D", weapon: "hth" }, { token: "0F", weapon: "hth" }, { token: "0E", weapon: "hth" }, { token: "45", weapon: "hth" }, { token: "0C", weapon: "hth" }, { token: "XY", weapon: "hth" }, { token: "CR", weapon: "1hs" }, { token: "CR", weapon: "bow" }, { token: "CR", weapon: "2ht" }, { token: "SW", weapon: "hth" }, { token: "SM", weapon: "hth" }, { token: "SP", weapon: "hth" }, { token: "SC", weapon: "hth" }, { token: "UM", weapon: "mgk" }, { token: "DM", weapon: "hth" }, { token: "XX", weapon: "hth" }],
}
/**
@ -751,13 +762,13 @@ export const ACT_MONSTER_SPECS: Record<number, readonly MonsterArtSpec[]> = {
export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
const lower = id.toLowerCase()
const direct = MONSTER_ART_MAP[lower]
if (direct && direct.token !== 'XX' && direct.token.trim() !== '') {
if (direct && direct.token.trim() !== '') {
return direct
}
const base = lower.replace(/\d+$/, '')
if (base !== lower) {
const baseSpec = MONSTER_ART_MAP[base]
if (baseSpec && baseSpec.token !== 'XX' && baseSpec.token.trim() !== '') {
if (baseSpec && baseSpec.token.trim() !== '') {
return baseSpec
}
}

View File

@ -506,9 +506,16 @@ export function resolveDs1Object(
if (objectType !== OBJECT_TYPE_OBJECT) {
if (objectType === OBJECT_TYPE_MONSTER && monsters !== undefined) {
const presets = monsters.presetPlaceByAct.get(String(act))
if (presets !== undefined && objectId >= 0 && objectId < presets.length) {
const place = presets[objectId]!
const statRow = monsters.statsById.get(place)
let place = presets !== undefined && objectId >= 0 && objectId < presets.length ? presets[objectId] : undefined
let statRow = place !== undefined ? monsters.statsById.get(place) : undefined
if (statRow === undefined && objectId >= 1000) {
const targetHcIdx = String(objectId - 1000)
statRow = monsters.stats.rows.find(r => cell(monsters.stats, r, 'hcIdx').trim() === targetHcIdx)
if (statRow !== undefined && place === undefined) {
place = cell(monsters.stats, statRow, 'Id').trim()
}
}
if (place !== undefined) {
// `Code` is spelled inconsistently in the shipped table — `K9` on one row,
// `k9` on another, `ja` lower-cased — while the art directories are indexed
// upper-cased. Canonicalise here so one monster is never emitted under two

File diff suppressed because it is too large Load Diff

View File

@ -1,713 +0,0 @@
/**
* Diablo II Act 3 Jungle River Network & Branch Topology Algorithm (`DRLG_GenerateJungles`).
*
* In Diablo II, the vast outdoor jungle zones of Act 3 — Spider Forest (76), Great Marsh (77),
* and Flayer Jungle (78) — are procedurally assembled from a macro-grid of 32x32 presets.
* Rather than a trivial static two-column strip, the authentic generation engine builds:
*
* 1. Meandering River Network:
* - River channels that wind across multiple columns rather than staying in a fixed channel.
* - Flow direction tracking to select authentic directional river presets (`NS`, `EW`, `NE`, `NW`, `SE`, `SW`).
* 2. River Forks / Junctions:
* - Branches splitting into secondary tributary channels using T-junction and cross presets (`NSE`, `NSW`, `NEW`, `SEW`, `NSEW`).
* 3. River Island Clearings:
* - Clearings bordered by river channels on multiple sides (e.g. 2 or more orthogonal sides), forming defensible enclaves.
* 4. Wooden Bridges:
* - Wooden bridge crossings (`Act 3 - Bridge`) spanning river channels to connect island clearings and opposite banks.
* 5. Branching Multi-Zone Topology:
* - Models the non-linear progression between Spider Forest (76), Great Marsh (77), and Flayer Jungle (78).
* - Reproduces the iconic "Great Marsh Skip": in ~75% of seeds, Spider Forest connects directly to Flayer Jungle,
* allowing Great Marsh to be an optional side branch (or dead-end swamp). In the remaining seeds, Great Marsh
* serves as the mandatory throughway.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, PlacedPresetRect, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
/** Connection between two Act 3 zones. */
export interface JungleZoneConnection {
readonly fromLevelId: number
readonly toLevelId: number
readonly fromZone: string
readonly toZone: string
readonly direction: 'north' | 'south' | 'east' | 'west'
readonly connectionType: 'main' | 'branch' | 'skip'
readonly gatePosition?: { readonly x: number; readonly y: number }
}
/** Global 3-zone topological progression graph. */
export interface JungleTopology {
readonly seed: number
readonly zones: readonly {
readonly levelId: number
readonly name: string
readonly isDeadEnd: boolean
}[]
readonly connections: readonly JungleZoneConnection[]
readonly hasMarshSkip: boolean
readonly marshIsDeadEnd: boolean
}
/** River network layout statistics. */
export interface JungleRiverNetwork {
readonly meanderingPath: readonly { readonly col: number; readonly row: number }[]
readonly meanderCount: number
readonly forks: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly islands: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly bridges: readonly { readonly col: number; readonly row: number; readonly x: number; readonly y: number; readonly pieceName: string }[]
readonly clearings: readonly { readonly col: number; readonly row: number; readonly pieceName: string; readonly isIsland: boolean }[]
}
/** Complete layout statistics for an Act 3 Jungle level. */
export interface JungleLayoutStats {
readonly levelId: number
readonly levelName: string
readonly gridColumns: number
readonly gridRows: number
readonly riverNetwork: JungleRiverNetwork
readonly topology: JungleTopology
}
/** Macro-grid cell types. */
export type JungleCellType = 'river' | 'clearing' | 'head' | 'tail'
/** Macro-grid cell representation. */
interface MacroGridCell {
col: number
row: number
type: JungleCellType
flowDirs: Set<'N' | 'S' | 'E' | 'W'>
isFork?: boolean
isIsland?: boolean
pieceName?: string
}
/**
* Builds the authentic Act 3 Jungle multi-zone branching topology.
*
* In Diablo II:
* - Spider Forest (76) begins at Kurast Docks (75).
* - Spider Forest always connects to Great Marsh (77).
* - In ~75% of world seeds, Spider Forest also connects directly to Flayer Jungle (78) (the Great Marsh skip).
* - When the skip is active, Great Marsh can either be a dead-end swamp (~50%) or loop back into Flayer Jungle.
* - When the skip is inactive, Great Marsh is guaranteed to connect forward to Flayer Jungle (ensuring reachability).
*/
export function buildJungleTopology(seed: number): JungleTopology {
const rng = new Rng(seed).fork('jungle-topology')
// ~75% chance for direct Spider Forest -> Flayer Jungle skip
const hasMarshSkip = rng.int(0, 3) !== 0
// If no skip, Great Marsh must connect to Flayer Jungle. If skip exists, Great Marsh can be dead-end.
const marshIsDeadEnd = hasMarshSkip && rng.chance(0.5)
const connections: JungleZoneConnection[] = [
{
fromLevelId: 75,
toLevelId: 76,
fromZone: 'Kurast Docks',
toZone: 'Spider Forest',
direction: 'south',
connectionType: 'main',
},
{
fromLevelId: 76,
toLevelId: 77,
fromZone: 'Spider Forest',
toZone: 'Great Marsh',
direction: 'east',
connectionType: 'branch',
},
]
if (hasMarshSkip) {
connections.push({
fromLevelId: 76,
toLevelId: 78,
fromZone: 'Spider Forest',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: 'skip',
})
}
if (!marshIsDeadEnd) {
connections.push({
fromLevelId: 77,
toLevelId: 78,
fromZone: 'Great Marsh',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: hasMarshSkip ? 'branch' : 'main',
})
}
connections.push({
fromLevelId: 78,
toLevelId: 79,
fromZone: 'Flayer Jungle',
toZone: 'Lower Kurast',
direction: 'north',
connectionType: 'main',
})
const zones = [
{ levelId: 76, name: 'Spider Forest', isDeadEnd: false },
{ levelId: 77, name: 'Great Marsh', isDeadEnd: marshIsDeadEnd },
{ levelId: 78, name: 'Flayer Jungle', isDeadEnd: false },
]
return {
seed,
zones,
connections,
hasMarshSkip,
marshIsDeadEnd,
}
}
/**
* Finds the most suitable directional river piece from available pieces.
*/
function pickRiverPiece(
pieces: readonly WildernessPiece[],
flowDirs: Set<'N' | 'S' | 'E' | 'W'>,
rng: Rng,
): WildernessPiece | null {
const riverPieces = pieces.filter(
p =>
p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0,
)
if (riverPieces.length === 0) return null
const requiredTokens = Array.from(flowDirs)
const tokenStr = requiredTokens.sort().join('')
// 1. Exact match on token (e.g. "Act 3 - Jungle NS", "Act 3 - Jungle SW", etc.)
const exactMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
const tokens = rest.split(/\s+/)
return tokens.some(t => t.toUpperCase() === tokenStr)
})
if (exactMatches.length > 0) return exactMatches[rng.int(0, exactMatches.length - 1)]!
// 2. Subset / superset match containing all required directions
const supersetMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
return requiredTokens.every(d => rest.includes(d))
})
if (supersetMatches.length > 0) return supersetMatches[rng.int(0, supersetMatches.length - 1)]!
// 3. Fallback to any river piece
return riverPieces[rng.int(0, riverPieces.length - 1)]!
}
/**
* Finds the most suitable clearing piece matching the Act 3 sub-theme.
*/
function pickClearingPiece(
pieces: readonly WildernessPiece[],
levelId: number,
preferredOpening: 'E' | 'W' | 'N' | 'S' | null,
rng: Rng,
): WildernessPiece | null {
const prefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
let pool = pieces.filter(p => p.name.startsWith(prefix) && p.levels.length > 0)
if (pool.length === 0) {
pool = pieces.filter(p => p.name.startsWith('Act 3 - Clearing') && p.levels.length > 0)
}
if (pool.length === 0) return null
if (preferredOpening) {
const openingMatches = pool.filter(p => p.name.includes(preferredOpening))
if (openingMatches.length > 0) return openingMatches[rng.int(0, openingMatches.length - 1)]!
}
return pool[rng.int(0, pool.length - 1)]!
}
/**
* Stamps wooden bridge floor tiles directly across the water channel.
*/
function synthesizeBridgePlanks(
canvas: Canvas,
bridgeX: number,
bridgeY: number,
orientation: 'horizontal' | 'vertical',
bridgeDs1?: Ds1,
): void {
const makeBridgeFloor = (): Ds1Floor => ({
prop1: 2,
sequence: 0,
style: 4,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// A 4-tile wide plank walkway bridging across the 32-tile river channel
if (orientation === 'horizontal') {
for (let dy = 14; dy <= 17; dy += 1) {
for (let dx = 0; dx < 32; dx += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) {
const ds1Cell = bridgeDs1.cells[dy]?.[dx]
const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1
if (hasPresetWall || hasPresetFloor) {
continue
}
}
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
} else {
for (let dx = 14; dx <= 17; dx += 1) {
for (let dy = 0; dy < 32; dy += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
if (bridgeDs1 && dx < bridgeDs1.width && dy < bridgeDs1.height) {
const ds1Cell = bridgeDs1.cells[dy]?.[dx]
const hasPresetWall = ds1Cell?.walls && ds1Cell.walls.length > 0 && !ds1Cell.walls[0]?.hidden
const hasPresetFloor = ds1Cell?.floors && ds1Cell.floors.length > 0 && !ds1Cell.floors[0]?.hidden && ds1Cell.floors[0]?.style !== 1
if (hasPresetWall || hasPresetFloor) {
continue
}
}
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
}
}
/**
* Measure what the jungle pass actually painted, cell by cell.
*
* The counters used to be literals (`junglePathCells: 100`), so a pass that
* stamped nothing — no clearing pieces in the MPQ, a canvas too small for the
* macro-grid — still reported a healthy-looking jungle. Counting the finished
* canvas means an empty level reports zeros and the gap is visible.
*
* A cell counts as painted when it carries a visible, non-blank floor. Bridge
* planks (style 4, prop1 2) are skipped here: they are decks laid *over* the
* water and are reported as bridges, not as water or as ground.
*
* @param canvas - the level being stamped.
* @param grid - the macro-grid that decided which 32x32 block is river.
* @returns the painted ground and water cell counts.
*/
function measureJungleTopography(
canvas: Canvas,
grid: readonly (readonly MacroGridCell[])[],
): { canalCells: number; junglePathCells: number } {
let canalCells = 0
let junglePathCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
const gridRow = grid[Math.floor(y / 32)]
if (gridRow === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell === undefined) continue
const floor = cell.floors.find(
f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0),
)
if (floor === undefined) continue
if (floor.style === 4 && floor.prop1 === 2) continue
const gridCell = gridRow[Math.floor(x / 32)]
if (gridCell === undefined) continue
if (gridCell.type === 'river') canalCells += 1
else junglePathCells += 1
}
}
return { canalCells, junglePathCells }
}
/**
* `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm.
*
* Corresponds to Blizzard DRLGOUTJUNG_BuildJungle (D2Common.0x6FD7FC20).
*
* In Diablo II 1.13c / D2MOO, DRLGOUTJUNG_BuildJungle assembles the 32x32 macro-grid for
* Spider Forest (76), Great Marsh (77), and Flayer Jungle (78), stamping head/tail presets
* (LVLPREST_ACT3_JUNGLE_HEAD / LVLPREST_ACT3_JUNGLE_TAIL) and assigning river/clearing presets.
* This TypeScript implementation reconstructs that behavior with:
* - Meandering river paths wandering across columns.
* - River forks splitting into secondary tributary channels.
* - Island clearings surrounded by river channels.
* - Wooden bridges crossing river channels to connect trails and islands.
* - Multi-zone branching topology (Spider Forest 76, Great Marsh 77, Flayer Jungle 78).
*/
export function DRLG_GenerateJungles(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number,
placedRects?: PlacedPresetRect[],
): Set<string> {
const placed = new Set<string>()
const numCols = Math.max(2, Math.floor(canvas.width / 32))
const numRows = Math.max(3, Math.floor(canvas.height / 32))
// 1. Build and record the 3-zone branching topology
const topology = buildJungleTopology(rng.seed)
stats.jungleTopology = topology
// 2. Initialize Macro-Grid
const grid: MacroGridCell[][] = []
for (let r = 0; r < numRows; r += 1) {
const row: MacroGridCell[] = []
for (let c = 0; c < numCols; c += 1) {
row.push({
col: c,
row: r,
type: 'clearing',
flowDirs: new Set(),
})
}
grid.push(row)
}
// Row 0 is Tail, Row numRows - 1 is Head
for (let c = 0; c < numCols; c += 1) {
grid[0]![c]!.type = 'tail'
grid[numRows - 1]![c]!.type = 'head'
}
// 3. Generate Meandering River Path from Head (row numRows - 1) to Tail (row 0)
// Kurast River Head and Tail naturally connect to the East column (col 1 in 2-col layout)
const startCol = Math.min(1, numCols - 1)
const endCol = Math.min(1, numCols - 1)
let currentCol = startCol
const meanderingPath: { col: number; row: number }[] = []
meanderingPath.push({ col: currentCol, row: numRows - 1 })
grid[numRows - 1]![currentCol]!.flowDirs.add('N')
let meanderCount = 0
// Target row to initiate column meander
const primaryMeanderRow = Math.max(1, Math.floor((numRows - 1) / 2))
for (let r = numRows - 2; r >= 1; r -= 1) {
let nextCol = currentCol
// Meander to adjacent column when at target row or on random roll
if (r === primaryMeanderRow || (r === primaryMeanderRow + 1 && rng.chance(0.6))) {
if (numCols === 2) {
nextCol = 1 - currentCol
} else {
const step = rng.chance(0.5) ? -1 : 1
nextCol = Math.max(0, Math.min(numCols - 1, currentCol + step))
}
}
if (nextCol !== currentCol) {
meanderCount += 1
// Connect directional flows between old and new columns
if (nextCol < currentCol) {
grid[r]![currentCol]!.flowDirs.add('W')
grid[r]![nextCol]!.flowDirs.add('E')
} else {
grid[r]![currentCol]!.flowDirs.add('E')
grid[r]![nextCol]!.flowDirs.add('W')
}
currentCol = nextCol
}
grid[r]![currentCol]!.type = 'river'
grid[r]![currentCol]!.flowDirs.add('S')
grid[r]![currentCol]!.flowDirs.add('N')
meanderingPath.push({ col: currentCol, row: r })
}
// Ensure tail connection
if (currentCol !== endCol) {
meanderCount += 1
if (endCol < currentCol) {
grid[1]![currentCol]!.flowDirs.add('W')
grid[1]![endCol]!.flowDirs.add('E')
} else {
grid[1]![currentCol]!.flowDirs.add('E')
grid[1]![endCol]!.flowDirs.add('W')
}
currentCol = endCol
}
grid[0]![endCol]!.flowDirs.add('S')
meanderingPath.push({ col: endCol, row: 0 })
// 4. Generate River Fork / Tributary Branch
const forkRow = Math.max(1, Math.min(numRows - 2, primaryMeanderRow))
const mainCellAtFork = grid[forkRow]![meanderingPath.find(p => p.row === forkRow)?.col ?? 0]!
const forkCol = numCols === 2 ? 1 - mainCellAtFork.col : (mainCellAtFork.col === 0 ? 1 : mainCellAtFork.col - 1)
const forks: { col: number; row: number; pieceName?: string }[] = []
mainCellAtFork.isFork = true
if (forkCol < mainCellAtFork.col) {
mainCellAtFork.flowDirs.add('W')
grid[forkRow]![forkCol]!.flowDirs.add('E')
} else {
mainCellAtFork.flowDirs.add('E')
grid[forkRow]![forkCol]!.flowDirs.add('W')
}
grid[forkRow]![forkCol]!.type = 'river'
grid[forkRow]![forkCol]!.flowDirs.add('N')
grid[forkRow]![forkCol]!.flowDirs.add('S')
forks.push({ col: mainCellAtFork.col, row: forkRow })
// 5. Identify River Island Clearings
// Any clearing cell flanked by river on 2 or more orthogonal sides is an island clearing
const islands: { col: number; row: number; pieceName?: string }[] = []
const clearings: { col: number; row: number; pieceName: string; isIsland: boolean }[] = []
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
if (cell.type !== 'clearing') continue
let riverNeighbors = 0
if (c > 0 && grid[r]![c - 1]!.type === 'river') riverNeighbors += 1
if (c < numCols - 1 && grid[r]![c + 1]!.type === 'river') riverNeighbors += 1
if (r > 0 && grid[r - 1]![c]!.type === 'river') riverNeighbors += 1
if (r < numRows - 1 && grid[r + 1]![c]!.type === 'river') riverNeighbors += 1
if (riverNeighbors >= 2) {
cell.isIsland = true
islands.push({ col: c, row: r })
}
}
}
// Ensure at least one island clearing exists whenever river forks
if (islands.length === 0 && numRows >= 4) {
const candidateR = forkRow === 1 ? 2 : 1
const candidateC = numCols === 2 ? 1 - grid[candidateR]![0]!.col : 1
const candidateCell = grid[candidateR]?.[candidateC]
if (candidateCell && candidateCell.type === 'clearing') {
candidateCell.isIsland = true
islands.push({ col: candidateC, row: candidateR })
}
}
// 6. Wooden Bridges Crossing River Channels
const bridges: { col: number; row: number; x: number; y: number; pieceName: string }[] = []
const bridgePiece = pieces.find(p => p.name === 'Act 3 - Bridge')
// Bridge placement for island clearings
for (const island of islands) {
// Find adjacent river cell to span across
let bridgeCol = island.col
let bridgeRow = island.row
let orientation: 'horizontal' | 'vertical' = 'horizontal'
if (island.col > 0 && grid[island.row]![island.col - 1]!.type === 'river') {
bridgeCol = island.col - 1
orientation = 'horizontal'
} else if (island.col < numCols - 1 && grid[island.row]![island.col + 1]!.type === 'river') {
bridgeCol = island.col + 1
orientation = 'horizontal'
} else if (island.row > 0 && grid[island.row - 1]![island.col]!.type === 'river') {
bridgeRow = island.row - 1
orientation = 'vertical'
} else if (island.row < numRows - 1 && grid[island.row + 1]![island.col]!.type === 'river') {
bridgeRow = island.row + 1
orientation = 'vertical'
}
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
const bDs1 = bridgePiece.levels[0]
const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2))
const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2))
stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY)
placedRects?.push({ x: bx + offsetX, y: by + offsetY, w: bDs1.width, h: bDs1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, bx, by, orientation, bridgePiece?.levels[0])
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// Place at least one bridge across the main meandering channel
if (bridges.length === 0) {
const bridgeCol = meanderingPath[Math.floor(meanderingPath.length / 2)]?.col ?? 1
const bridgeRow = Math.floor(numRows / 2)
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
const bDs1 = bridgePiece.levels[0]
stampDs1Fn(canvas, bDs1, bx, by)
placedRects?.push({ x: bx, y: by, w: bDs1.width, h: bDs1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, bx, by, 'horizontal', bridgePiece?.levels[0])
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// 7. Stamp Jungle Head and Tail
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1Fn(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x: 0, y: 0, w: tailPiece.levels[0].width, h: tailPiece.levels[0].height, name: tailPiece.name })
}
if (headPiece?.levels[0]) {
stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x: 0, y: (numRows - 1) * 32, w: headPiece.levels[0].width, h: headPiece.levels[0].height, name: headPiece.name })
}
// 8. Stamp Interior River and Clearing Presets
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
const x = c * 32
const y = r * 32
if (cell.type === 'river') {
const riverPiece = pickRiverPiece(pieces, cell.flowDirs, rng)
if (riverPiece?.levels[0]) {
const ds1 = riverPiece.levels[rng.int(0, riverPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = riverPiece.name
placed.add(riverPiece.name)
stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: riverPiece.name })
}
} else if (cell.type === 'clearing') {
const preferredOpening =
c < numCols - 1 && grid[r]![c + 1]!.type === 'river'
? 'E'
: c > 0 && grid[r]![c - 1]!.type === 'river'
? 'W'
: null
const clearingPiece = pickClearingPiece(pieces, levelId, preferredOpening, rng)
if (clearingPiece?.levels[0]) {
const ds1 = clearingPiece.levels[rng.int(0, clearingPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = clearingPiece.name
placed.add(clearingPiece.name)
stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects?.push({ x, y, w: ds1.width, h: ds1.height, name: clearingPiece.name })
clearings.push({
col: c,
row: r,
pieceName: clearingPiece.name,
isIsland: cell.isIsland ?? false,
})
}
}
}
}
// 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity
for (const b of bridges) {
if (bridgePiece?.levels[0]) {
const ds1 = bridgePiece.levels[0]
stampDs1Fn(canvas, ds1, b.x, b.y)
placedRects?.push({ x: b.x, y: b.y, w: ds1.width, h: ds1.height, name: bridgePiece.name })
}
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal', bridgePiece?.levels[0])
}
// 10. Record Layout Statistics
const riverNetwork: JungleRiverNetwork = {
meanderingPath,
meanderCount,
forks,
islands,
bridges,
clearings,
}
const levelName =
levelId === 76 ? 'Spider Forest' : levelId === 77 ? 'Great Marsh' : 'Flayer Jungle'
stats.jungleLayout = {
levelId,
levelName,
gridColumns: numCols,
gridRows: numRows,
riverNetwork,
topology,
}
// Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography
let canonicalCrossings = 0
for (const by of [78, 142]) {
let plankCells = 0
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) {
const row = canvas.cells[y]
if (row?.[x]) {
;(row[x]!.floors as Ds1Floor[])[0] = {
prop1: 2,
sequence: 0,
style: 4,
unknown1: 0,
unknown2: 0,
hidden: false,
}
plankCells += 1
}
}
}
}
// A crossing off the bottom of a short canvas paints nothing, so it is not
// a crossing. Counting the attempt is how `riverBridges` used to claim 2.
if (plankCells > 0) canonicalCrossings += 1
}
const measured = measureJungleTopography(canvas, grid)
stats.act3Topography = {
// Causeways are a Kurast feature; the jungle pass carves none, and 0 here
// is a measurement, not a placeholder.
causewayCells: 0,
riverBridges: bridges.length + canonicalCrossings,
canalCells: measured.canalCells,
junglePathCells: measured.junglePathCells,
}
stats.notes.push(
`Act 3 Jungle Topography: ${String(bridges.length + canonicalCrossings)} river bridges, ` +
`${String(measured.junglePathCells)} jungle ground cells, ${String(measured.canalCells)} river water cells`,
)
stats.bridgesPlaced = bridges.length
stats.riverIslands = islands.length
stats.riverForks = forks.length
stats.meanderCount = meanderCount
return placed
}

View File

@ -1,522 +0,0 @@
/**
* Act 5 Bloody Foothills (Level 110, Act 5 - Siege 1) dynamic siege generation.
*
* Replaces static linear strip assembly with dynamic DRLGOUTSIEGE-style siege
* progression:
* 1. Sequential 16x48 strip placement supporting dynamic variant and level selection.
* 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels.
* 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 10/11)
* and traversable breach openings.
* 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts.
* 5. Crumbled stone fort ruins interspersed between siege strips.
* 6. Progressive frontline fortification density from Harrogath exit (East, x = 160..240, low)
* towards Shenk the Overseer / Arreat Plateau barricade (West, x = 0..80, high).
*/
import type { Ds1, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, WildernessPiece, WildernessStats } from './wilderness.ts'
import { stampDs1, emptyWall } from './wilderness.ts'
/** Canonical strip names from West (Arreat Barricade) to East (Harrogath Town). */
export const ACT5_SIEGE_STRIP_NAMES: readonly string[] = [
'Act 5 - Siege To Barricade',
'Act 5 - Siege Strip 13',
'Act 5 - Siege Strip 12',
'Act 5 - Siege Strip 11',
'Act 5 - Siege Strip 10',
'Act 5 - Siege Strip 9',
'Act 5 - Siege Strip 8',
'Act 5 - Siege Strip 7',
'Act 5 - Siege Strip 6',
'Act 5 - Siege Strip 5',
'Act 5 - Siege Strip 4',
'Act 5 - Siege Strip 3',
'Act 5 - Siege Strip 2',
'Act 5 - Siege Strip 1',
'Act 5 - Siege To Town',
]
/** Metrics and telemetry for dynamic siege battlefield generation. */
export interface SiegeTopographyStats {
readonly stripCount: number
readonly trenchCells: number
readonly barricadeWalls: number
readonly catapultPlatforms: number
readonly fortRuins: number
readonly frontlineDensity: {
readonly westFrontlineObstacles: number
readonly midFieldObstacles: number
readonly eastRearObstacles: number
}
}
/** Set floor tile at (x, y) safely within canvas bounds. */
function setFloor(canvas: Canvas, x: number, y: number, floor: Ds1Floor): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const row = canvas.cells[y]
if (!row) return
const cell = row[x]
if (!cell) return
const targetFloors = cell.floors as Ds1Floor[]
if (targetFloors.length > 0) {
targetFloors[0] = floor
} else {
targetFloors.push(floor)
}
}
/** Set wall tile at (x, y) safely within canvas bounds. */
function setWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const row = canvas.cells[y]
if (!row) return
const cell = row[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
}
/** Clear all wall layers at (x, y) to guarantee traversability. */
function clearCellWalls(canvas: Canvas, x: number, y: number): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const row = canvas.cells[y]
if (!row) return
const cell = row[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
for (let i = 0; i < targetWalls.length; i += 1) {
targetWalls[i] = emptyWall()
}
}
/**
* Stamp a dugout battlefield trench (style: 5, prop1: 194).
* Clears walls in the trench path to guarantee traversable channels.
*/
function stampTrench(
canvas: Canvas,
startX: number,
startY: number,
length: number,
orientation: 'horizontal' | 'vertical',
rng: Rng,
): number {
let cellsStamped = 0
const width = 2
if (orientation === 'vertical') {
for (let dy = 0; dy < length; dy += 1) {
const y = startY + dy
if (y < 6 || y > canvas.height - 6) continue
for (let dx = 0; dx < width; dx += 1) {
const x = startX + dx
if (x < 4 || x >= canvas.width - 4) continue
setFloor(canvas, x, y, {
style: 5,
sequence: rng.int(0, 5),
prop1: 194,
unknown1: 0,
unknown2: 0,
hidden: false,
})
clearCellWalls(canvas, x, y)
cellsStamped += 1
}
}
} else {
for (let dx = 0; dx < length; dx += 1) {
const x = startX + dx
if (x < 4 || x >= canvas.width - 4) continue
for (let dy = 0; dy < width; dy += 1) {
const y = startY + dy
if (y < 6 || y > canvas.height - 6) continue
setFloor(canvas, x, y, {
style: 5,
sequence: rng.int(0, 5),
prop1: 194,
unknown1: 0,
unknown2: 0,
hidden: false,
})
clearCellWalls(canvas, x, y)
cellsStamped += 1
}
}
}
return cellsStamped
}
/**
* Stamp a breached wooden barricade palisade (style: 2, prop1: 129).
* Features splintered end-caps (sequence: 10 or 11) flanking a traversable breach opening.
*/
function stampBreachedBarricade(
canvas: Canvas,
x: number,
startY: number,
length: number,
breachY: number,
breachHeight: number,
rng: Rng,
): { wallCount: number; breachFloorCount: number } {
let wallCount = 0
let breachFloorCount = 0
if (x < 4 || x >= canvas.width - 4) return { wallCount: 0, breachFloorCount: 0 }
for (let dy = 0; dy < length; dy += 1) {
const y = startY + dy
if (y < 6 || y > canvas.height - 6) continue
if (y >= breachY && y < breachY + breachHeight) {
// Traversable breach gap: clear walls and trample floor
clearCellWalls(canvas, x, y)
setFloor(canvas, x, y, {
style: 5,
sequence: 0,
prop1: 194,
unknown1: 0,
unknown2: 0,
hidden: false,
})
breachFloorCount += 1
} else if (y === breachY - 1 || y === breachY + breachHeight) {
// Splintered end-cap post at the breach edge (fence.dt1 type 7 pillar, seq 10 or 11)
setWall(canvas, x, y, {
style: 2,
sequence: rng.int(10, 11),
prop1: 129,
type: 7,
unknown1: 0,
unknown2: 0,
hidden: false,
})
wallCount += 1
} else {
// Intact spiked wooden palisade (fence.dt1 type 1 left wall, seq 10)
setWall(canvas, x, y, {
style: 2,
sequence: 10,
prop1: 129,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
wallCount += 1
}
}
return { wallCount, breachFloorCount }
}
/**
* Stamp a raised catapult defense platform (style: 4, prop1: 2) with defensive parapets.
* Placed in North or South wings to keep the central traversal corridor open.
*/
function stampCatapultPlatform(
canvas: Canvas,
originX: number,
originY: number,
width: number,
height: number,
rng: Rng,
): void {
for (let dy = 0; dy < height; dy += 1) {
const y = originY + dy
if (y < 6 || y > canvas.height - 6) continue
for (let dx = 0; dx < width; dx += 1) {
const x = originX + dx
if (x < 4 || x >= canvas.width - 4) continue
// Raised timber/stone platform floor
setFloor(canvas, x, y, {
style: 4,
sequence: rng.int(0, 3),
prop1: 2,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// Parapet rampart along outer perimeter, leaving access opening on interior side
const isPerimeter = dx === 0 || dx === width - 1 || dy === 0 || dy === height - 1
const isAccessGap = (originY < 20 && dy === height - 1 && dx >= 1 && dx <= 2) ||
(originY > 26 && dy === 0 && dx >= 1 && dx <= 2)
if (isPerimeter && !isAccessGap) {
setWall(canvas, x, y, {
style: 2,
sequence: 10,
prop1: 2,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
}
}
}
}
/**
* Stamp crumbled stone fort ruins (style: 18) interspersed between strips.
* Features weathered stone flagstone floors and broken masonry walls.
*/
function stampFortRuin(
canvas: Canvas,
originX: number,
originY: number,
size: number,
rng: Rng,
): void {
for (let dy = 0; dy < size; dy += 1) {
const y = originY + dy
if (y < 6 || y > canvas.height - 6) continue
for (let dx = 0; dx < size; dx += 1) {
const x = originX + dx
if (x < 4 || x >= canvas.width - 4) continue
// Ruined camp flagstone floor (camp.dt1 style 18 floor)
setFloor(canvas, x, y, {
style: 18,
sequence: rng.int(0, 4),
prop1: 2,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// Weathered/crumbled perimeter wall with missing sections (camp.dt1 style 18 wall)
const isPerimeter = dx === 0 || dx === size - 1 || dy === 0 || dy === size - 1
if (isPerimeter && rng.next() > 0.35) {
setWall(canvas, x, y, {
style: 18,
sequence: rng.int(0, 2),
prop1: 129,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
}
}
}
}
/** Find and choose strip DS1 piece supporting dynamic variants and level variations. */
function selectStripPiece(
pieces: readonly WildernessPiece[],
targetName: string,
stripIndex: number,
rng: Rng,
): { piece: WildernessPiece; ds1: Ds1 } | null {
// 1. Direct exact name match
const exact = pieces.filter(p => p.name === targetName && p.levels.length > 0)
if (exact.length > 0) {
const chosen = exact[rng.int(0, exact.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
// 2. Prefix or variant matches (e.g. "Act 5 - Siege Strip 5 A")
const variantMatches = pieces.filter(
p => p.name.startsWith(targetName) && p.levels.length > 0,
)
if (variantMatches.length > 0) {
const chosen = variantMatches[rng.int(0, variantMatches.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
// 3. Match by strip number
if (stripIndex >= 1 && stripIndex <= 13) {
const stripNum = 14 - stripIndex
const pattern = new RegExp(`\\bStrip\\s*0*${stripNum}\\b`, 'i')
const byNum = pieces.filter(p => pattern.test(p.name) && p.levels.length > 0)
if (byNum.length > 0) {
const chosen = byNum[rng.int(0, byNum.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
}
// 4. Any piece matching the target name substring
const substringMatches = pieces.filter(
p => p.name.toLowerCase().includes(targetName.toLowerCase()) && p.levels.length > 0,
)
if (substringMatches.length > 0) {
const chosen = substringMatches[rng.int(0, substringMatches.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
return null
}
/**
* Assemble the 15 sequential 16x48 siege strips for Act 5 Bloody Foothills (Level 110)
* with dynamic strip variations, battlefield trenches, breached barricades,
* catapult defense platforms, and fort ruins with progressive frontline density.
*/
export function stampAct5SiegeStrips(
canvas: Canvas,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
if (!(rng instanceof Rng)) {
throw new Error('stampAct5SiegeStrips: missing required Rng parameter')
}
if (!stats) {
throw new Error('stampAct5SiegeStrips: missing required WildernessStats parameter')
}
const placed = new Set<string>()
// 1. Assemble 15 strips West (x=0) to East (x=224)
const dynamicStripStride = canvas.width > 0
? Math.floor(canvas.width / ACT5_SIEGE_STRIP_NAMES.length)
: 16
let currentX = 0
for (let i = 0; i < ACT5_SIEGE_STRIP_NAMES.length; i += 1) {
const targetName = ACT5_SIEGE_STRIP_NAMES[i]!
const selection = selectStripPiece(pieces, targetName, i, rng)
if (!selection) {
currentX += dynamicStripStride
continue
}
// DS1 tiles often include a +1 border overlap column (e.g. 17 width for 16 stride)
const ds1Width = selection.ds1.width
const stripStride = ds1Width > 0
? (ds1Width % 16 === 1 ? ds1Width - 1 : ds1Width)
: dynamicStripStride
stampDs1(canvas, selection.ds1, currentX, 0)
currentX += stripStride
placed.add(selection.piece.name)
stats.substitutions.push({
name: selection.piece.name,
role: 'object',
enabled: true,
clusters: 1,
})
}
// 2. Dynamic battlefield obstacle generation across 3 tactical zones:
// West Frontline (x = 0..80): Heavy fortification near Arreat Barricade / Shenk
// Mid Field (x = 80..160): Moderate fortifications in no-man's land
// East Rear (x = 160..240): Light defenses near Harrogath town gates
let totalTrenchCells = 0
let totalBarricadeWalls = 0
let catapultPlatformCount = 0
let fortRuinCount = 0
let westObstacles = 0
let midObstacles = 0
let eastObstacles = 0
// -------------------------------------------------------------------------
// Zone 1: West Frontline (x in [0, 80)) - High density (Shenk's stronghold)
// -------------------------------------------------------------------------
// Trenches (3 trenches)
totalTrenchCells += stampTrench(canvas, 18 + rng.int(-2, 2), 8 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 50 + rng.int(-2, 2), 28 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 34 + rng.int(-2, 2), 12 + rng.int(-1, 1), 7 + rng.int(-1, 1), 'horizontal', rng)
westObstacles += 3
// Breached Barricades (3 barricades)
const b1 = stampBreachedBarricade(canvas, 26 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng)
totalBarricadeWalls += b1.wallCount
totalTrenchCells += b1.breachFloorCount
const b2 = stampBreachedBarricade(canvas, 42 + rng.int(-2, 2), 8 + rng.int(-1, 1), 12, 13, 3, rng)
totalBarricadeWalls += b2.wallCount
totalTrenchCells += b2.breachFloorCount
const b3 = stampBreachedBarricade(canvas, 68 + rng.int(-2, 2), 26 + rng.int(-1, 1), 12, 31, 3, rng)
totalBarricadeWalls += b3.wallCount
totalTrenchCells += b3.breachFloorCount
westObstacles += 3
// Catapult Platforms (2 platforms)
stampCatapultPlatform(canvas, 12 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, 4, rng)
stampCatapultPlatform(canvas, 58 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, 4, rng)
catapultPlatformCount += 2
westObstacles += 2
// Fort Ruins (2 ruins)
stampFortRuin(canvas, 30 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, rng)
stampFortRuin(canvas, 64 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, rng)
fortRuinCount += 2
westObstacles += 2
// -------------------------------------------------------------------------
// Zone 2: Mid Field (x in [80, 160)) - Medium density (No-man's land)
// -------------------------------------------------------------------------
// Trenches (2 trenches)
totalTrenchCells += stampTrench(canvas, 98 + rng.int(-2, 2), 10 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 138 + rng.int(-2, 2), 28 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
midObstacles += 2
// Breached Barricades (2 barricades)
const b4 = stampBreachedBarricade(canvas, 114 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng)
totalBarricadeWalls += b4.wallCount
totalTrenchCells += b4.breachFloorCount
const b5 = stampBreachedBarricade(canvas, 146 + rng.int(-2, 2), 8 + rng.int(-1, 1), 12, 13, 3, rng)
totalBarricadeWalls += b5.wallCount
totalTrenchCells += b5.breachFloorCount
midObstacles += 2
// Catapult Platforms (1 platform)
stampCatapultPlatform(canvas, 86 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, 4, rng)
catapultPlatformCount += 1
midObstacles += 1
// Fort Ruins (1 ruin)
stampFortRuin(canvas, 124 + rng.int(-2, 2), 10 + rng.int(-1, 1), 4, rng)
fortRuinCount += 1
midObstacles += 1
// -------------------------------------------------------------------------
// Zone 3: East Rear (x in [160, 240)) - Low density (Harrogath outskirts)
// -------------------------------------------------------------------------
// Trenches (1 trench)
totalTrenchCells += stampTrench(canvas, 186 + rng.int(-2, 2), 12 + rng.int(-1, 1), 10 + rng.int(-1, 1), 'vertical', rng)
eastObstacles += 1
// Breached Barricades (1 barricade)
const b6 = stampBreachedBarricade(canvas, 202 + rng.int(-2, 2), 10 + rng.int(-1, 1), 24, 21 + rng.int(0, 1), 5, rng)
totalBarricadeWalls += b6.wallCount
totalTrenchCells += b6.breachFloorCount
eastObstacles += 1
// Catapult Platforms (0 platforms in rear reserve)
// Fort Ruins (1 ruin)
stampFortRuin(canvas, 170 + rng.int(-2, 2), 30 + rng.int(-1, 1), 4, rng)
fortRuinCount += 1
eastObstacles += 1
// Record metrics and frontline density gradient
const siegeStats: SiegeTopographyStats = {
stripCount: placed.size,
trenchCells: totalTrenchCells,
barricadeWalls: totalBarricadeWalls,
catapultPlatforms: catapultPlatformCount,
fortRuins: fortRuinCount,
frontlineDensity: {
westFrontlineObstacles: westObstacles,
midFieldObstacles: midObstacles,
eastRearObstacles: eastObstacles,
},
}
stats.siegeTopography = siegeStats
stats.proceduralWallCells = (stats.proceduralWallCells ?? 0) + totalBarricadeWalls
stats.proceduralRoadCells = (stats.proceduralRoadCells ?? 0) + totalTrenchCells
stats.notes.push('generated dynamic siege trenches and fortifications')
return placed
}

File diff suppressed because it is too large Load Diff

View File

@ -46,7 +46,6 @@
* {@link Rng} so the same seed replays the same world.
*/
import type { D2Table } from './acts.ts'
import { Rng } from './rng.ts'
/** How the player gets from one level to the next. */
export type LinkKind =
@ -211,11 +210,12 @@ export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [
// 78 Flayer Jungle, 79 Lower Kurast, 80 Kurast Bazaar, 81 Upper Kurast,
// 82 Kurast Causeway, 83 Travincal.
//
// Canonical throughway progression: 75 -> 76 -> 77 -> 78 -> 79.
// The procedural Great Marsh skip (76 -> 78) is handled dynamically in
// wilderness-jungles.ts; SEAMLESS_ADJACENCY defines the canonical planar throughway.
// DRLGOUTPLACE_InitAct3OutdoorLevel (D2Common.0x6FDD0130) attaches Flayer
// Jungle (78) either to Great Marsh (77, linear: 75->76->77->78->79) or
// directly to Spider Forest (76, branching: 76->77 dead-end and 76->78->79).
[75, 76],
[76, 77],
[76, 78],
[77, 78],
[78, 79],
[79, 80],
@ -240,39 +240,38 @@ export const SEAMLESS_ADJACENCY: readonly (readonly [number, number])[] = [
]
/**
* Seams whose side is fixed by a preset rather than chosen per seed.
* Seams involving a preset or maze level whose side is fixed by hand-authored
* DS1 geometry rather than chosen per seed by the outdoor DRLG placer.
*
* Everywhere else the outdoor placer re-picks which edge an opening sits on for
* every seed, so hard-coding a side would be stating a coincidence as a law.
* These are the exceptions: the opening is part of a hand-authored preset whose
* geometry cannot move.
*
* The two `Depend` values in the whole of `Levels.txt` corroborate two of them:
* `27` depends on `26` at offset `(0, -40)`, and `33` depends on `32` at
* `(-4, -34)` — in both cases the dependent level sits directly north.
* Outdoor-to-outdoor DRLG levels (`DrlgType === 3`) report their exact seam
* sides and spans per seed from `pLevelLink` in `buildDrlgLevelMap`. Preset
* (`DrlgType === 2`) and maze (`DrlgType === 1`) levels have fixed border
* openings (with single-seam towns like `TownN1`/`TownE1`/`TownW1` overriding
* their default side when a variant's border opening sits on another edge).
*
* Keyed `"lowId:highId"`; the value is the side belonging to the *lower* id.
*/
export const PINNED_SIDES: ReadonlyMap<string, Side> = new Map([
// The Rogue Encampment's gate faces south onto the Blood Moor.
export const PRESET_SEAM_SIDES: ReadonlyMap<string, Side> = new Map([
// Rogue Encampment default gate faces south (TownN1/E1/W1 override via inset 0 scan).
['1:2', 'south'],
// Tamoe Highland runs east into the Monastery Gate.
['7:26', 'east'],
// Tamoe Highland (7) is south of Monastery Gate (26, facade1.ds1): 7->26 is north, 26->7 is south.
['7:26', 'north'],
// Courtyard 1 sits 40 cells north of the Monastery Gate (`Depend = 26`).
['26:27', 'north'],
// The Barracks gateway continues north out of the Outer Cloister.
['27:28', 'north'],
// The Cathedral sits 34 cells north of the Inner Cloister (`Depend = 32`).
['32:33', 'north'],
// The Kurast Causeway is a 48x16 bridge: its openings are the short ends.
['81:82', 'east'],
['82:83', 'east'],
// Act 5: Harrogath gate faces west onto the Bloody Foothills.
// Lut Gholein default gate faces west (TownN1 overrides via inset 0 scan).
['40:41', 'west'],
// Kurast Docks opens north onto Spider Forest.
['75:76', 'north'],
// Pandemonium Fortress opens east onto Outer Steppes.
['103:104', 'east'],
// River of Flame (107) opens north onto Chaos Sanctuary (108).
['107:108', 'north'],
// Harrogath gate faces west onto Bloody Foothills.
['109:110', 'west'],
// Bloody Foothills marches west into the Frigid Highlands.
['110:111', 'west'],
// Frigid Highlands marches north into the Arreat Plateau.
['111:112', 'north'],
])
/** A hard-coded portal, stair or act transition. */
@ -436,8 +435,9 @@ function edgeKey(from: number, to: number): string {
* the coarser one — and every edge is emitted in both directions.
*
* @param rows - the output of {@link parseLevelRows}.
* @returns the graph, with `sideFrom`/`sideTo` still null; call
* {@link assignGateSides} to fill them.
* @returns the graph, with `sideFrom`/`sideTo` populated for fixed preset/maze
* seams ({@link PRESET_SEAM_SIDES}) and null on outdoor DRLG seams (which
* come from the DRLG level link per seed).
*/
export function buildWorldGraph(rows: readonly LevelRow[]): WorldGraph {
const levels = new Map<number, LevelRow>()
@ -494,7 +494,16 @@ export function buildWorldGraph(rows: readonly LevelRow[]): WorldGraph {
const key = edgeKey(from, to)
if (emitted.has(key)) return
emitted.add(key)
edges.push({ from, to, kind, warps, warpSlots, sideFrom: null, sideTo: null, source })
let sideFrom: Side | null = null
let sideTo: Side | null = null
if (kind === 'seamless') {
const presetSide = PRESET_SEAM_SIDES.get(pairKey(from, to))
if (presetSide !== undefined) {
sideFrom = from < to ? presetSide : oppositeSide(presetSide)
sideTo = oppositeSide(sideFrom)
}
}
edges.push({ from, to, kind, warps, warpSlots, sideFrom, sideTo, source })
}
// 1. Hard-coded outdoor stitching. Highest priority: where a pair is both
@ -553,110 +562,6 @@ export function seamlessPairs(graph: WorldGraph): (readonly [number, number])[]
return pairs
}
/**
* Choose which edge of each level every seam sits on.
*
* Diablo II re-picks these per seed — the documented constraint is that in the
* Cold Plains the Blood Moor entrance and the two exits may not share an edge —
* so this is a constraint solve, not a table. Two rules:
*
* 1. **Opposite sides.** If the seam leaves A heading east it must arrive on
* B's west edge, or the two rectangles do not abut.
* 2. **No sharing.** Two seams of the same level may not use the same edge, or
* the two neighbours would occupy the same strip of ground.
*
* Preset-anchored seams ({@link PINNED_SIDES}) are placed first and never moved.
* The rest are assigned greedily in a shuffled order, retrying with a fresh
* shuffle when the greedy pass paints itself into a corner; with a maximum
* degree of three this converges immediately, and the retry loop is there so a
* future adjacency addition fails loudly rather than silently sharing an edge.
*
* The choice is deliberately **not** made per pack variant. A level's three
* baked variants all share one set of gate sides, so any variant of A docks
* against any variant of B; only the interior differs.
*
* @param graph - the graph to annotate.
* @param seed - the act layout seed.
* @returns a copy of the graph with `sideFrom`/`sideTo` filled on every
* seamless edge.
* @throws when no assignment satisfies the constraints.
*/
export function assignGateSides(graph: WorldGraph, seed: number): WorldGraph {
const pairs = seamlessPairs(graph)
const maxAttempts = 64
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const rng = new Rng(seed).fork(`gate-sides:${String(attempt)}`)
/** level id -> the sides already spoken for. */
const used = new Map<number, Set<Side>>()
const taken = (level: number): Set<Side> => {
let set = used.get(level)
if (set === undefined) {
set = new Set<Side>()
used.set(level, set)
}
return set
}
const chosen = new Map<string, Side>()
let failed = false
// Pinned seams first: they cannot move, so everything else works around
// them rather than the other way round.
for (const [low, high] of pairs) {
const key = pairKey(low, high)
const pinned = PINNED_SIDES.get(key)
if (pinned === undefined) continue
const lowUsed = taken(low)
const highUsed = taken(high)
if (lowUsed.has(pinned) || highUsed.has(oppositeSide(pinned))) {
// Two pinned seams contradict each other; no shuffle can fix that.
throw new Error(`pinned sides conflict at ${key}`)
}
lowUsed.add(pinned)
highUsed.add(oppositeSide(pinned))
chosen.set(key, pinned)
}
// The rest, in a shuffled order so no level systematically gets first pick.
const free = pairs.filter(([low, high]) => !chosen.has(pairKey(low, high)))
for (let index = free.length - 1; index > 0; index -= 1) {
const swap = rng.int(0, index)
const hold = free[index]!
free[index] = free[swap]!
free[swap] = hold
}
for (const [low, high] of free) {
const lowUsed = taken(low)
const highUsed = taken(high)
const candidates = SIDES.filter(side => !lowUsed.has(side) && !highUsed.has(oppositeSide(side)))
const pick = rng.pick(candidates)
if (pick === undefined) {
failed = true
break
}
lowUsed.add(pick)
highUsed.add(oppositeSide(pick))
chosen.set(pairKey(low, high), pick)
}
if (failed) continue
const edges = graph.edges.map((edge): WorldEdge => {
if (edge.kind !== 'seamless') return edge
const low = Math.min(edge.from, edge.to)
const side = chosen.get(pairKey(edge.from, edge.to))
if (side === undefined) return edge
const sideFrom = edge.from === low ? side : oppositeSide(side)
return { ...edge, sideFrom, sideTo: oppositeSide(sideFrom) }
})
return { levels: graph.levels, edges, waypoints: graph.waypoints }
}
throw new Error(`could not assign gate sides after ${String(maxAttempts)} attempts`)
}
/**
* The edges leaving one level.
*
@ -829,6 +734,3 @@ export function isClickableWarp(warp: WarpGeometry): boolean {
return warp.selectDX > 0 && warp.selectDY > 0
}
export type { WorldOffset } from './world-placement.ts'
export { resolveWorldOffsets } from './world-placement.ts'

File diff suppressed because it is too large Load Diff

View File

@ -5806,6 +5806,8 @@ interface PackIndexEntry {
readonly ds1?: string
/** DRLG act variant of a generated level (see act-variants.ts). */
readonly actVariant?: number
/** DRLG parity fidelity tier of this level. */
readonly fidelity?: 'exact' | 'layout' | 'approximation'
}
/** Object frame inside an object atlas page. */
@ -5850,6 +5852,8 @@ interface PackSceneJson {
readonly levelId?: number
readonly levelName: string
readonly ds1: string
/** DRLG parity fidelity tier of this scene. */
readonly fidelity?: 'exact' | 'layout' | 'approximation'
readonly cellsX: number
readonly cellsY: number
readonly originX: number

View File

@ -1,321 +0,0 @@
import { describe, it, expect } from 'vitest'
import { SEAMLESS_ADJACENCY, PINNED_SIDES } from '../src/game/world-graph.ts'
import { resolveWorldOffsets, type WorldOffset, CANONICAL_LEVEL_ROWS } from '../src/game/world-placement.ts'
export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean {
return (
a.offsetX < b.offsetX + b.sizeX &&
a.offsetX + a.sizeX > b.offsetX &&
a.offsetY < b.offsetY + b.sizeY &&
a.offsetY + a.sizeY > b.offsetY
)
}
export interface BoundaryCheckResult {
readonly touching: boolean
readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none'
readonly sharedLength: number
readonly gapX: number
readonly gapY: number
}
export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult {
const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX
const overlapY = Math.max(
0,
Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY),
)
if (touchLR && overlapY > 0) {
return {
touching: true,
type: 'vertical-boundary',
sharedLength: overlapY,
gapX: 0,
gapY: 0,
}
}
const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY
const overlapX = Math.max(
0,
Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX),
)
if (touchTB && overlapX > 0) {
return {
touching: true,
type: 'horizontal-boundary',
sharedLength: overlapX,
gapX: 0,
gapY: 0,
}
}
const gapX = a.offsetX + a.sizeX <= b.offsetX
? b.offsetX - (a.offsetX + a.sizeX)
: b.offsetX + b.sizeX <= a.offsetX
? a.offsetX - (b.offsetX + b.sizeX)
: 0
const gapY = a.offsetY + a.sizeY <= b.offsetY
? b.offsetY - (a.offsetY + a.sizeY)
: b.offsetY + b.sizeY <= a.offsetY
? a.offsetY - (b.offsetY + b.sizeY)
: 0
return {
touching: false,
type: 'none',
sharedLength: 0,
gapX,
gapY,
}
}
// Generate 100 test seeds identical to standard suite
export function generateTestSeeds(): number[] {
const seedSet = new Set<number>([
0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999,
0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919,
])
let state = 0x8546291a
while (seedSet.size < 100) {
state = (state * 1664525 + 1013904223) >>> 0
seedSet.add(state)
}
return Array.from(seedSet)
}
const TEST_SEEDS = generateTestSeeds()
describe('Adversarial Challenger M3 Gen2: World Placement & Graph Topology', () => {
it('Property 1: 100% boundary sharing (sharedLength > 0, zero gaps) across all 100 seeds for all contiguous pairs in SEAMLESS_ADJACENCY', () => {
let totalChecks = 0
let passedChecks = 0
const failureList: string[] = []
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
for (const [aId, bId] of actSeamless) {
totalChecks++
const a = offsets.get(aId)!
const b = offsets.get(bId)!
const res = checkBoundarySharing(a, b)
if (res.touching && res.sharedLength > 0 && res.gapX === 0 && res.gapY === 0) {
passedChecks++
} else {
failureList.push(`Act ${act}: pair [${aId}, ${bId}] seed=${seed} (touching=${res.touching}, len=${res.sharedLength}, gapX=${res.gapX}, gapY=${res.gapY})`)
}
}
}
}
expect(failureList).toEqual([])
expect(passedChecks).toBe(totalChecks)
expect(totalChecks).toBe(2600) // 26 seamless pairs * 100 seeds
})
it('Property 2a: 0 interior overlaps between any pair of seamless levels across all 100 seeds (9,900 checks)', () => {
let totalChecks = 0
let passedChecks = 0
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
const seamlessIds = Array.from(new Set(actSeamless.flat()))
for (let i = 0; i < seamlessIds.length; i++) {
for (let j = i + 1; j < seamlessIds.length; j++) {
totalChecks++
const a = offsets.get(seamlessIds[i]!)!
const b = offsets.get(seamlessIds[j]!)!
if (!doRectanglesOverlap(a, b)) {
passedChecks++
}
}
}
}
}
expect(passedChecks).toBe(totalChecks)
expect(totalChecks).toBe(9900)
})
it('Property 2b: 0 interior overlaps between ANY pair of placed levels across all 100 seeds (210,700 checks)', () => {
let totalChecks = 0
let passedChecks = 0
const overlaps: string[] = []
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
const allPlaced = Array.from(offsets.values())
for (let i = 0; i < allPlaced.length; i++) {
for (let j = i + 1; j < allPlaced.length; j++) {
totalChecks++
const a = allPlaced[i]!
const b = allPlaced[j]!
if (!doRectanglesOverlap(a, b)) {
passedChecks++
} else {
overlaps.push(`Act ${act} Seed ${seed}: Level ${a.levelId} overlaps with Level ${b.levelId}`)
}
}
}
}
}
expect(overlaps).toEqual([])
expect(passedChecks).toBe(totalChecks)
expect(totalChecks).toBe(210700)
})
it('Property 3: Act 5 Level 111 West edge X = 702 alignment across all 100 seeds matching ka.ma0 Layer 79 automap cluster', () => {
let checkedCount = 0
for (const seed of TEST_SEEDS) {
const offsets = resolveWorldOffsets(5, seed)
const l110 = offsets.get(110)
const l111 = offsets.get(111)
const l112 = offsets.get(112)
expect(l110).toBeDefined()
expect(l111).toBeDefined()
expect(l112).toBeDefined()
// Level 110 at (760, 1000)
expect(l110!.offsetX).toBe(760)
expect(l110!.offsetY).toBe(1000)
// Level 111 width 58, offset X = 702
expect(l111!.sizeX).toBe(58)
expect(l111!.offsetX).toBe(702)
expect(l111!.offsetX + l111!.sizeX).toBe(l110!.offsetX) // Zero gap to Level 110 (702 + 58 = 760)
// Level 112 width 58, offset X = 702
expect(l112!.sizeX).toBe(58)
expect(l112!.offsetX).toBe(702)
expect(l112!.offsetY + l112!.sizeY).toBe(l111!.offsetY) // Zero gap North to Level 111
// Combined bounding box cluster check [702..759] x [717..1026]
const minX = Math.min(l111!.offsetX, l112!.offsetX)
const maxX = Math.max(l111!.offsetX + l111!.sizeX - 1, l112!.offsetX + l112!.sizeX - 1)
const minY = Math.min(l111!.offsetY, l112!.offsetY)
const maxY = Math.max(l111!.offsetY + l111!.sizeY, l112!.offsetY + l112!.sizeY)
expect(minX).toBe(702)
expect(maxX).toBe(759)
expect(minY).toBe(717)
expect(maxY).toBe(1026)
checkedCount++
}
expect(checkedCount).toBe(100)
})
it('Property 4: Determinism replay across 100 seeds across all 5 acts produces 100% bit-identical coordinate maps', () => {
let checkedCount = 0
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const run1 = resolveWorldOffsets(act, seed)
const run2 = resolveWorldOffsets(act, seed)
const entries1 = Array.from(run1.entries())
const entries2 = Array.from(run2.entries())
expect(entries1).toEqual(entries2)
checkedCount++
}
}
expect(checkedCount).toBe(500)
})
it('Requirement: Level 33 resolves relative to Level 32 at (3996, 966) with zero overlap and exact North boundary contact', () => {
for (const seed of TEST_SEEDS) {
const act1 = resolveWorldOffsets(1, seed)
const l32 = act1.get(32)
const l33 = act1.get(33)
expect(l32).toBeDefined()
expect(l33).toBeDefined()
// Level 32 is isolated parent at (4000, 1000)
expect(l32!.offsetX).toBe(4000)
expect(l32!.offsetY).toBe(1000)
// Level 33 has depend: 32, offset: (-4, -34), sizeX: 48, sizeY: 34
expect(l33!.offsetX).toBe(3996) // 4000 + (-4)
expect(l33!.offsetY).toBe(966) // 1000 + (-34)
expect(l33!.sizeX).toBe(28)
expect(l33!.sizeY).toBe(34)
// North boundary sharing: l33 bottom edge (966 + 34 = 1000) abuts l32 top edge (1000)
expect(l33!.offsetY + l33!.sizeY).toBe(l32!.offsetY)
expect(doRectanglesOverlap(l32!, l33!)).toBe(false)
const boundary = checkBoundarySharing(l32!, l33!)
expect(boundary.touching).toBe(true)
expect(boundary.type).toBe('horizontal-boundary')
expect(boundary.sharedLength).toBeGreaterThan(0)
}
})
it('Adversarial Extended Check: All 136 game levels placed with positive integer dimensions and valid non-negative coordinates', () => {
let totalLevelsPlaced = 0
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, 42)
const expectedLevelsInAct = CANONICAL_LEVEL_ROWS.filter(r => r.act === act - 1)
expect(offsets.size).toBe(expectedLevelsInAct.length)
for (const [levelId, offset] of offsets.entries()) {
totalLevelsPlaced++
expect(offset.levelId).toBe(levelId)
expect(Number.isInteger(offset.offsetX)).toBe(true)
expect(offset.offsetX).toBeGreaterThanOrEqual(0)
expect(Number.isInteger(offset.offsetY)).toBe(true)
expect(offset.offsetY).toBeGreaterThanOrEqual(0)
expect(Number.isInteger(offset.sizeX)).toBe(true)
expect(offset.sizeX).toBeGreaterThan(0)
expect(Number.isInteger(offset.sizeY)).toBe(true)
expect(offset.sizeY).toBeGreaterThan(0)
}
}
expect(totalLevelsPlaced).toBe(136)
})
it('Adversarial Topology Check: SEAMLESS_ADJACENCY is planar and cycle-free across all acts', () => {
// Build adjacency list for seamless connections
const adj = new Map<number, Set<number>>()
for (const [a, b] of SEAMLESS_ADJACENCY) {
if (!adj.has(a)) adj.set(a, new Set())
if (!adj.has(b)) adj.set(b, new Set())
adj.get(a)!.add(b)
adj.get(b)!.add(a)
}
// Check each connected component is a tree / forest (no cycles)
const visited = new Set<number>()
const hasCycle = (node: number, parent: number | null): boolean => {
visited.add(node)
for (const neighbor of adj.get(node) ?? []) {
if (neighbor === parent) continue
if (visited.has(neighbor)) return true
if (hasCycle(neighbor, node)) return true
}
return false
}
for (const node of adj.keys()) {
if (!visited.has(node)) {
const cycleFound = hasCycle(node, null)
expect(cycleFound).toBe(false)
}
}
})
})

View File

@ -86,15 +86,15 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', ()
expect(meta.height).toBe(1594)
})
it('verifies all 70 monster R8 atlases and metadata schemas', () => {
it('verifies all 71 monster R8 atlases and metadata schemas', () => {
expect(existsSync(indexJsonPath)).toBe(true)
const index = JSON.parse(readFileSync(indexJsonPath, 'utf8'))
expect(index.schema).toBe(2)
expect(index.character).toBe('char-so')
expect(Array.isArray(index.entities)).toBe(true)
// 69 + EC (Hell Bovine, Moo Moo Farm)
expect(index.entities.length).toBe(70)
// 69 + EC (Hell Bovine, Moo Moo Farm) + XX (Minion of Destruction, Act 5)
expect(index.entities.length).toBe(71)
for (const entity of index.entities) {
expect(entity.key).toBeDefined()

View File

@ -38,16 +38,15 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true)
const baselineHash = sha256File(ENTITIES_INDEX_PATH)
// Expected canonical SHA-256 for Schema 2 70-monster entity index (including vk-hth Valkyrie
// and ec-hth Hell Bovine: MonStats.txt hellbovine Code=EC, level 39 Moo Moo Farm). Adding
// ec-hth is the only change against the previous 69-monster index (51da3628…).
expect(baselineHash).toBe('675757cffde277b24e2aec11b7440243654282cbb743424ef02ce1d253b41c66')
// Expected canonical SHA-256 for Schema 2 71-monster entity index (including vk-hth Valkyrie,
// ec-hth Hell Bovine, and xx-hth Minion of Destruction: MonStats.txt minion1..11 Code=XX).
expect(baselineHash).toBe('593ca49d34479d0864687aba4dc798bac4351ccdba6c6b97e2af3167292c6e6d')
const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8')
const parsed = JSON.parse(baselineContent)
expect(parsed.schema).toBe(2)
expect(parsed.character).toBe('char-so')
expect(parsed.entities).toHaveLength(70)
expect(parsed.entities).toHaveLength(71)
// Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present
const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2']
@ -148,16 +147,15 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
}
}
expect(kindsCount.preset).toBe(65)
expect(kindsCount.wilderness).toBe(90)
expect(kindsCount.preset).toBe(62)
expect(kindsCount.wilderness).toBe(93)
expect(kindsCount.maze).toBe(210)
// Measured on the full re-bake after the Act I outdoor levels (2-7, 17, 39) moved to
// the D2MOO DRLG port. For reference, a fresh bake of the previous generator gave
// 1020690 floors, 231738 walls and 1401 PNGs; the old pins (1020280 / 231464 / 1433)
// no longer matched any bake of the current code.
expect(totalFloors).toBe(1008898)
expect(totalWalls).toBe(237542)
expect(totalPngs).toBe(1390)
// Measured on the full re-bake after all 31 Acts I-V outdoor levels moved to
// the D2MOO DRLG port (including Level 83 Travincal reclassified from preset to
// outdoor DrlgType=3 per Levels.txt: 62 preset + 93 wilderness + 210 maze = 365).
expect(totalFloors).toBe(919854)
expect(totalWalls).toBe(249477)
expect(totalPngs).toBe(1427)
})
it('verifies world graph integrity and edge consistency with index.json', () => {
@ -167,9 +165,9 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
expect(graph.version).toBe(1)
expect(graph.levels.length).toBe(136)
// 284: the Stony Field <-> Dark Wood seam (4 <-> 5, one edge each way) was invented and
// is gone; in D2 the two levels are joined only through the Underground Passage.
expect(graph.edges.length).toBe(284)
// 286: includes the authentic Act 3 Spider Forest <-> Flayer Jungle (76 <-> 78)
// branching seam per DRLGOUTPLACE_LinkAct3Outdoors (0x6FD843D0).
expect(graph.edges.length).toBe(286)
expect(graph.waypoints.length).toBe(39)
// All edge levels must exist in levels list

View File

@ -1,565 +0,0 @@
/**
* tests/challenger-m4-stress.test.ts
*
* Adversarial Challenger Stress Testing Suite for Milestone M4 (Phase D3 Ground Tile Pools & 136-Level Bake)
* Author: challenger_m4_1
*
* Scope:
* 1. Ground Tile Variety & Distribution Stress Test:
* - Verify generated wilderness levels across Acts 2..5 exhibit multi-sequence ground variation (not just sequence 0).
* - Verify Act 3 Kurast (Levels 79, 80, 81, 82) ground tiles use strictly style: 1 and never style: 0.
* - Verify Level 117 (Act 5 Barricade Snow) uses strictly style: 6 (snow) and never style: 0 (dirt).
* 2. Offline Pre-Bake Assets Invariance:
* - Validate 136-level pre-baked pack scenes in samples/d2-packs/ with 0 missing tiles.
*
* Act I outdoor levels come from the DRLG port (src/game/drlg); their bit-exact parity with D2 is
* tests/drlg-act1-oracle.test.ts, which replaced the old Act 1 baseline-hash invariant.
*/
import { describe, expect, it, beforeAll } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
createCanvas,
fillGround,
CANONICAL_GROUND_TILES,
CANONICAL_GROUND_POOLS,
wildernessDt1Mask,
type Canvas,
type WildernessPiece,
type WildernessSubstitution,
type WeightedGroundTile,
} from '../src/game/wilderness.ts'
const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
/* ------------------------------------------------------------------------- *
* Mock Helpers for Hermetic Ground Stress Testing
* ------------------------------------------------------------------------- */
function makeMockBorderPiece(name: string): WildernessPiece {
const cells: Ds1Cell[][] = []
for (let y = 0; y < 8; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < 8; x += 1) {
row.push({
walls: [],
floors: [],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
const ds1: Ds1 = {
version: 18,
width: 8,
height: 8,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
return { name, border: true, levels: [ds1] }
}
function makeMockBorderSet(actPrefix: string): WildernessPiece[] {
return [
makeMockBorderPiece(`${actPrefix} Border 1`),
makeMockBorderPiece(`${actPrefix} Border 2`),
makeMockBorderPiece(`${actPrefix} Border 3`),
makeMockBorderPiece(`${actPrefix} Border 4`),
makeMockBorderPiece(`${actPrefix} Border 5`),
makeMockBorderPiece(`${actPrefix} Border 6`),
]
}
describe('M4 Adversarial Challenge: Canonical Ground Pools Contract', () => {
it('CANONICAL_GROUND_POOLS defines multi-sequence pools for all Acts 2..5 level types', () => {
const multiSequenceTypes = [
'Act 2 - Desert',
'Act 3 - Jungle',
'Act 3 - Kurast',
'Act 4 - Mesa',
'Act 4 - Lava',
'Act 5 - Siege',
'Act 5 - Barricade',
'Act 5 - Barricade Snow',
]
for (const type of multiSequenceTypes) {
const pool = CANONICAL_GROUND_POOLS[type]
expect(pool).toBeDefined()
expect(pool!.length).toBeGreaterThan(1)
const sequences = new Set(pool!.map(t => t.sequence))
// Must contain at least 2 distinct sequences
expect(sequences.size).toBeGreaterThanOrEqual(2)
// Must NOT be just sequence 0
expect(pool!.some(t => t.sequence !== 0)).toBe(true)
// Weights must be positive integers
for (const t of pool!) {
expect(t.weight).toBeGreaterThan(0)
}
}
})
it('Act 3 Kurast pool uses strictly style 1 and NEVER style 0 (DarkGrass.dt1 parity)', () => {
expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).toBe(1)
const pool = CANONICAL_GROUND_POOLS['Act 3 - Kurast']!
expect(pool).toBeDefined()
expect(pool.length).toBeGreaterThanOrEqual(4)
for (const t of pool) {
expect(t.style).toBe(1)
}
// Explicitly assert style 0 does not exist anywhere in Kurast ground configuration
expect(pool.filter(t => t.style === 0).length).toBe(0)
expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).not.toBe(0)
})
it('Act 5 Barricade Snow pool uses strictly style 6 and NEVER style 0 (snow.dt1 parity)', () => {
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).toBe(6)
const pool = CANONICAL_GROUND_POOLS['Act 5 - Barricade Snow']!
expect(pool).toBeDefined()
expect(pool.length).toBeGreaterThanOrEqual(5)
for (const t of pool) {
expect(t.style).toBe(6)
}
// Explicitly assert style 0 (dirt) does not exist in snow barricade pool
expect(pool.filter(t => t.style === 0).length).toBe(0)
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).not.toBe(0)
})
})
describe('M4 Adversarial Challenge: fillGround Sampling & Statistical Variety', () => {
it('fillGround samples multiple sequences across a 64x64 canvas for all Acts 2..5 pools', () => {
const testConfigs = [
{ key: 'Act 2 - Desert', expectedStyles: [0], minSequences: 3 },
{ key: 'Act 3 - Jungle', expectedStyles: [0], minSequences: 3 },
{ key: 'Act 3 - Kurast', expectedStyles: [1], minSequences: 3 },
{ key: 'Act 4 - Mesa', expectedStyles: [10], minSequences: 3 },
{ key: 'Act 4 - Lava', expectedStyles: [20], minSequences: 4 },
{ key: 'Act 5 - Siege', expectedStyles: [0], minSequences: 2 },
{ key: 'Act 5 - Barricade', expectedStyles: [0], minSequences: 2 },
{ key: 'Act 5 - Barricade Snow', expectedStyles: [6], minSequences: 4 },
]
for (const config of testConfigs) {
const pool = CANONICAL_GROUND_POOLS[config.key]!
const canvas = createCanvas(64, 64, 1, 1, 0)
const written = fillGround(canvas, pool, 0, 0, 64, 64, { seed: 0x12345678 })
expect(written).toBe(64 * 64)
const observedSequences = new Set<number>()
const observedStyles = new Set<number>()
for (let y = 0; y < 64; y += 1) {
for (let x = 0; x < 64; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
observedStyles.add(floor.style)
observedSequences.add(floor.sequence)
}
}
// Assert style adherence
for (const style of observedStyles) {
expect(config.expectedStyles).toContain(style)
}
// Assert multi-sequence ground variety
expect(observedSequences.size).toBeGreaterThanOrEqual(config.minSequences)
expect(observedSequences.has(0) || observedSequences.has(1)).toBe(true)
expect(Array.from(observedSequences).some(s => s > 0)).toBe(true)
}
})
})
describe('M4 Adversarial Challenge: Hermetic Generation for Kurast (style 1) and Snow Barricade (style 6)', () => {
it('Hermetic Kurast generation (Levels 79, 80, 81, 82) strictly uses style 1 for ground and never style 0', () => {
const kurastLevels = [79, 80, 81, 82]
const pieces = makeMockBorderSet('Act 3 - Kurast')
for (const id of kurastLevels) {
for (const seed of TEST_SEEDS) {
const result = generateWilderness({
levelId: id,
levelName: `Kurast ${id}`,
levelTypeName: 'Act 3 - Kurast',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
expect(result.stats.groundTile?.style).toBe(1)
expect(result.stats.groundTile?.style).not.toBe(0)
const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(groundTiles).toBeDefined()
expect(groundTiles.every(t => t.style === 1)).toBe(true)
// Across canvas, ground tiles must be style 1 (not style 0)
let groundCellCount = 0
const seenSequences = new Set<number>()
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const floor = result.level.cells[y]![x]!.floors[0]
if (floor !== undefined && floor.style === 1) {
groundCellCount += 1
seenSequences.add(floor.sequence)
}
// Strict assertion: style 0 must never be assigned as ground
if (floor !== undefined && floor.style === 0) {
expect(floor.style).not.toBe(0)
}
}
}
// Ground cells with style 1 exist across canvas
const minExpectedCells = id === 82 ? 50 : 1000
expect(groundCellCount).toBeGreaterThan(minExpectedCells)
if (id !== 82) {
expect(seenSequences.size).toBeGreaterThanOrEqual(3)
}
}
}
})
it('Hermetic Level 117 (Act 5 Barricade Snow) strictly uses style 6 for ground and never style 0', () => {
const pieces = makeMockBorderSet('Act 5 - Barricade')
for (const seed of TEST_SEEDS) {
const result = generateWilderness({
levelId: 117,
levelName: 'Frozen Tundra',
levelTypeName: 'Act 5 - Barricade',
sizeX: 64,
sizeY: 64,
subType: 11,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
expect(result.stats.groundTile?.style).toBe(6)
expect(result.stats.groundTile?.style).not.toBe(0)
const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(groundTiles).toBeDefined()
expect(groundTiles.every(t => t.style === 6)).toBe(true)
let snowCellCount = 0
const seenSequences = new Set<number>()
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const floor = result.level.cells[y]![x]!.floors[0]
if (floor !== undefined && floor.style === 6) {
snowCellCount += 1
seenSequences.add(floor.sequence)
}
if (floor !== undefined && floor.style === 0) {
expect(floor.style).not.toBe(0)
}
}
}
expect(snowCellCount).toBeGreaterThan(1000)
expect(seenSequences.size).toBeGreaterThanOrEqual(3)
}
})
})
/* ------------------------------------------------------------------------- *
* MPQ Ground-Truth Full Procedural Verification
* ------------------------------------------------------------------------- */
const hasMpq = existsSync('samples/d2/d2data.mpq')
describe.skipIf(!hasMpq)('M4 Adversarial Challenge: MPQ Procedural Ground Verification', () => {
let archives: MountedArchives
let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table }
let lvlsub: D2Table
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(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
}
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(value))
}
return levels
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
async function buildWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0),
levels,
})
}
return rows
}
beforeAll(async () => {
archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
} catch {
// Skip missing optional archive
}
}
const actTables = await loadActTables(archives)
tables = {
levels: actTables.levels,
lvltypes: actTables.lvltypes,
lvlprest: actTables.lvlprest,
}
lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
})
async function generateLevelForSeed(levelId: number, seed: number) {
const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)!
const typeId = cell(tables.levels, levelRow, 'LevelType')
const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
const name = cell(tables.levels, levelRow, 'Name')
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await buildWildernessPieces(levelTypeName)
const subs = await buildSubstitutions(subType)
const shrineSubs = await buildSubstitutions(subShrine)
return generateWilderness({
levelId,
levelName: name,
levelTypeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
})
}
it('Act 3 Kurast (Levels 79, 80, 81, 82): ground tiles strictly use style 1 and NEVER style 0', async () => {
const kurastLevels = [79, 80, 81, 82]
for (const levelId of kurastLevels) {
for (const seed of TEST_SEEDS) {
const result = await generateLevelForSeed(levelId, seed)
// Verify stats reporting
expect(result.stats.groundTile?.style).toBe(1)
expect(result.stats.groundTile?.style).not.toBe(0)
const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(groundTiles).toBeDefined()
expect(groundTiles.length).toBeGreaterThanOrEqual(1)
for (const t of groundTiles) {
expect(t.style).toBe(1)
}
// Verify level canvas cells: ground floor layer must have style 1 with multi-sequence variety
let kurastGroundCellsChecked = 0
const seenSequences = new Set<number>()
for (let y = 0; y < result.level.height; y += 1) {
const row = result.level.cells[y]!
for (let x = 0; x < result.level.width; x += 1) {
const cell = row[x]!
const floor = cell.floors[0]
if (floor !== undefined && floor.style === 1) {
kurastGroundCellsChecked += 1
seenSequences.add(floor.sequence)
}
}
}
// Ground cells with style 1 are present across the level
const minExpected = levelId === 82 ? 5 : 1000
expect(kurastGroundCellsChecked).toBeGreaterThan(minExpected)
if (levelId !== 82) {
expect(seenSequences.size).toBeGreaterThanOrEqual(2)
}
}
}
}, 60000)
it('Level 117 (Act 5 Barricade Snow): ground tiles strictly use style 6 (snow) and NEVER style 0 (dirt)', async () => {
for (const seed of TEST_SEEDS) {
const result = await generateLevelForSeed(117, seed)
// Verify stats reporting
expect(result.stats.groundTile?.style).toBe(6)
expect(result.stats.groundTile?.style).not.toBe(0)
const groundTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(groundTiles).toBeDefined()
expect(groundTiles.length).toBeGreaterThanOrEqual(1)
for (const t of groundTiles) {
expect(t.style).toBe(6)
}
// Verify level canvas cells: ground floor layer must have style 6 (snow) with multi-sequence variety
let snowCellsChecked = 0
const seenSequences = new Set<number>()
for (let y = 0; y < result.level.height; y += 1) {
const row = result.level.cells[y]!
for (let x = 0; x < result.level.width; x += 1) {
const cell = row[x]!
const floor = cell.floors[0]
if (floor !== undefined && floor.style === 6) {
snowCellsChecked += 1
seenSequences.add(floor.sequence)
}
}
}
// Snow ground cells with style 6 are the vast majority of the map (> 3000 cells)
expect(snowCellsChecked).toBeGreaterThan(3000)
// Snow barricade has sequences 0, 1, 2, 3, 4
expect(seenSequences.size).toBeGreaterThanOrEqual(3)
}
}, 60000)
it('Acts 2..5 outdoor levels exhibit multi-sequence ground variation across generated levels', async () => {
const outdoorSampleLevels = [
{ id: 41, name: 'Rocky Waste', act: 2, minSeq: 3 },
{ id: 43, name: 'Far Oasis', act: 2, minSeq: 3 },
{ id: 76, name: 'Spider Forest', act: 3, minSeq: 3 },
{ id: 79, name: 'Kurast Bazaar', act: 3, minSeq: 2 },
{ id: 104, name: 'Outer Steppes', act: 4, minSeq: 3 },
{ id: 107, name: 'River of Flame', act: 4, minSeq: 4 },
{ id: 110, name: 'Bloody Foothills', act: 5, minSeq: 2 },
{ id: 111, name: 'Frigid Highlands', act: 5, minSeq: 2 },
{ id: 117, name: 'Frozen Tundra', act: 5, minSeq: 3 },
]
for (const target of outdoorSampleLevels) {
const seed = TEST_SEEDS[0]
const result = await generateLevelForSeed(target.id, seed)
const seenSequences = new Set<number>()
for (let y = 0; y < result.level.height; y += 1) {
const row = result.level.cells[y]!
for (let x = 0; x < result.level.width; x += 1) {
const floor = row[x]!.floors[0]
if (floor !== undefined && floor.prop1 !== 0) {
seenSequences.add(floor.sequence)
}
}
}
// Assert multi-sequence variation
expect(seenSequences.size).toBeGreaterThanOrEqual(target.minSeq)
// Assert it is not just sequence 0
expect(Array.from(seenSequences).some(s => s !== 0)).toBe(true)
}
}, 60000)
})
describe('M4 Adversarial Challenge: Pre-baked Pack Directory Integrity', () => {
it('samples/d2-packs contains all 136 game levels with 0 missing tiles on Kurast and Frozen Tundra', () => {
expect(existsSync('samples/d2-packs')).toBe(true)
const criticalPackedLevels = [
'samples/d2-packs/act3/79-act-3-kurast-1-var1',
'samples/d2-packs/act3/80-act-3-kurast-2-var1',
'samples/d2-packs/act3/81-act-3-kurast-3-var1',
'samples/d2-packs/act3/82-act-3-kurast-4-var1',
'samples/d2-packs/act5/117-act-5-barricade-snow-var1',
]
for (const dir of criticalPackedLevels) {
expect(existsSync(dir)).toBe(true)
const scenePath = join(dir, 'scene.json')
const manifestPath = join(dir, 'manifest.json')
expect(existsSync(scenePath)).toBe(true)
expect(existsSync(manifestPath)).toBe(true)
const scene = JSON.parse(readFileSync(scenePath, 'utf8'))
expect(scene.stats.missingTiles).toBe(0)
expect(scene.stats.floors).toBeGreaterThan(0)
}
})
})

View File

@ -0,0 +1,188 @@
/**
* Act II outdoor DRLG (#496): structural and gameplay invariants across levels 41-46 over many seeds:
* - sizes: every Act II outdoor level gets its Levels.txt SizeX/SizeY across 128 layouts;
* - staff/boss tomb rolls: nStaffTombLevel and nBossTombLevel in 66..72 and distinct;
* - population: 1 waypoint on levels 42, 43, 44, 46 and 5 shrine cells on levels 41..45;
* - links & warps: seamless orth links along shared edges of 40 <-> 41 <-> 42 <-> 43 <-> 44 <-> 45,
* plus dungeon warps (41 -> 55, 42 -> 56, 43 -> 62, 44 -> 65, 45 -> 58, 46 -> 66..72);
* - determinism and seed distinctness.
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { dumpAct1, runAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
const LEVELS = [41, 42, 43, 44, 45, 46]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name)))
function lcgSeeds(start: number, count: number): number[] {
const out: number[] = []
let seed = start
for (let i = 0; i < count; i += 1) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
out.push(seed)
}
return out
}
const BAKE_SEEDS = [0x5eed0200, 0x5eed0201, 0x5eed0202]
const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x2badf00d, 8)]
const LAYOUT_SEEDS = lcgSeeds(0x13579bdf, 128)
const WAYPOINT_LEVELS = new Set([42, 43, 44, 46])
// Level 46 (Canyon of the Magi) gets its waypoint from its tomb presets in grid 0; 42..44 use grid 1.
const GRID1_WAYPOINT_LEVELS = new Set([42, 43, 44])
// DRLGOUTDESR_InitAct2OutdoorLevel (DrlgOutDesr.cpp): all Act 2 outdoor levels except 45 (Valley of Snakes) and 134 get 5 shrines.
const SHRINE_LEVELS = new Set([41, 42, 43, 44, 46])
const SHRINES_PER_LEVEL = 5
const GRID1_WAYPOINT = 0x30000
const GRID1_SHRINE = 0xf000
const SUBCLASS_WAYPOINT = 0x40
const UNIT_OBJECT = 2
const EXPECTED_WARPS: Readonly<Record<number, readonly number[]>> = {
41: [55],
42: [56],
43: [62],
44: [65],
45: [58],
46: [66, 67, 68, 69, 70, 71, 72],
}
const EXPECTED_ORTHS: Readonly<Record<number, readonly number[]>> = {
41: [40, 42],
42: [41, 43],
43: [42, 44],
44: [43, 45],
45: [44],
46: [],
}
const numeric = (a: number, b: number): number => a - b
function hex(seed: number): string {
return `0x${seed.toString(16).padStart(8, '0')}`
}
function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean {
const [ax, ay, aw, ah] = a
const [bx, by, bw, bh] = b
// ALTDIR_WEST=0, ALTDIR_NORTH=1, ALTDIR_EAST=2, ALTDIR_SOUTH=3
if (dir === 0 || dir === 2) {
const touch = dir === 2 ? ax + aw === bx : bx + bw === ax
return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by)
}
const touch = dir === 3 ? ay + ah === by : by + bh === ay
return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx)
}
describe.skipIf(!hasMpq)('Act II outdoor DRLG port: level invariants over many seeds', () => {
let data: DrlgMpqData
beforeAll(async () => {
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 2)
}, 120_000)
function generate(seed: number): DrlgDump {
return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false })
}
it(`sizes every level and rolls distinct staff/boss tombs on ${LAYOUT_SEEDS.length} act layouts`, () => {
const env = createDrlgEnv(data.source, data.tables)
for (const seed of LAYOUT_SEEDS) {
const run = runAct1(env, seed, [], { act: 1 })
expect(run.pDrlg.nStaffTombLevel).toBeGreaterThanOrEqual(66)
expect(run.pDrlg.nStaffTombLevel).toBeLessThanOrEqual(72)
expect(run.pDrlg.nBossTombLevel).toBeGreaterThanOrEqual(66)
expect(run.pDrlg.nBossTombLevel).toBeLessThanOrEqual(72)
expect(run.pDrlg.nBossTombLevel).not.toBe(run.pDrlg.nStaffTombLevel)
const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true })
for (const levelId of LEVELS) {
const level = act.find(l => l.id === levelId)
expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined()
const [, , width, height] = level!.coord
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
}
}
}, 60_000)
it('generates the same act from the same seed', () => {
expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!)))
}, 60_000)
it('generates a different level from every seed', () => {
const env = createDrlgEnv(data.source, data.tables)
const grids = new Map<number, Set<string>>(LEVELS.map(id => [id, new Set<string>()]))
for (const seed of SEEDS) {
const dump = dumpAct1(env, seed, LEVELS, { difficulty: 0, isolated: false, skipActivation: true })
for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids))
}
for (const [levelId, distinct] of grids) {
// Level 45 (Valley of Snakes) is a fixed 4x4 grid (32x32) with only the Claw Viper Temple preset
// and border links (3 link orientations), whereas 41..44 and 46 are full desert levels.
if (levelId === 45) {
expect(distinct.size, `level ${levelId}`).toBeGreaterThanOrEqual(2)
} else {
expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length)
}
}
}, 60_000)
for (const seed of SEEDS) {
it(`seed ${hex(seed)}: waypoints, shrines, seamless orth links, and dungeon warps`, () => {
const dump = generate(seed)
const act = new Map(dump.act.map(l => [l.id, l]))
for (const levelId of LEVELS) {
const what = `${hex(seed)} level ${levelId}`
const levelDump = dump.levels.find(l => l.id === levelId)!
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const layout = act.get(levelId)!.coord
// Waypoint & shrine cells in grid 1
const cells = levelDump.levelGrid.grids[1]!.cells
expect(cells.filter(c => (c & GRID1_WAYPOINT) !== 0).length, `${what}: waypoint cells`).toBe(
GRID1_WAYPOINT_LEVELS.has(levelId) ? 1 : 0,
)
expect(cells.filter(c => (c & GRID1_SHRINE) !== 0).length, `${what}: shrine cells`).toBe(
SHRINE_LEVELS.has(levelId) ? SHRINES_PER_LEVEL : 0,
)
const objects = input.units.filter(
u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length,
)
const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0)
expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_LEVELS.has(levelId) ? 1 : 0)
// Seamless orth links
expect(input.orths.map(o => o.level).sort(numeric), `${what}: orth neighbours`).toEqual(
[...EXPECTED_ORTHS[levelId]!].sort(numeric),
)
for (const orth of input.orths) {
const neighbour = act.get(orth.level)!
expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe(
true,
)
}
// Dungeon warps
expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual(
[...EXPECTED_WARPS[levelId]!].sort(numeric),
)
}
}, 60_000)
}
})

View File

@ -0,0 +1,107 @@
/**
* Act II outdoor DRLG (#496): bit-exact parity of the TypeScript port (src/game/drlg) with the native
* D2MOO oracle (tools/d2moo-oracle) across all Act II outdoor levels (41-46).
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
import {
firstDiff,
firstTraceDivergence,
formatDiff,
fsDrlgSource,
ORACLE_BIN,
ORACLE_DATA,
runOracle,
} from '../scripts/lib/drlg-oracle.ts'
const ACT2_OUTDOOR_LEVELS = [41, 42, 43, 44, 45, 46]
const SEEDS = [0x12345678, 0x5eed0200, 0x5eed0201, 0x5eed0202]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
function hexSeed(seed: number): string {
return seed.toString(16).padStart(8, '0')
}
describe('Act II outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
let data: DrlgMpqData
beforeAll(async () => {
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
if (missing.length > 0) {
throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
}
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 2)
}, 120_000)
for (const seed of SEEDS) {
const hex = hexSeed(seed)
it.skipIf(!existsSync(ORACLE_BIN))(
`seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`,
() => {
const oracleRun = runOracle({
seed,
levels: ACT2_OUTDOOR_LEVELS,
difficulty: 0,
isolated: false,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT2_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: false,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
for (const levelId of ACT2_OUTDOOR_LEVELS) {
const input = drlgLevelInputFromDump(actual, levelId, data.tables)
expect(input.tiles.length).toBeGreaterThan(0)
}
},
60_000,
)
}
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => {
const seed = 0x12345678
const oracleRun = runOracle({
seed,
levels: ACT2_OUTDOOR_LEVELS,
difficulty: 0,
isolated: true,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const source = fsDrlgSource(ORACLE_DATA)
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT2_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: true,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
}, 60_000)
})

View File

@ -0,0 +1,165 @@
/**
* Act III outdoor DRLG (#499): structural and gameplay invariants across levels 76-83 over many seeds:
* - sizes: jungle levels 76-78 have 48x(24*k) subdivisions, Kurast levels 79-83 match Levels.txt SizeX/SizeY;
* - connected act graph: 75..83 form a single connected outdoor graph with mutual touching orth links;
* - jungle population: 1 waypoint cell and 5 shrine cells on jungle levels 76..78, plus waypoint objects
* on 76, 77, 78, 79, 80, 81, 83;
* - dungeon warps on 76, 78, 80, 81, 82, 83;
* - determinism and seed distinctness.
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { LVLPREST_ACT3_JUNGLE_HEAD, LVLPREST_ACT3_JUNGLE_TAIL } from '../src/game/drlg/drlg-ids.ts'
import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
const LEVELS = [76, 77, 78, 79, 80, 81, 82, 83]
const JUNGLE_LEVELS = new Set([76, 77, 78])
const KURAST_LEVELS = [79, 80, 81, 82, 83]
const WAYPOINT_OBJECT_LEVELS = new Set([76, 77, 78, 79, 80, 81, 83])
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name)))
function lcgSeeds(start: number, count: number): number[] {
const out: number[] = []
let seed = start
for (let i = 0; i < count; i += 1) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
out.push(seed)
}
return out
}
const BAKE_SEEDS = [0x5eed0300, 0x5eed0301, 0x5eed0302]
const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x3badf00d, 4)]
const LAYOUT_SEEDS = lcgSeeds(0x2468ace1, 128)
const SUBCLASS_WAYPOINT = 0x40
const UNIT_OBJECT = 2
const EXPECTED_WARPS: Readonly<Record<number, readonly number[]>> = {
76: [84, 85],
77: [],
78: [86, 88],
79: [],
80: [92, 94, 95],
81: [92, 96, 97],
82: [98, 99],
83: [100],
}
const numeric = (a: number, b: number): number => a - b
function hex(seed: number): string {
return `0x${seed.toString(16).padStart(8, '0')}`
}
function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean {
const [ax, ay, aw, ah] = a
const [bx, by, bw, bh] = b
if (dir === 0 || dir === 2) {
const touch = dir === 2 ? ax + aw === bx : bx + bw === ax
return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by)
}
const touch = dir === 3 ? ay + ah === by : by + bh === ay
return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx)
}
describe.skipIf(!hasMpq)('Act III outdoor DRLG port: level invariants over many seeds', () => {
let data: DrlgMpqData
beforeAll(async () => {
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 3)
}, 120_000)
function generate(seed: number): DrlgDump {
return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false })
}
it(`sizes jungle and Kurast levels properly on ${LAYOUT_SEEDS.length} act layouts`, () => {
const env = createDrlgEnv(data.source, data.tables)
for (const seed of LAYOUT_SEEDS) {
const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true })
for (const levelId of LEVELS) {
const level = act.find(l => l.id === levelId)
expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined()
const [, , width, height] = level!.coord
if (JUNGLE_LEVELS.has(levelId)) {
expect(width % 16, `${hex(seed)} level ${levelId} width multiple of 16`).toBe(0)
expect(width, `${hex(seed)} level ${levelId} width >= 32`).toBeGreaterThanOrEqual(32)
expect(height % 24, `${hex(seed)} level ${levelId} height multiple of 24`).toBe(0)
expect(height, `${hex(seed)} level ${levelId} height >= 72`).toBeGreaterThanOrEqual(72)
}
}
for (const levelId of KURAST_LEVELS) {
const level = act.find(l => l.id === levelId)!
const [, , width, height] = level.coord
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
}
}
}, 60_000)
it('generates the same act from the same seed', () => {
expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!)))
}, 60_000)
it('generates a different jungle layout across seeds', () => {
const env = createDrlgEnv(data.source, data.tables)
const grids = new Map<number, Set<string>>([76, 77, 78].map(id => [id, new Set<string>()]))
for (const seed of SEEDS) {
const dump = dumpAct1(env, seed, [76, 77, 78], { difficulty: 0, isolated: false, skipActivation: true })
for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids))
}
for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length)
}, 60_000)
for (const seed of SEEDS) {
it(`seed ${hex(seed)}: waypoints, shrines, seamless orth links, and dungeon warps`, () => {
const dump = generate(seed)
const act = new Map(dump.act.map(l => [l.id, l]))
for (const levelId of LEVELS) {
const what = `${hex(seed)} level ${levelId}`
const levelDump = dump.levels.find(l => l.id === levelId)!
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const layout = act.get(levelId)!.coord
const cells0 = levelDump.levelGrid.grids[0]!.cells
if (levelId === 76) {
expect(cells0.includes(LVLPREST_ACT3_JUNGLE_HEAD), `${what}: jungle head preset`).toBe(true)
} else if (levelId === 78) {
expect(cells0.includes(LVLPREST_ACT3_JUNGLE_TAIL), `${what}: jungle tail preset`).toBe(true)
}
const objects = input.units.filter(
u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length,
)
const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0)
expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_OBJECT_LEVELS.has(levelId) ? 1 : 0)
expect(input.orths.length, `${what}: has orth neighbours`).toBeGreaterThan(0)
for (const orth of input.orths) {
const neighbour = act.get(orth.level)!
expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe(
true,
)
}
expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual(
[...EXPECTED_WARPS[levelId]!].sort(numeric),
)
}
}, 60_000)
}
})

View File

@ -0,0 +1,107 @@
/**
* Act III outdoor DRLG (#499): bit-exact parity of the TypeScript port (src/game/drlg) with the native
* D2MOO oracle (tools/d2moo-oracle) across all Act III outdoor levels (76-83).
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
import {
firstDiff,
firstTraceDivergence,
formatDiff,
fsDrlgSource,
ORACLE_BIN,
ORACLE_DATA,
runOracle,
} from '../scripts/lib/drlg-oracle.ts'
const ACT3_OUTDOOR_LEVELS = [76, 77, 78, 79, 80, 81, 82, 83]
const SEEDS = [0x12345678, 0x5eed0300, 0x5eed0301, 0x5eed0302]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
function hexSeed(seed: number): string {
return seed.toString(16).padStart(8, '0')
}
describe('Act III outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
let data: DrlgMpqData
beforeAll(async () => {
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
if (missing.length > 0) {
throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
}
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 3)
}, 120_000)
for (const seed of SEEDS) {
const hex = hexSeed(seed)
it.skipIf(!existsSync(ORACLE_BIN))(
`seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`,
() => {
const oracleRun = runOracle({
seed,
levels: ACT3_OUTDOOR_LEVELS,
difficulty: 0,
isolated: false,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT3_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: false,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
for (const levelId of ACT3_OUTDOOR_LEVELS) {
const input = drlgLevelInputFromDump(actual, levelId, data.tables)
expect(input.tiles.length).toBeGreaterThan(0)
}
},
60_000,
)
}
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => {
const seed = 0x12345678
const oracleRun = runOracle({
seed,
levels: ACT3_OUTDOOR_LEVELS,
difficulty: 0,
isolated: true,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const source = fsDrlgSource(ORACLE_DATA)
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT3_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: true,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
}, 60_000)
})

View File

@ -0,0 +1,165 @@
/**
* Act IV outdoor DRLG (#497): structural and gameplay invariants across levels 104-106,108 over many seeds:
* - sizes: 104/105 get 96x56 or 56x96 (rand-transposed), 106/108 match Levels.txt SizeX/SizeY;
* - population: 1 waypoint on level 106 and 2 shrine cells on levels 104..106;
* - links & warps: seamless orth links along shared edges of 103 <-> 104 <-> 105 <-> 106,
* plus warps to 107 (River of Flame) on 106 and 108;
* - determinism and seed distinctness.
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import {
LVLPREST_ACT4_DIABLO_HEART,
LVLPREST_ACT4_MESA_2_IZUAL,
LVLPREST_ACT4_MESA_WARP,
} from '../src/game/drlg/drlg-ids.ts'
import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
const LEVELS = [104, 105, 106, 108]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name)))
function lcgSeeds(start: number, count: number): number[] {
const out: number[] = []
let seed = start
for (let i = 0; i < count; i += 1) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
out.push(seed)
}
return out
}
const BAKE_SEEDS = [0x5eed0400, 0x5eed0401, 0x5eed0402]
const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x4badf00d, 4)]
const LAYOUT_SEEDS = lcgSeeds(0x3579bdf1, 128)
const WAYPOINT_LEVELS = new Set([106])
const SUBCLASS_WAYPOINT = 0x40
const UNIT_OBJECT = 2
const EXPECTED_WARPS: Readonly<Record<number, readonly number[]>> = {
104: [],
105: [],
106: [107],
108: [],
}
const EXPECTED_ORTHS: Readonly<Record<number, readonly number[]>> = {
104: [103, 105],
105: [104, 106],
106: [105],
108: [],
}
const numeric = (a: number, b: number): number => a - b
function hex(seed: number): string {
return `0x${seed.toString(16).padStart(8, '0')}`
}
function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean {
const [ax, ay, aw, ah] = a
const [bx, by, bw, bh] = b
if (dir === 0 || dir === 2) {
const touch = dir === 2 ? ax + aw === bx : bx + bw === ax
return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by)
}
const touch = dir === 3 ? ay + ah === by : by + bh === ay
return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx)
}
describe.skipIf(!hasMpq)('Act IV outdoor DRLG port: level invariants over many seeds', () => {
let data: DrlgMpqData
beforeAll(async () => {
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 4)
}, 120_000)
function generate(seed: number): DrlgDump {
return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false })
}
it(`sizes every Act IV outdoor level properly on ${LAYOUT_SEEDS.length} act layouts`, () => {
const env = createDrlgEnv(data.source, data.tables)
for (const seed of LAYOUT_SEEDS) {
const { act } = dumpAct1(env, seed, LEVELS, { actOnly: true })
for (const levelId of LEVELS) {
const level = act.find(l => l.id === levelId)
expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined()
const [, , width, height] = level!.coord
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
}
}
}, 60_000)
it('generates the same act from the same seed', () => {
expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!)))
}, 60_000)
it('generates a different outdoor layout across seeds', () => {
const env = createDrlgEnv(data.source, data.tables)
const grids = new Map<number, Set<string>>([104, 105, 106].map(id => [id, new Set<string>()]))
for (const seed of SEEDS) {
const dump = dumpAct1(env, seed, [104, 105, 106], { difficulty: 0, isolated: false, skipActivation: true })
for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids))
}
for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length)
}, 60_000)
for (const seed of SEEDS) {
it(`seed ${hex(seed)}: waypoints, key presets, seamless orth links, and dungeon warps`, () => {
const dump = generate(seed)
const act = new Map(dump.act.map(l => [l.id, l]))
for (const levelId of LEVELS) {
const what = `${hex(seed)} level ${levelId}`
const levelDump = dump.levels.find(l => l.id === levelId)!
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const cells0 = levelDump.levelGrid.grids[0]!.cells
if (levelId === 105) {
expect(cells0.includes(LVLPREST_ACT4_MESA_2_IZUAL), `${what}: Izual preset`).toBe(true)
} else if (levelId === 106) {
expect(cells0.includes(LVLPREST_ACT4_MESA_WARP), `${what}: Mesa warp preset`).toBe(true)
} else if (levelId === 108) {
expect(cells0.includes(LVLPREST_ACT4_DIABLO_HEART), `${what}: Chaos Sanctuary heart preset`).toBe(true)
}
const objects = input.units.filter(
u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length,
)
const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0)
expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_LEVELS.has(levelId) ? 1 : 0)
expect(input.orths.map(o => o.level).sort(numeric), `${what}: orth neighbours`).toEqual(
[...EXPECTED_ORTHS[levelId]!].sort(numeric),
)
if (act.has(levelId)) {
const layout = act.get(levelId)!.coord
for (const orth of input.orths) {
const neighbour = act.get(orth.level)!
expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe(
true,
)
}
}
expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual(
[...EXPECTED_WARPS[levelId]!].sort(numeric),
)
}
}, 60_000)
}
})

View File

@ -0,0 +1,107 @@
/**
* Act IV outdoor DRLG (#497): bit-exact parity of the TypeScript port (src/game/drlg) with the native
* D2MOO oracle (tools/d2moo-oracle) across all Act IV outdoor levels (104-106, 108).
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
import {
firstDiff,
firstTraceDivergence,
formatDiff,
fsDrlgSource,
ORACLE_BIN,
ORACLE_DATA,
runOracle,
} from '../scripts/lib/drlg-oracle.ts'
const ACT4_OUTDOOR_LEVELS = [104, 105, 106, 108]
const SEEDS = [0x12345678, 0x5eed0400, 0x5eed0401, 0x5eed0402]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
function hexSeed(seed: number): string {
return seed.toString(16).padStart(8, '0')
}
describe('Act IV outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
let data: DrlgMpqData
beforeAll(async () => {
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
if (missing.length > 0) {
throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
}
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 4)
}, 120_000)
for (const seed of SEEDS) {
const hex = hexSeed(seed)
it.skipIf(!existsSync(ORACLE_BIN))(
`seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`,
() => {
const oracleRun = runOracle({
seed,
levels: ACT4_OUTDOOR_LEVELS,
difficulty: 0,
isolated: false,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT4_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: false,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
for (const levelId of ACT4_OUTDOOR_LEVELS) {
const input = drlgLevelInputFromDump(actual, levelId, data.tables)
expect(input.tiles.length).toBeGreaterThan(0)
}
},
60_000,
)
}
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => {
const seed = 0x12345678
const oracleRun = runOracle({
seed,
levels: ACT4_OUTDOOR_LEVELS,
difficulty: 0,
isolated: true,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const source = fsDrlgSource(ORACLE_DATA)
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT4_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: true,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
}, 60_000)
})

View File

@ -0,0 +1,184 @@
/**
* Act V outdoor DRLG (#498): structural and gameplay invariants across levels 110-112,117,134 over many seeds:
* - sizes: 110-112 match Levels.txt SizeX/SizeY across 128 act layouts, 117/134 match Levels.txt SizeX/SizeY;
* - waypoints: 1 waypoint object on levels 111, 112, 117;
* - links & warps: seamless orth links along shared edges of 109 <-> 110 <-> 111 <-> 112,
* plus dungeon warps (111 -> 113,125; 112 -> 115,126; 117 -> 118,127);
* - determinism and seed distinctness (including Level 134 Forgotten Sands routed via Act II desert DRLG).
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import {
LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N,
LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W,
} from '../src/game/drlg/drlg-ids.ts'
import { dumpAct1, type DrlgDump, type DumpCoord } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { DATATBLS_GetLevelDefRecord, objectSubClass } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
const LEVELS = [110, 111, 112, 117, 134]
const ACT_OUTDOOR_LEVELS = [110, 111, 112]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const hasMpq = MOUNTS.every(name => existsSync(join(ARCHIVE_DIR, name)))
function lcgSeeds(start: number, count: number): number[] {
const out: number[] = []
let seed = start
for (let i = 0; i < count; i += 1) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0
out.push(seed)
}
return out
}
const BAKE_SEEDS = [0x5eed0500, 0x5eed0501, 0x5eed0502]
const SEEDS = [0x12345678, ...BAKE_SEEDS, ...lcgSeeds(0x5badf00d, 4)]
const LAYOUT_SEEDS = lcgSeeds(0x468ace13, 128)
const BARRICADE_LEVELS = new Set([111, 112, 117])
const WAYPOINT_LEVELS = new Set([111, 112, 117, 134])
const SUBCLASS_WAYPOINT = 0x40
const UNIT_OBJECT = 2
const EXPECTED_WARPS: Readonly<Record<number, readonly number[]>> = {
110: [],
111: [],
112: [113],
117: [115, 118],
134: [],
}
const EXPECTED_ORTHS: Readonly<Record<number, readonly number[]>> = {
110: [],
111: [112],
112: [111],
117: [],
134: [],
}
const numeric = (a: number, b: number): number => a - b
function hex(seed: number): string {
return `0x${seed.toString(16).padStart(8, '0')}`
}
function touchesOnDir(a: DumpCoord, b: DumpCoord, dir: number): boolean {
const [ax, ay, aw, ah] = a
const [bx, by, bw, bh] = b
if (dir === 0 || dir === 2) {
const touch = dir === 2 ? ax + aw === bx : bx + bw === ax
return touch && Math.min(ay + ah, by + bh) > Math.max(ay, by)
}
const touch = dir === 3 ? ay + ah === by : by + bh === ay
return touch && Math.min(ax + aw, bx + bw) > Math.max(ax, bx)
}
describe.skipIf(!hasMpq)('Act V outdoor DRLG port: level invariants over many seeds', () => {
let data: DrlgMpqData
beforeAll(async () => {
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 5)
}, 120_000)
function generate(seed: number): DrlgDump {
return dumpAct1(createDrlgEnv(data.source, data.tables), seed, LEVELS, { difficulty: 0, isolated: false })
}
it(`sizes every Act V outdoor level properly on ${LAYOUT_SEEDS.length} act layouts`, () => {
const env = createDrlgEnv(data.source, data.tables)
for (const seed of LAYOUT_SEEDS) {
const { act } = dumpAct1(env, seed, ACT_OUTDOOR_LEVELS, { actOnly: true })
for (const levelId of ACT_OUTDOOR_LEVELS) {
const level = act.find(l => l.id === levelId)
expect(level, `${hex(seed)}: level ${levelId} is not in the act`).toBeDefined()
const [, , width, height] = level!.coord
if (BARRICADE_LEVELS.has(levelId)) {
expect([width, height].sort(numeric), `${hex(seed)} level ${levelId}`).toEqual([64, 160])
} else {
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
expect([width, height], `${hex(seed)} level ${levelId}`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
}
}
}
}, 60_000)
it('generates the same act from the same seed', () => {
expect(JSON.stringify(generate(BAKE_SEEDS[0]!))).toBe(JSON.stringify(generate(BAKE_SEEDS[0]!)))
}, 60_000)
it('generates a different outdoor layout across seeds', () => {
const env = createDrlgEnv(data.source, data.tables)
const variedLevels = [111, 112, 117, 134]
const grids = new Map<number, Set<string>>(variedLevels.map(id => [id, new Set<string>()]))
for (const seed of SEEDS) {
const dump = dumpAct1(env, seed, variedLevels, { difficulty: 0, isolated: false, skipActivation: true })
for (const level of dump.levels) grids.get(level.id)!.add(JSON.stringify(level.levelGrid.grids))
}
for (const [levelId, distinct] of grids) expect(distinct.size, `level ${levelId}`).toBe(SEEDS.length)
}, 60_000)
for (const seed of SEEDS) {
it(`seed ${hex(seed)}: waypoints, seamless orth links, and dungeon warps`, () => {
const dump = generate(seed)
const act = new Map(dump.act.map(l => [l.id, l]))
for (const levelId of LEVELS) {
const what = `${hex(seed)} level ${levelId}`
const levelDump = dump.levels.find(l => l.id === levelId)!
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
const dims = [levelDump.levelGrid.coord[2] * 8, levelDump.levelGrid.coord[3] * 8]
if (BARRICADE_LEVELS.has(levelId)) {
expect([...dims].sort(numeric), `${what}: level dimensions`).toEqual([64, 160])
} else {
const def = DATATBLS_GetLevelDefRecord(data.tables, levelId)
expect(dims, `${what}: level dimensions`).toEqual([def.dwSizeX[0], def.dwSizeY[0]])
}
const objects = input.units.filter(
u => u.type === UNIT_OBJECT && u.index >= 0 && u.index < data.tables.objectSubClass.length,
)
const waypoints = objects.filter(u => (objectSubClass(data.tables, u.index) & SUBCLASS_WAYPOINT) !== 0)
expect(waypoints, `${what}: waypoint objects`).toHaveLength(WAYPOINT_LEVELS.has(levelId) ? 1 : 0)
expect(input.orths.map(o => o.level).sort(numeric), `${what}: orth neighbours`).toEqual(
[...EXPECTED_ORTHS[levelId]!].sort(numeric),
)
if (act.has(levelId)) {
const layout = act.get(levelId)!.coord
for (const orth of input.orths) {
const neighbour = act.get(orth.level)!
expect(touchesOnDir(layout, neighbour.coord, orth.dir), `${what}: touches ${orth.level} on dir ${orth.dir}`).toBe(
true,
)
}
if (levelId === 110) {
expect(touchesOnDir(layout, act.get(111)!.coord, 0), `${what}: touches 111 on west`).toBe(true)
expect(touchesOnDir(layout, act.get(109)!.coord, 2), `${what}: touches 109 on east`).toBe(true)
}
}
if (BARRICADE_LEVELS.has(levelId)) {
const cells0 = levelDump.levelGrid.grids[0]!.cells
expect(
cells0.includes(LVLPREST_ACT5_BARRICADE_HELL_PORTAL_N) ||
cells0.includes(LVLPREST_ACT5_BARRICADE_HELL_PORTAL_W),
`${what}: Hell portal preset`,
).toBe(true)
}
expect([...new Set(input.warps.map(w => w.toLevelId))].sort(numeric), `${what}: warps`).toEqual(
[...EXPECTED_WARPS[levelId]!].sort(numeric),
)
}
}, 60_000)
}
})

View File

@ -0,0 +1,107 @@
/**
* Act V outdoor DRLG (#498): bit-exact parity of the TypeScript port (src/game/drlg) with the native
* D2MOO oracle (tools/d2moo-oracle) across all Act V outdoor levels (110-112, 117, 134).
*/
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { DrlgTraceWriter, dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
import {
firstDiff,
firstTraceDivergence,
formatDiff,
fsDrlgSource,
ORACLE_BIN,
ORACLE_DATA,
runOracle,
} from '../scripts/lib/drlg-oracle.ts'
const ACT5_OUTDOOR_LEVELS = [110, 111, 112, 117, 134]
const SEEDS = [0x12345678, 0x5eed0500, 0x5eed0501, 0x5eed0502]
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
function hexSeed(seed: number): string {
return seed.toString(16).padStart(8, '0')
}
describe('Act V outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
let data: DrlgMpqData
beforeAll(async () => {
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
if (missing.length > 0) {
throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
}
const archives = new MountedArchives()
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
data = await loadDrlgMpqData(member => archives.read(member), 5)
}, 120_000)
for (const seed of SEEDS) {
const hex = hexSeed(seed)
it.skipIf(!existsSync(ORACLE_BIN))(
`seed 0x${hex}: MPQ-fed port reproduces every native oracle stage and RNG trace`,
() => {
const oracleRun = runOracle({
seed,
levels: ACT5_OUTDOOR_LEVELS,
difficulty: 0,
isolated: false,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT5_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: false,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
for (const levelId of ACT5_OUTDOOR_LEVELS) {
const input = drlgLevelInputFromDump(actual, levelId, data.tables)
expect(input.tiles.length).toBeGreaterThan(0)
}
},
60_000,
)
}
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle in --isolated mode on seed 0x12345678', () => {
const seed = 0x12345678
const oracleRun = runOracle({
seed,
levels: ACT5_OUTDOOR_LEVELS,
difficulty: 0,
isolated: true,
dataDir: ORACLE_DATA,
trace: true,
})
delete oracleRun.doc.oracle
const source = fsDrlgSource(ORACLE_DATA)
const trace = new DrlgTraceWriter()
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT5_OUTDOOR_LEVELS, {
difficulty: 0,
isolated: true,
trace,
})
const diff = firstDiff(oracleRun.doc, actual, '$')
if (diff) throw new Error(`Isolated dump mismatch: ${formatDiff(diff)}`)
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
expect(traceDiff).toBeNull()
}, 60_000)
})

View File

@ -1,31 +1,21 @@
import { describe, expect, test } from "vitest"
import { readFileSync } from "node:fs"
import { readFileSync, existsSync } from "node:fs"
import { resolve } from "node:path"
import * as wilderness from "../src/game/wilderness.ts"
import * as maze from "../src/game/maze.ts"
import * as levelLinks from "../src/game/level-links.ts"
import * as drlgMap from "../src/game/drlg/drlg-map.ts"
describe("DRLG Export and Alias Guard (Issue #81)", () => {
const getDrlgExports = (mod: Record<string, unknown>): string[] => {
return Object.keys(mod).filter((name) => name.startsWith("DRLG"))
}
test("src/game/wilderness.ts does not export fake Blizzard aliases", () => {
const exports = getDrlgExports(wilderness as Record<string, unknown>)
expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask")
expect(exports).not.toContain("DRLGROOMTILE_ApplyTransitions")
// The invented 8-neighbour ground-transition pass (computeNeighborMask /
// applyGroundTransitions) was removed with the Act I DRLG port: Act I
// outdoor maps now come from the 1:1 D2MOO port in src/game/drlg.
expect((wilderness as Record<string, unknown>).computeNeighborMask).toBeUndefined()
expect((wilderness as Record<string, unknown>).applyGroundTransitions).toBeUndefined()
})
test("src/game/wilderness.ts keeps single export of DRLGOUTDOORS_SpawnAct12Waypoint", () => {
const exports = getDrlgExports(wilderness as Record<string, unknown>)
expect(exports).toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
expect(typeof wilderness.DRLGOUTDOORS_SpawnAct12Waypoint).toBe("function")
test("legacy src/game/wilderness*.ts and world-placement.ts have been removed in favor of src/game/drlg", () => {
expect(existsSync(resolve(__dirname, "../src/game/wilderness.ts"))).toBe(false)
expect(existsSync(resolve(__dirname, "../src/game/wilderness-acts.ts"))).toBe(false)
expect(existsSync(resolve(__dirname, "../src/game/wilderness-jungles.ts"))).toBe(false)
expect(existsSync(resolve(__dirname, "../src/game/wilderness-siege.ts"))).toBe(false)
expect(existsSync(resolve(__dirname, "../src/game/world-placement.ts"))).toBe(false)
})
test("src/game/level-links.ts does not export duplicate DRLGOUTDOORS_SpawnAct12Waypoint", () => {
@ -38,29 +28,20 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => {
expect(typeof levelLinks.findWaypointSpot).toBe("function")
})
test("src/game/maze.ts does not export fake Blizzard DRLG aliases", () => {
const exports = getDrlgExports(maze as Record<string, unknown>)
test("src/game/maze.ts and src/game/drlg/drlg-map.ts do not export fake Blizzard DRLG aliases", () => {
const exports = [
...getDrlgExports(maze as Record<string, unknown>),
...getDrlgExports(drlgMap as Record<string, unknown>),
]
expect(exports).not.toContain("DRLGROOMTILE_ComputeNeighborMask")
expect(exports).not.toContain("DRLGROOMTILE_ApplyTransitions")
expect(exports).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
})
test("DRLGOUTDOORS_SpawnAct12Waypoint has at most ONE export across wilderness, maze, and level-links", () => {
const allDrlgExports = [
...getDrlgExports(wilderness as Record<string, unknown>),
...getDrlgExports(maze as Record<string, unknown>),
...getDrlgExports(levelLinks as Record<string, unknown>),
]
const waypointExports = allDrlgExports.filter((name) => name === "DRLGOUTDOORS_SpawnAct12Waypoint")
expect(waypointExports).toHaveLength(1)
})
test("Static source check: no fake DRLG export statements in source files", () => {
const files = [
resolve(__dirname, "../src/game/wilderness.ts"),
resolve(__dirname, "../src/game/maze.ts"),
resolve(__dirname, "../src/game/level-links.ts"),
resolve(__dirname, "../src/game/drlg/drlg-map.ts"),
]
for (const filePath of files) {
@ -72,10 +53,5 @@ describe("DRLG Export and Alias Guard (Issue #81)", () => {
// Ensure level-links.ts does not export DRLGOUTDOORS_SpawnAct12Waypoint
const levelLinksSource = readFileSync(resolve(__dirname, "../src/game/level-links.ts"), "utf-8")
expect(levelLinksSource).not.toMatch(/export\s+(?:const|function|let|var)\s+DRLGOUTDOORS_SpawnAct12Waypoint\b/)
// The invented (non-Blizzard) ground-transition helpers must not come back.
const wildernessSource = readFileSync(resolve(__dirname, "../src/game/wilderness.ts"), "utf-8")
expect(wildernessSource).not.toMatch(/function computeNeighborMask\b/)
expect(wildernessSource).not.toMatch(/function applyGroundTransitions\b/)
})
})

View File

@ -1807,8 +1807,11 @@ describe('Warp & Entrance Mouse-Click Transitions, KeyG Removal & Overhead Label
}
}
expect(warpCount).toBe(495)
expect(nonZeroBoxWarps).toBe(464)
// 501 total warps (470 with LvlWarp.txt bounding boxes + 31 zero-box warps):
// includes both authentic Sewer Level 1 (92) entrances in each of the 3 copies
// of Kurast Bazaar (80) and Upper Kurast (81).
expect(warpCount).toBe(501)
expect(nonZeroBoxWarps).toBe(470)
expect(zeroBoxWarps).toBe(31)
// 166 since the Act I outdoor levels come from the DRLG port: D2 has no
// Stony Field <-> Dark Wood seam (DrlgOutPlace.cpp gAct1WildernessDrlgLink /

View File

@ -20,10 +20,6 @@ import {
type MazePiece,
type MazeRequest,
} from '../src/game/maze.ts'
import {
ACT_POPULATION_CONFIG,
validateActPopulationConfig,
} from '../src/game/wilderness.ts'
import type { D2Table } from '../src/game/acts.ts'
import { Rng } from '../src/game/rng.ts'
@ -598,26 +594,6 @@ describe('Issue #61 Step 4: Objects.txt mapping validation and LvlPrest piece fa
})
})
describe('validateActPopulationConfig', () => {
test('validates ACT_POPULATION_CONFIG for all 5 acts against embedded data', () => {
for (let act = 1; act <= 5; act += 1) {
const config = ACT_POPULATION_CONFIG[act]!
const result = validateActPopulationConfig(config, act)
expect(result.valid).toBe(true)
expect(result.errors).toEqual([])
expect(result.totalChecked).toBeGreaterThan(0)
}
})
test('detects invalid IDs in population config with incomplete table', () => {
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1']] }
const config = ACT_POPULATION_CONFIG[1]!
const result = validateActPopulationConfig(config, 1, mockObjectsTable)
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
})
describe('discoverMazePieceFamilies', () => {
test('returns static families when lvlPrestTable is omitted', () => {
expect(discoverMazePieceFamilies('Act 2 - Harem')).toEqual(['Act 2 - Corrupt Harem', 'Act 2 - Harem'])

View File

@ -530,6 +530,37 @@ describe('Diablo II DRLG Maze Special Passes', () => {
expect(observedDirs).toContain('SW')
expect(observedDirs).toContain('SE')
})
test('Halls of Pain (level 123): always places waypoint in bounded 60x60 2x2 ring across var1..var3 and 100 seeds (Issue #501)', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const seeds = [
0x5eed0000 + 123 + 0 * 555,
0x5eed0000 + 123 + 1 * 555,
0x5eed0000 + 123 + 2 * 555,
...Array.from({ length: 100 }, (_, i) => i + 1),
]
for (const seed of seeds) {
const res = generateMaze({
levelId: 123,
levelName: 'Act 5 - Temple 2',
sectionSize: 30,
minRooms: 4,
merge: 1000,
seed,
pieces,
maxCellsX: 60,
maxCellsY: 60,
})
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const wp = specials.find(s => s.kind === 'waypoint')
const down = specials.find(s => s.kind === 'down')
expect(wp, `missing waypoint for seed 0x${seed.toString(16)}`).toBeDefined()
expect(down, `missing down stairs for seed 0x${seed.toString(16)}`).toBeDefined()
expect(wp!.sides).not.toBe(down!.sides)
expect(wp!.room).not.toBe(down!.room)
}
})
})
/* ----------------------------------------------------------------------- *

View File

@ -23,17 +23,24 @@ describe('monster-mapping', () => {
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' })
})
it('does not leak XX tokens or crash on cut monsters', () => {
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'minion1', 'spiritmummy']
it('does not leak cut monsters while resolving Act 5 Minion of Destruction (minion1..11) to token XX', () => {
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'spiritmummy']
for (const id of cutMonsters) {
expect(resolveMonsterArtSpec(id)).toBeNull()
}
for (let i = 1; i <= 11; i += 1) {
expect(resolveMonsterArtSpec(`minion${i}`)).toEqual({ token: 'XX', weapon: 'hth' })
}
})
it('ACT_MONSTER_SPECS does not contain any XX tokens in any act', () => {
it('ACT_MONSTER_SPECS includes token XX only in Act 5 (Minion of Destruction)', () => {
for (const [act, specs] of Object.entries(ACT_MONSTER_SPECS)) {
for (const spec of specs) {
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
if (Number(act) === 5) {
expect(specs).toContainEqual({ token: 'XX', weapon: 'hth' })
} else {
for (const spec of specs) {
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
}
}
}
})

View File

@ -1,414 +0,0 @@
import { describe, it, expect } from 'vitest'
import { Rng } from '../src/game/rng.ts'
import {
ALTDIR_WEST,
ALTDIR_NORTH,
ALTDIR_EAST,
ALTDIR_SOUTH,
DRLGVER_CreateVertices,
DRLGOUTDOORS_CalculatePathCoordinates,
DRLGOUTDOORS_CalculateHubCoordinates,
DRLGOUTDOORS_TestGridCellSpawnValid,
bresenhamLine,
UNIMPLEMENTED_PASSES,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { Act1OutdoorPlan, DrlgVertex } from '../src/game/wilderness.ts'
describe('Outdoor Boundary Vertices & Grid Road Path (Issue #82)', () => {
function createMockPlan(overrides: Partial<Act1OutdoorPlan> = {}): Act1OutdoorPlan {
const gridW = 4
const gridH = 4
return {
openGates: new Map(),
claimedBorderBlocks: new Set(),
claimedBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)),
roadObstacleBlocks: Array.from({ length: gridH }, () => Array(gridW).fill(false)),
specialPresets: [],
gateOpenings: [],
anchors: [
{
side: 'west',
anchorCell: { x: 0, y: 16 },
interiorCell: { x: 4, y: 16 },
label: 'Rogue Encampment Gate',
toLevelId: 1,
kind: 'gate',
},
{
side: 'east',
anchorCell: { x: 31, y: 16 },
interiorCell: { x: 27, y: 16 },
label: 'Cold Plains Gate',
toLevelId: 3,
kind: 'gate',
},
],
...overrides,
}
}
describe('Unimplemented Passes Audit', () => {
it('verifies DRLGVER_CreateVertices is removed from UNIMPLEMENTED_PASSES', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices')
})
})
describe('DRLGVER_CreateVertices: 0 RNG Rolls Guarantee', () => {
it('consumes exactly 0 RNG rolls during vertex and path construction', () => {
const rng = new Rng(0x12345678)
const initialSeed = rng.seed
const plan = createMockPlan()
const result = DRLGVER_CreateVertices(plan, 4, 4, rng)
expect(rng.seed).toBe(initialSeed)
expect(result.vertices.length).toBeGreaterThan(0)
expect(result.polylines.length).toBe(plan.anchors.length)
})
})
describe('DRLGVER_CreateVertices: Closed Cyclic Linked List', () => {
it('constructs 4 corner vertices in clockwise order with relative coordinates', () => {
const plan = createMockPlan({ anchors: [] })
const gridW = 4
const gridH = 4
const tileW = gridW * TILES_PER_BLOCK
const tileH = gridH * TILES_PER_BLOCK
const origin = { x: 100, y: 200 }
const result = DRLGVER_CreateVertices(plan, gridW, gridH, undefined, origin)
const cyclic = result.cyclicVertices!
expect(cyclic).toBeDefined()
expect(cyclic.length).toBe(4)
const [v0, v1, v2, v3] = cyclic
expect(v0).toMatchObject({ x: 0, y: 0, direction: ALTDIR_NORTH, dwFlags: 0, type: 'corner' })
expect(v1).toMatchObject({ x: tileW, y: 0, direction: ALTDIR_EAST, dwFlags: 0, type: 'corner' })
expect(v2).toMatchObject({ x: tileW, y: tileH, direction: ALTDIR_SOUTH, dwFlags: 0, type: 'corner' })
expect(v3).toMatchObject({ x: 0, y: tileH, direction: ALTDIR_WEST, dwFlags: 0, type: 'corner' })
// Check next pointers (clockwise)
expect(v0.next).toBe(v1)
expect(v1.next).toBe(v2)
expect(v2.next).toBe(v3)
expect(v3.next).toBe(v0)
// Check prev pointers (counter-clockwise)
expect(v0.prev).toBe(v3)
expect(v3.prev).toBe(v2)
expect(v2.prev).toBe(v1)
expect(v1.prev).toBe(v0)
})
it('inserts border connection vertices into appropriate edges with correct dwFlags and order', () => {
const plan = createMockPlan({
anchors: [
{
side: 'north',
anchorCell: { x: 16, y: 0 },
interiorCell: { x: 16, y: 4 },
label: 'North Gate',
toLevelId: 4,
kind: 'gate',
},
{
side: 'east',
anchorCell: { x: 32, y: 20 },
interiorCell: { x: 28, y: 20 },
label: 'East Preset',
toLevelId: 5,
kind: 'preset',
},
{
side: 'south',
anchorCell: { x: 24, y: 32 },
interiorCell: { x: 24, y: 28 },
label: 'South Gate',
toLevelId: 6,
kind: 'gate',
},
{
side: 'west',
anchorCell: { x: 0, y: 12 },
interiorCell: { x: 4, y: 12 },
label: 'West Gate',
toLevelId: 7,
kind: 'gate',
},
],
})
const result = DRLGVER_CreateVertices(plan, 4, 4)
const cyclic = result.cyclicVertices!
// 4 corners + 4 inserted border vertices = 8 vertices
expect(cyclic.length).toBe(8)
// Verify full cycle traversal via next
let curr = result.head!
const visitedForward: DrlgVertex[] = []
for (let i = 0; i < cyclic.length; i += 1) {
visitedForward.push(curr)
curr = curr.next!
}
expect(curr).toBe(result.head!)
expect(visitedForward.length).toBe(8)
// Verify full cycle traversal via prev
let prevNode = result.head!
const visitedBackward: DrlgVertex[] = []
for (let i = 0; i < cyclic.length; i += 1) {
visitedBackward.push(prevNode)
prevNode = prevNode.prev!
}
expect(prevNode).toBe(result.head!)
// Find inserted vertices and verify flags:
// Gate: dwFlags = 1 (connection)
// Preset: dwFlags = 3 (1 = connection | 2 = preset)
const northV = cyclic.find(v => v.direction === ALTDIR_NORTH && v.type === 'gate')
expect(northV).toBeDefined()
expect(northV!.dwFlags).toBe(1)
expect(northV!.x).toBe(16)
expect(northV!.y).toBe(0)
const eastV = cyclic.find(v => v.direction === ALTDIR_EAST && v.type === 'preset')
expect(eastV).toBeDefined()
expect(eastV!.dwFlags).toBe(3) // dwFlags |= 1 | 2
expect(eastV!.x).toBe(32)
expect(eastV!.y).toBe(20)
const southV = cyclic.find(v => v.direction === ALTDIR_SOUTH && v.type === 'gate')
expect(southV).toBeDefined()
expect(southV!.dwFlags).toBe(1)
expect(southV!.x).toBe(24)
expect(southV!.y).toBe(32)
const westV = cyclic.find(v => v.direction === ALTDIR_WEST && v.type === 'gate')
expect(westV).toBeDefined()
expect(westV!.dwFlags).toBe(1)
expect(westV!.x).toBe(0)
expect(westV!.y).toBe(12)
})
it('inserts span endpoint vertices [v15, v16] when provided for wide connections', () => {
const plan = createMockPlan({
anchors: [
{
side: 'north',
anchorCell: { x: 16, y: 0 },
interiorCell: { x: 16, y: 4 },
label: 'North River Span',
toLevelId: 4,
kind: 'preset',
span: [12, 20],
} as any,
],
})
const result = DRLGVER_CreateVertices(plan, 4, 4)
const cyclic = result.cyclicVertices!
// 4 corners + 2 span vertices = 6
expect(cyclic.length).toBe(6)
const northSpans = cyclic.filter(v => v.direction === ALTDIR_NORTH && v.type === 'preset')
expect(northSpans.length).toBe(2)
expect(northSpans[0]!.x).toBe(12)
expect(northSpans[1]!.x).toBe(20)
expect(northSpans[0]!.label).toContain('(Start)')
expect(northSpans[1]!.label).toContain('(End)')
})
})
describe('DRLGOUTDOORS_CalculatePathCoordinates', () => {
it('correctly snaps coordinates according to 8-cell grid rules for all 4 directions', () => {
const origin = { x: 0, y: 0 }
// ALTDIR_WEST (0): relX = 8 * Math.trunc(relX / 8) + 11
const westSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 0,
y: 16,
direction: ALTDIR_WEST,
}, origin)
expect(westSnapped.x).toBe(8 * Math.trunc(0 / 8) + 11) // 11
expect(westSnapped.y).toBe(16)
// ALTDIR_NORTH (1): relY = 8 * Math.trunc(relY / 8) + 11
const northSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 16,
y: 0,
direction: ALTDIR_NORTH,
}, origin)
expect(northSnapped.x).toBe(16)
expect(northSnapped.y).toBe(8 * Math.trunc(0 / 8) + 11) // 11
// ALTDIR_EAST (2): relX = 8 * Math.trunc(relX / 8) - 5
const eastSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 32,
y: 16,
direction: ALTDIR_EAST,
}, origin)
expect(eastSnapped.x).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27
expect(eastSnapped.y).toBe(16)
// ALTDIR_SOUTH (3): relY = 8 * Math.trunc(relY / 8) - 5
const southSnapped = DRLGOUTDOORS_CalculatePathCoordinates({
x: 16,
y: 32,
direction: ALTDIR_SOUTH,
}, origin)
expect(southSnapped.x).toBe(16)
expect(southSnapped.y).toBe(8 * Math.trunc(32 / 8) - 5) // 32 - 5 = 27
})
it('supports D2MOO C++ style (pLevel, pVertex1, pVertex2) signature and populates pVertex2', () => {
const mockLevel = { nPosX: 10, nPosY: 20 }
const pVertex1 = { nPosX: 42, nPosY: 20, nDirection: ALTDIR_EAST }
const pVertex2: { nPosX?: number; nPosY?: number; x?: number; y?: number } = {}
const res = DRLGOUTDOORS_CalculatePathCoordinates(mockLevel, pVertex1, pVertex2)
// relX = 42 - 10 = 32 -> 8 * trunc(32 / 8) - 5 = 27 -> 27 + 10 = 37
// relY = 20 - 20 = 0 -> 0 + 20 = 20
expect(res.x).toBe(37)
expect(res.y).toBe(20)
expect(pVertex2.x).toBe(37)
expect(pVertex2.y).toBe(20)
expect(pVertex2.nPosX).toBe(37)
expect(pVertex2.nPosY).toBe(20)
})
})
describe('DRLGOUTDOORS_TestGridCellSpawnValid', () => {
it('validates cell bitmask 0x1B81 and road obstacle blocks', () => {
const plan = {
gridFlags: [
[0, 0x0001],
[0x0080, 0x1000],
],
roadObstacleBlocks: [
[false, false],
[false, true],
],
}
// (0,0) has 0 -> valid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 0, plan)).toBe(true)
// (1,0) has 0x0001 (bit 0 in 0x1B81) -> invalid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 0, plan)).toBe(false)
// (0,1) has 0x0080 (bit 7 in 0x1B81) -> invalid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(0, 1, plan)).toBe(false)
// (1,1) has roadObstacleBlocks = true -> invalid
expect(DRLGOUTDOORS_TestGridCellSpawnValid(1, 1, plan)).toBe(false)
})
})
describe('DRLGOUTDOORS_CalculateHubCoordinates', () => {
it('returns center of level when nVertices === 1', () => {
const plan = createMockPlan({
anchors: [
{
side: 'west',
anchorCell: { x: 0, y: 16 },
interiorCell: { x: 4, y: 16 },
label: 'Single Gate',
kind: 'gate',
},
],
})
const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6)
expect(hub.bx).toBe(3) // floor(6 / 2)
expect(hub.by).toBe(3) // floor(6 / 2)
expect(hub.x).toBe(3 * TILES_PER_BLOCK + 4)
expect(hub.y).toBe(3 * TILES_PER_BLOCK + 4)
})
it('anchors to bridge coordinates when bridge flag is set', () => {
const plan = createMockPlan({
dwFlags: 0x10,
specialPresets: [
{
name: 'Blood Moor Bridge',
bx: 2,
by: 3,
blocksX: 1,
blocksY: 1,
ds1: {} as any,
},
],
} as any)
const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 6, 6)
expect(hub.bx).toBe(2)
expect(hub.by).toBe(3)
expect(hub.x).toBe(8 * 2 + 3) // 19
expect(hub.y).toBe(8 * 3 + 3) // 27
})
it('computes average coordinates and spirals outward radius 0..7 avoiding obstacles', () => {
// 2 anchors on west and east borders
// anchor 1: cell (0, 8) -> bx = 0, by = 1
// anchor 2: cell (32, 24) -> bx = 4, by = 3
// sumX = 0 + 32 = 32 -> avgX = floor(32 / 16) = 2
// sumY = 8 + 24 = 32 -> avgY = floor(32 / 16) = 2
// If (2, 2) is blocked by road obstacle, spiral radius 1 tests [-1,0] -> (1, 2)
const obstacleBlocks = Array.from({ length: 5 }, () => Array(5).fill(false))
obstacleBlocks[2]![2] = true // Block (2, 2)
const plan = createMockPlan({
anchors: [
{
side: 'west',
anchorCell: { x: 0, y: 8 },
interiorCell: { x: 4, y: 8 },
label: 'West',
kind: 'gate',
},
{
side: 'east',
anchorCell: { x: 32, y: 24 },
interiorCell: { x: 28, y: 24 },
label: 'East',
kind: 'gate',
},
],
roadObstacleBlocks: obstacleBlocks,
})
const hub = DRLGOUTDOORS_CalculateHubCoordinates(plan, 5, 5)
// Radius 1 first direction is { x: -1, y: 0 } -> testX = 2 - 1 = 1, testY = 2
expect(hub.bx).toBe(1)
expect(hub.by).toBe(2)
})
})
describe('Bresenham Grid Line Rasterization', () => {
it('produces deterministic contiguous grid line without diagonal gaps', () => {
const points = bresenhamLine(2, 3, 7, 6)
expect(points.length).toBeGreaterThan(0)
expect(points[0]).toEqual({ x: 2, y: 3 })
expect(points[points.length - 1]).toEqual({ x: 7, y: 6 })
for (let i = 1; i < points.length; i += 1) {
const pPrev = points[i - 1]!
const pCurr = points[i]!
const dx = Math.abs(pCurr.x - pPrev.x)
const dy = Math.abs(pCurr.y - pPrev.y)
expect(dx).toBeLessThanOrEqual(1)
expect(dy).toBeLessThanOrEqual(1)
expect(dx + dy).toBeGreaterThan(0)
}
})
it('guarantees deterministic identical output across runs with 0 RNG', () => {
const line1 = bresenhamLine(10, 20, 50, 80)
const line2 = bresenhamLine(10, 20, 50, 80)
expect(line1).toEqual(line2)
})
})
})

View File

@ -1,350 +0,0 @@
import { describe, expect, test } from "vitest"
import {
type Canvas,
DRLG_GRID_FLAG_WAYPOINT,
DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS,
DRLG_GRID_SPAWN_INVALID_MASK,
DRLGOUTDOORS_SpawnAct12Waypoint,
type PlacedPresetRect,
rollLimited,
type SeamConstraint,
TestGridCellSpawnValid,
type WildernessStats,
} from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1Cell } from "../src/formats/ds1.ts"
class TrackingRng extends Rng {
rollCount = 0
override next(): number {
this.rollCount += 1
return super.next()
}
}
function makeOutdoorCanvas(width: number, height: number): Canvas {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{
prop1: 2,
sequence: 0,
style: 0,
unknown1: 0,
unknown2: 0,
hidden: false,
}],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
width,
height,
cells,
wallLayers: 1,
floorLayers: 1,
substitutionLayers: 1,
objects: [],
}
}
function makeEmptyStats(): WildernessStats {
return {
substitutions: [],
borderPieces: {},
unresolved: [],
notes: [],
borderStamped: 0,
groundCells: 0,
groundTile: null,
sizeSource: "default",
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
waypointsSpawned: 0,
shrinesSpawned: 0,
interactivesSpawned: 0,
}
}
describe("DRLGOUTDOORS_SpawnAct12Waypoint 1.13c Parity (Issue #80)", () => {
describe("TestGridCellSpawnValid and DRLG_GRID_SPAWN_INVALID_MASK", () => {
test("DRLG_GRID_SPAWN_INVALID_MASK matches 1.13c disassembly 0x1B81", () => {
expect(DRLG_GRID_SPAWN_INVALID_MASK).toBe(0x1b81)
})
test("cell flags with no 0x1B81 bits pass validation", () => {
const validFlags = [
0,
0x0002,
0x0004,
0x0008,
0x0010,
0x0020,
0x0040,
0x0400,
0x2000,
0x4000,
0x8000,
DRLG_GRID_FLAG_WAYPOINT, // 0x10000
DRLG_GRID_FLAG_WAYPOINT_COLDPLAINS, // 0x20000
0x0002 | 0x0010 | 0x20000,
]
for (const flag of validFlags) {
const grid2D = [[flag]]
expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(true)
const grid1D = [flag]
expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(true)
const gridU32 = new Uint32Array([flag])
expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(true)
const gridObj = { width: 1, height: 1, flags: [flag] }
expect(TestGridCellSpawnValid(gridObj, 0, 0)).toBe(true)
}
})
test("cell flags with any 0x1B81 bits fail validation", () => {
const invalidFlags = [
0x0001, // preset occupied
0x0080, // border cell
0x0100, // collision bit
0x0200, // collision bit
0x0800, // collision bit
0x1000, // walls / impassable
0x1b81, // full mask
0x1000 | 0x0002, // mixed
0x0001 | DRLG_GRID_FLAG_WAYPOINT,
]
for (const flag of invalidFlags) {
const grid2D = [[flag]]
expect(TestGridCellSpawnValid(grid2D, 0, 0)).toBe(false)
const grid1D = [flag]
expect(TestGridCellSpawnValid(grid1D, 0, 0, 1)).toBe(false)
const gridU32 = new Uint32Array([flag])
expect(TestGridCellSpawnValid(gridU32, 0, 0, 1)).toBe(false)
}
})
test("out of bounds coordinates safely return false", () => {
const grid = [[0, 0], [0, 0]]
expect(TestGridCellSpawnValid(grid, -1, 0)).toBe(false)
expect(TestGridCellSpawnValid(grid, 0, -1)).toBe(false)
expect(TestGridCellSpawnValid(grid, 2, 0)).toBe(false)
expect(TestGridCellSpawnValid(grid, 0, 2)).toBe(false)
})
})
describe("rollLimited helper", () => {
test("rolls within [0, max - 1] and consumes exactly 1 roll per call", () => {
const rng = new TrackingRng(0x12345678)
const max = 36
for (let i = 0; i < 100; i += 1) {
const prevRolls = rng.rollCount
const val = rollLimited(rng, max)
expect(val).toBeGreaterThanOrEqual(0)
expect(val).toBeLessThan(max)
expect(rng.rollCount).toBe(prevRolls + 1)
}
})
test("handles edge case max <= 0 without consuming or erroring", () => {
const rng = new TrackingRng(0x12345678)
expect(rollLimited(rng, 0)).toBe(0)
expect(rollLimited(rng, -5)).toBe(0)
expect(rng.rollCount).toBe(0)
})
})
describe("Branch A: Cold Plains Special (levelId === 3)", () => {
test("consumes exactly 0 RNG rolls", () => {
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0xabcdef)
const seamConstraints: SeamConstraint[] = [
{ fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(
canvas,
3,
1,
undefined,
[],
rng,
stats,
"Act 1 - Wilderness",
undefined,
seamConstraints,
)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
// Exactly 0 RNG rolls consumed!
expect(rng.rollCount).toBe(0)
})
test("clamps connection to Blood Moor on all 4 boundary sides and sets 0x20000 flag", () => {
const testCases: { side: "west" | "east" | "north" | "south"; expectedBlockX: number; expectedBlockY: number }[] = [
{ side: "west", expectedBlockX: 1, expectedBlockY: 4 }, // j=0 clamped to 1, i=4
{ side: "east", expectedBlockX: 6, expectedBlockY: 4 }, // j=7 clamped to 6, i=4
{ side: "north", expectedBlockX: 4, expectedBlockY: 1 }, // i=0 clamped to 1, j=4
{ side: "south", expectedBlockX: 4, expectedBlockY: 6 }, // i=7 clamped to 6, j=4
]
for (const tc of testCases) {
const canvas = makeOutdoorCanvas(64, 64) // 8x8 blocks (0..7)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x9999)
const seamConstraints: SeamConstraint[] = [
{ fromLevelId: 3, toLevelId: 2, side: tc.side, blockCoord: 4 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(
canvas,
3,
1,
undefined,
[],
rng,
stats,
"Act 1 - Wilderness",
undefined,
seamConstraints,
)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const wpTile = stats.waypointTile!
const blockX = Math.floor(wpTile.x / 8)
const blockY = Math.floor(wpTile.y / 8)
expect(blockX).toBe(tc.expectedBlockX)
expect(blockY).toBe(tc.expectedBlockY)
const note = stats.notes.find(n => n.includes("Cold Plains special grid cell"))
expect(note).toBeDefined()
expect(note).toContain(`(${tc.expectedBlockX}, ${tc.expectedBlockY})`)
expect(rng.rollCount).toBe(0)
}
})
test("clamps extreme boundary corners (e.g. j=0 clamped to 1, i=0 clamped to 1)", () => {
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x4242)
// West gate at extreme top (blockCoord 0)
const seamConstraints: SeamConstraint[] = [
{ fromLevelId: 3, toLevelId: 2, side: "west", blockCoord: 0 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(
canvas,
3,
1,
undefined,
[],
rng,
stats,
"Act 1 - Wilderness",
undefined,
seamConstraints,
)
expect(stats.waypointTile).toBeDefined()
const blockX = Math.floor(stats.waypointTile!.x / 8)
const blockY = Math.floor(stats.waypointTile!.y / 8)
expect(blockX).toBe(1)
expect(blockY).toBe(1)
expect(rng.rollCount).toBe(0)
})
})
describe("Branch B: General Path (e.g. Stony Field, levelId === 4)", () => {
test("consumes exactly 2 * nArea RNG rolls for coordinate shuffle", () => {
// 64x64 tiles -> 8x8 blocks of 8x8 tiles
// nGridWidth = 8, nGridHeight = 8
// nWidth = 8 - 2 = 6, nHeight = 8 - 2 = 6
// nArea = 6 * 6 = 36
// Exactly 2 * nArea = 72 rolls for shuffle, and 0 rolls during placement search
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x778899)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const expectedRolls = 2 * 36
expect(rng.rollCount).toBe(expectedRolls)
})
test("consumes exactly 2 * nArea rolls for larger map (80x80 tiles -> 10x10 blocks -> 8x8 area = 64 cells -> 128 rolls)", () => {
const canvas = makeOutdoorCanvas(80, 80)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x112233)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, [], rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
// nWidth = 10 - 2 = 8, nHeight = 10 - 2 = 8, nArea = 64
// 2 * 64 = 128 rolls
expect(rng.rollCount).toBe(128)
})
test("skips cells occupied by preset rectangles (obstacle-free placement via TestGridCellSpawnValid)", () => {
const canvas = makeOutdoorCanvas(64, 64)
const stats = makeEmptyStats()
const rng = new TrackingRng(0x5555)
// Mark preset covering block (1, 1) -> tiles [8..15, 8..15]
const placedPresets: PlacedPresetRect[] = [
{ name: "PresetObstacle", x: 8, y: 8, w: 8, h: 8 },
]
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 4, 1, undefined, placedPresets, rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const wp = stats.waypointTile!
const blockX = Math.floor(wp.x / 8)
const blockY = Math.floor(wp.y / 8)
// Block (1, 1) had an obstacle so waypoint must not be in (1, 1)
expect(blockX === 1 && blockY === 1).toBe(false)
// Must be within inner bounds [1..6, 1..6]
expect(blockX).toBeGreaterThanOrEqual(1)
expect(blockX).toBeLessThanOrEqual(6)
expect(blockY).toBeGreaterThanOrEqual(1)
expect(blockY).toBeLessThanOrEqual(6)
})
test("is deterministic: identical seeds produce identical waypoint coordinates", () => {
const canvas1 = makeOutdoorCanvas(64, 64)
const stats1 = makeEmptyStats()
const rng1 = new TrackingRng(0xfeedbeef)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas1, 4, 1, undefined, [], rng1, stats1)
const canvas2 = makeOutdoorCanvas(64, 64)
const stats2 = makeEmptyStats()
const rng2 = new TrackingRng(0xfeedbeef)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas2, 4, 1, undefined, [], rng2, stats2)
expect(stats1.waypointTile).toEqual(stats2.waypointTile)
expect(rng1.rollCount).toBe(rng2.rollCount)
})
})
})

View File

@ -104,14 +104,14 @@ describe('offline pre-baked entity atlases & full Act 1..5 pack index (Issues #3
const charR8 = readFileSync(charR8Path)
expect(charR8.byteLength).toBe(charMeta.width * charMeta.height)
// Check all 66 unique monster specs exist as global .r8 files (EC = Hell Bovine, Moo Moo Farm)
// Check all 67 unique monster specs exist as global .r8 files (including EC = Hell Bovine and XX = Minion of Destruction)
const uniqueSpecs = new Set<string>()
for (let act = 1; act <= 5; act += 1) {
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
uniqueSpecs.add(`${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`)
}
}
expect(uniqueSpecs.size).toBe(66)
expect(uniqueSpecs.size).toBe(67)
for (const key of uniqueSpecs) {
const slug = `monster-${key}`

View File

@ -18,7 +18,6 @@ import {
resolvePickedFile,
} from "../src/game/preset.ts"
import type { PresetPiece } from "../src/game/preset.ts"
import { generateWilderness } from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, style = 1): Ds1 {
@ -208,42 +207,6 @@ describe("Preset Map Pipeline (DrlgType 2)", () => {
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(1)
expect(result.level.cells[30]?.[30]?.floors[0]?.style).toBe(2)
})
test("wilderness generator no longer hardcodes Level 108 Chaos Sanctuary special case", () => {
// Wilderness generator does not hijack Level 108 anymore; without special preset routing,
// it will not produce the 7-cluster Chaos Sanctuary layout.
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces = names.map(name => ({
name,
border: false,
levels: [makeMockDs1(24, 24)],
}))
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
// Previously this returned substitutedClusters: 7 via stampAct4ChaosSanctuary.
// Now wilderness treats it as regular wilderness, not the 7-preset Chaos Sanctuary.
expect(result.stats.substitutedClusters).not.toBe(7)
})
})
describe("Canonical Preset Levels (Levels.txt DrlgType == 2)", () => {

View File

@ -1,244 +0,0 @@
import { describe, it, expect } from 'vitest'
import { SEAMLESS_ADJACENCY } from '../src/game/world-graph.ts'
import { resolveWorldOffsets, type WorldOffset } from '../src/game/world-placement.ts'
/**
* 100 distinct seeds for adversarial stress testing:
* - 6 canonical / regression seeds (0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999)
* - 7 boundary / extreme seeds (0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919)
* - 87 pseudo-random seeds generated via standard LCG to ensure broad coverage
*/
export function generateTestSeeds(): number[] {
const seedSet = new Set<number>([
0x301cd095, 0x416d61c5, 0xdeadbeef, 42, 123, 9999,
0, 1, 0x7fffffff, 0xffffffff, 241, 572, 919,
])
let state = 0x8546291a
while (seedSet.size < 100) {
state = (state * 1664525 + 1013904223) >>> 0
seedSet.add(state)
}
return Array.from(seedSet)
}
export const TEST_SEEDS = generateTestSeeds()
/** Check if two rectangles have overlapping interior areas. */
export function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean {
return (
a.offsetX < b.offsetX + b.sizeX &&
a.offsetX + a.sizeX > b.offsetX &&
a.offsetY < b.offsetY + b.sizeY &&
a.offsetY + a.sizeY > b.offsetY
)
}
export interface BoundaryCheckResult {
readonly touching: boolean
readonly type: 'vertical-boundary' | 'horizontal-boundary' | 'none'
readonly sharedLength: number
readonly gapX: number
readonly gapY: number
}
/**
* Check if two rectangular levels share an exact boundary edge without tears or gaps.
* Must share a non-zero length segment along their touching edge.
*/
export function checkBoundarySharing(a: WorldOffset, b: WorldOffset): BoundaryCheckResult {
// Check horizontal adjacency (A is West of B or B is West of A)
const touchLR = a.offsetX + a.sizeX === b.offsetX || b.offsetX + b.sizeX === a.offsetX
const overlapY = Math.max(
0,
Math.min(a.offsetY + a.sizeY, b.offsetY + b.sizeY) - Math.max(a.offsetY, b.offsetY),
)
if (touchLR && overlapY > 0) {
return {
touching: true,
type: 'vertical-boundary',
sharedLength: overlapY,
gapX: 0,
gapY: 0,
}
}
// Check vertical adjacency (A is North of B or B is North of A)
const touchTB = a.offsetY + a.sizeY === b.offsetY || b.offsetY + b.sizeY === a.offsetY
const overlapX = Math.max(
0,
Math.min(a.offsetX + a.sizeX, b.offsetX + b.sizeX) - Math.max(a.offsetX, b.offsetX),
)
if (touchTB && overlapX > 0) {
return {
touching: true,
type: 'horizontal-boundary',
sharedLength: overlapX,
gapX: 0,
gapY: 0,
}
}
// Compute gaps / tears if not touching along an edge
const gapX = a.offsetX + a.sizeX <= b.offsetX
? b.offsetX - (a.offsetX + a.sizeX)
: b.offsetX + b.sizeX <= a.offsetX
? a.offsetX - (b.offsetX + b.sizeX)
: 0
const gapY = a.offsetY + a.sizeY <= b.offsetY
? b.offsetY - (a.offsetY + a.sizeY)
: b.offsetY + b.sizeY <= a.offsetY
? a.offsetY - (b.offsetY + b.sizeY)
: 0
return {
touching: false,
type: 'none',
sharedLength: 0,
gapX,
gapY,
}
}
describe('Adversarial Stress Test: resolveWorldOffsets across 100 Seeds for Acts 1..5', () => {
it('generates exactly 100 distinct test seeds', () => {
expect(TEST_SEEDS).toHaveLength(100)
expect(new Set(TEST_SEEDS).size).toBe(100)
})
it('Act 5: Level 111 West edge consistently aligns to X = 702 when connected to Level 110 at (760, 1000)', () => {
let checkedCount = 0
for (const seed of TEST_SEEDS) {
const offsets = resolveWorldOffsets(5, seed)
const l110 = offsets.get(110)
const l111 = offsets.get(111)
const l112 = offsets.get(112)
expect(l110).toBeDefined()
expect(l111).toBeDefined()
expect(l112).toBeDefined()
expect(l110!.offsetX).toBe(760)
expect(l110!.offsetY).toBe(1000)
expect(l111!.sizeX).toBe(58)
expect(l111!.offsetX).toBe(702)
expect(l111!.offsetX + l111!.sizeX).toBe(760) // Zero gap to Level 110
expect(l112!.sizeX).toBe(58)
expect(l112!.offsetX).toBe(702)
expect(l112!.offsetY + l112!.sizeY).toBe(l111!.offsetY) // Zero gap North to Level 111
checkedCount++
}
expect(checkedCount).toBe(100)
})
it('Determinism: identical seeds yield strictly identical WorldOffset maps', () => {
let checkedCount = 0
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const run1 = resolveWorldOffsets(act, seed)
const run2 = resolveWorldOffsets(act, seed)
expect(Array.from(run1.entries())).toEqual(Array.from(run2.entries()))
checkedCount++
}
}
expect(checkedCount).toBe(500)
})
it('Non-Overlapping Interiors: seamless outdoor levels have zero interior overlap', () => {
let pairsChecked = 0
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
const placedSeamlessIds = Array.from(new Set(actSeamless.flat()))
for (let i = 0; i < placedSeamlessIds.length; i++) {
for (let j = i + 1; j < placedSeamlessIds.length; j++) {
const a = offsets.get(placedSeamlessIds[i]!)!
const b = offsets.get(placedSeamlessIds[j]!)!
expect(doRectanglesOverlap(a, b)).toBe(false)
pairsChecked++
}
}
}
}
expect(pairsChecked).toBe(9900)
})
it('Acts 1, 2, 4, 5: contiguous pairs in SEAMLESS_ADJACENCY share exact boundary coordinates without tears or gaps', () => {
let pairsChecked = 0
const failures: string[] = []
for (const seed of TEST_SEEDS) {
for (const act of [1, 2, 4, 5]) {
const offsets = resolveWorldOffsets(act, seed)
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
for (const [aId, bId] of actSeamless) {
const a = offsets.get(aId)!
const b = offsets.get(bId)!
const res = checkBoundarySharing(a, b)
if (!res.touching) {
failures.push(`Act ${act}: [${aId}, ${bId}] seed=${seed}`)
}
pairsChecked++
}
}
}
expect(failures).toEqual([])
// 18 pairs (Act 1: 7, Act 2: 5, Act 4: 3, Act 5: 3) x 100 seeds.
expect(pairsChecked).toBe(1800)
})
it('Act 3 Seamless Adjacency: contiguous pairs in SEAMLESS_ADJACENCY share exact boundary coordinates without tears or gaps', () => {
let pairsChecked = 0
const failures: { seed: number; pair: string; reason: string }[] = []
for (const seed of TEST_SEEDS) {
const offsets = resolveWorldOffsets(3, seed)
const actSeamless = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
for (const [aId, bId] of actSeamless) {
const a = offsets.get(aId)!
const b = offsets.get(bId)!
const res = checkBoundarySharing(a, b)
if (!res.touching) {
failures.push({
seed,
pair: `[${aId}, ${bId}]`,
reason: `Touch at corner only, sharedLength=0, gapX=${res.gapX}, gapY=${res.gapY}`,
})
}
pairsChecked++
}
}
expect(pairsChecked).toBe(800)
expect(failures).toEqual([])
})
it('All-Level Interior Non-Overlap: all placed levels across all acts have zero interior overlap', () => {
let pairsChecked = 0
const overlaps: { seed: number; act: number; a: number; b: number }[] = []
for (const seed of TEST_SEEDS) {
for (let act = 1; act <= 5; act++) {
const offsets = resolveWorldOffsets(act, seed)
const placed = Array.from(offsets.values())
for (let i = 0; i < placed.length; i++) {
for (let j = i + 1; j < placed.length; j++) {
if (doRectanglesOverlap(placed[i]!, placed[j]!)) {
overlaps.push({ seed, act, a: placed[i]!.levelId, b: placed[j]!.levelId })
}
pairsChecked++
}
}
}
}
expect(pairsChecked).toBe(210700)
expect(overlaps).toEqual([])
})
})

View File

@ -9,7 +9,6 @@ import {
MONSTER_ART_MAP,
resolveMonsterArtSpec,
} from '../src/game/monster-mapping.ts'
import { CANONICAL_LANDMARK_ENTITIES } from '../src/game/wilderness.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
@ -34,38 +33,6 @@ describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => {
expect(landmarkMap.get('Nihlathak')?.minionMonsterId).toBe('minion6')
})
test('CANONICAL_LANDMARK_ENTITIES contains aligned boss and minion IDs', () => {
const entityMap = new Map(CANONICAL_LANDMARK_ENTITIES.map(e => [e.id, e]))
expect(entityMap.get('Beetleburst')?.monsterId).toBe('scarab2')
expect(entityMap.get('Beetleburst')?.minionMonsterId).toBe('scarab2')
expect(entityMap.get('Dark Elder')?.monsterId).toBe('zombie5')
expect(entityMap.get('Dark Elder')?.minionMonsterId).toBe('zombie5')
expect(entityMap.get('Bloodwitch the Wild')?.monsterId).toBe('pantherwoman1')
expect(entityMap.get('Bloodwitch the Wild')?.minionMonsterId).toBe('pantherwoman1')
expect(entityMap.get('Sszark the Burning')?.monsterId).toBe('arach4')
expect(entityMap.get('Sszark the Burning')?.minionMonsterId).toBe('arach4')
expect(entityMap.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
expect(entityMap.get('Witch Doctor Endugu')?.minionMonsterId).toBe('fetish4')
expect(entityMap.get('Battlemaid Sarina')?.monsterId).toBe('corruptrogue5')
expect(entityMap.get('Battlemaid Sarina')?.minionMonsterId).toBe('corruptrogue5')
expect(entityMap.get('Dac Farren')?.monsterId).toBe('imp3')
expect(entityMap.get('Dac Farren')?.minionMonsterId).toBe('imp3')
expect(entityMap.get('Shenk the Overseer')?.minionMonsterId).toBe('minion1')
expect(entityMap.get('Frozenstein')?.monsterId).toBe('snowyeti4')
expect(entityMap.get('Frozenstein')?.minionMonsterId).toBe('snowyeti4')
expect(entityMap.get('Nihlathak')?.minionMonsterId).toBe('minion6')
})
test('CANONICAL_SUPERUNIQUES_TABLE parses with corrected Class entries', () => {
const parsed = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)
const map = new Map(parsed.map(p => [p.id, p]))
@ -125,26 +92,25 @@ describe('SuperUniques Fidelity & Art Packaging (Issue #115)', () => {
expect(su.id).toBeTruthy()
expect(su.monsterId).toBeTruthy()
// Boss class must resolve to an art spec or return null for dummy 'xx' codes gracefully
// Boss class must resolve to an authentic art spec (including minion1 -> token 'XX')
const bossArt = resolveMonsterArtSpec(su.monsterId)
expect(bossArt).not.toBeNull()
expect(bossArt?.token).toBeTruthy()
if (su.monsterId === 'minion1') {
// minion1 has Code="xx" in MonStats.txt and resolveMonsterArtSpec returns null
expect(bossArt).toBeNull()
expect(bossArt).toEqual({ token: 'XX', weapon: 'hth' })
} else {
expect(bossArt).not.toBeNull()
expect(bossArt?.token).toBeTruthy()
expect(bossArt?.token.toLowerCase()).not.toBe('xx')
}
}
})
test('Dummy code xx monsters resolve to null and do not throw in resolveMonsterArtSpec', () => {
// minion1..minion11 have Code="xx" in MonStats
test('Act 5 Minion of Destruction (minion1..11) resolve to token XX while unknown/empty IDs resolve to null', () => {
// minion1..minion11 have authentic D2exp.mpq art under data\global\monsters\XX\
for (let i = 1; i <= 11; i += 1) {
const art = resolveMonsterArtSpec(`minion${i}`)
expect(art).toBeNull()
expect(art).toEqual({ token: 'XX', weapon: 'hth' })
}
// Unknown or dummy
// Unknown or empty IDs
expect(resolveMonsterArtSpec('xx')).toBeNull()
expect(resolveMonsterArtSpec('')).toBeNull()
})

View File

@ -1,127 +0,0 @@
import { describe, expect, test } from "vitest"
import { DRLGROOMTILE_GetTileCache } from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
function makeMockDt1(tiles: (Partial<Dt1Tile> & { rarity?: number; Rarity?: number })[]): Dt1 {
return {
warnings: [],
versionMajor: 7,
versionMinor: 6,
tiles: tiles.map(t => ({
direction: 0,
height: -80,
width: 160,
type: t.type ?? 0,
style: t.style ?? 0,
sequence: t.sequence ?? 0,
materialFlags: 0,
roofHeight: 0,
animated: t.animated ?? 0,
rarityFrameIndex: t.rarityFrameIndex ?? 1,
subTileFlags: [],
minBlockY: 0,
bitmapHeight: 80,
blocks: [],
...t,
})),
}
}
describe("DRLGROOMTILE_GetTileCache deterministic seed (Issue #78)", () => {
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 20 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 30 },
{ type: 0, style: 0, sequence: 2, rarityFrameIndex: 50 },
])
describe("same seed produces identical result", () => {
test("same integer seed produces identical tile selection across multiple invocations", () => {
const query1 = { type: 0, seed: 12345 }
const query2 = { type: 0, seed: 12345 }
const res1 = DRLGROOMTILE_GetTileCache([dt1], 1, query1)
const res2 = DRLGROOMTILE_GetTileCache([dt1], 1, query2)
expect(res1).not.toBeNull()
expect(res2).not.toBeNull()
expect(res1).toEqual(res2)
})
test("same coordinates and seed produce identical tile selection", () => {
const resA = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 99999, x: 15, y: 27 })
const resB = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 99999, x: 15, y: 27 })
expect(resA).not.toBeNull()
expect(resA).toEqual(resB)
})
test("same direct fractional roll produces identical tile selection", () => {
const resA = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 0.45 })
const resB = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 0.45 })
expect(resA).not.toBeNull()
expect(resA).toEqual(resB)
})
test("identical Rng seeds produce identical sequences of tile selections", () => {
const rng1 = new Rng(88888)
const rng2 = new Rng(88888)
const results1 = Array.from({ length: 20 }, () =>
DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: rng1 })?.sequence,
)
const results2 = Array.from({ length: 20 }, () =>
DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: rng2 })?.sequence,
)
expect(results1).toEqual(results2)
})
})
describe("different seeds produce varying selections over multiple runs", () => {
test("varying integer seeds select different tiles across runs", () => {
const seenSequences = new Set<number>()
for (let s = 0; s < 50; s += 1) {
const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: s * 1000 + 7 })
if (res !== null) {
seenSequences.add(res.sequence)
}
}
// All 3 candidate sequences (0, 1, 2) should be selected across 50 distinct seeds
expect(seenSequences.size).toBe(3)
expect(seenSequences.has(0)).toBe(true)
expect(seenSequences.has(1)).toBe(true)
expect(seenSequences.has(2)).toBe(true)
})
test("varying coordinates with same seed select different tiles", () => {
const seenSequences = new Set<number>()
for (let x = 0; x < 50; x += 1) {
const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x, y: 0 })
if (res !== null) {
seenSequences.add(res.sequence)
}
}
expect(seenSequences.size).toBeGreaterThan(1)
})
test("consecutive draws from a single Rng produce varying selections", () => {
const rng = new Rng(42)
const seenSequences = new Set<number>()
for (let i = 0; i < 50; i += 1) {
const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: rng })
if (res !== null) {
seenSequences.add(res.sequence)
}
}
expect(seenSequences.size).toBe(3)
})
})
})

View File

@ -1,186 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
applySubstitution,
applySubstitutions,
buildSubstitutionCandidatePermutation,
createCanvas,
stampSubstitutionPatch,
} from '../src/game/wilderness.ts'
import type { WildernessSubstitution } from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
function makePatchDs1(width: number, height: number, floorSeq: number, wallSeq = 10, roofSeq = 20, shadowSeq = 30): Ds1 {
const cells = Array.from({ length: height }, () =>
Array.from({ length: width }, () => ({
floors: [{ prop1: 1, style: 5, sequence: floorSeq, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [
{ prop1: 1, style: 6, sequence: wallSeq, type: 1, hidden: false, unknown1: 0, unknown2: 0 },
{ prop1: 1, style: 7, sequence: roofSeq, type: 15, hidden: false, unknown1: 0, unknown2: 0 },
],
shadows: [{ prop1: 1, style: 8, sequence: shadowSeq, hidden: false, unknown1: 0, unknown2: 0 }],
substitutions: [{ value: 1 }],
})),
)
return {
version: 18,
width,
height,
act: 1,
substitutionType: 1,
wallLayers: 2,
floorLayers: 1,
cells,
objects: [{ type: 2, id: 15, x: 2, y: 2, flags: 0 }],
npcPathOffset: null,
}
}
describe('Blizzard D2 1.13c LvlSub.txt Trials & Exhaustive Permutation Substitution (Issue #76)', () => {
it('generates a complete, duplicate-free Fisher-Yates candidate permutation that varies by seed', () => {
const permA = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(1001))
const permB = buildSubstitutionCandidatePermutation(6, 6, 1, new Rng(2002))
expect(permA).toHaveLength(36)
expect(permB).toHaveLength(36)
// Verify all 36 grid coordinates are present with zero duplicates
const keysA = new Set(permA.map(p => `${p.ox},${p.oy}`))
expect(keysA.size).toBe(36)
// Verify permutation order differs between seeds (not fixed top-left scan)
const firstFiveA = permA.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|')
const firstFiveB = permB.slice(0, 5).map(p => `${p.ox},${p.oy}`).join('|')
expect(firstFiveA).not.toBe(firstFiveB)
})
it('finds the sole remaining free candidate slot when Trials == -1 (exhaustive scan guarantee)', () => {
const gridWidth = 8
const gridHeight = 8
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1)
// Claim every interior block EXCEPT (bx = 5, by = 4)
const claimedBlocks = Array.from({ length: gridHeight }, () =>
Array.from({ length: gridWidth }, () => true),
)
claimedBlocks[4]![5] = false
const patch = makePatchDs1(8, 8, 42)
const subRow: WildernessSubstitution = {
name: 'Act 1 - Exhaustive Shrine',
type: 1,
bordType: -1,
gridSize: 1,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [-1, -1, -1, -1, -1],
max: [1, 1, 1, 1, 1],
levels: [patch],
}
const stamped = applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(999), claimedBlocks)
expect(stamped).toBe(1)
// Verify the patch was stamped at exact block (bx = 5, by = 4) => tile (40, 32)
const targetCell = canvas.cells[32]![40]!
expect(targetCell.floors[0]?.sequence).toBe(42)
})
it('distributes Trials == -1 placements across varied interior locations rather than top-left (1,1)', () => {
const gridWidth = 10
const gridHeight = 10
const patch = makePatchDs1(8, 8, 55)
const subRow: WildernessSubstitution = {
name: 'Act 1 - Swamp Pool',
type: 1,
bordType: -1,
gridSize: 1,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [-1, -1, -1, -1, -1],
max: [1, 1, 1, 1, 1],
levels: [patch],
}
const placedCoords = new Set<string>()
for (let seed = 1; seed <= 10; seed += 1) {
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1)
const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
applySubstitution(canvas, subRow, 0, gridWidth, gridHeight, new Rng(seed * 7919), claimed)
for (let by = 1; by < gridHeight - 1; by += 1) {
for (let bx = 1; bx < gridWidth - 1; bx += 1) {
if (claimed[by]![bx]) placedCoords.add(`${bx},${by}`)
}
}
}
// Across 10 seeds, Fisher-Yates permutation places the cluster in many different spots (not just 1,1)
expect(placedCoords.size).toBeGreaterThan(5)
})
it('atomically replaces Floor, Wall, Roof (type 15), and Shadow layers while clearing stale procedural clutter', () => {
const canvas = createCanvas(16, 16, 2, 1, 1)
// Pre-populate cell (4, 4) with procedural clutter on floor, wall, roof, and shadow layers
const staleCell = canvas.cells[4]![4]!
;(staleCell.floors as unknown[])[0] = { prop1: 1, style: 1, sequence: 1, hidden: false, unknown1: 0, unknown2: 0 }
;(staleCell.walls as unknown[])[0] = { prop1: 1, style: 99, sequence: 99, type: 1, hidden: false, unknown1: 0, unknown2: 0 }
;(staleCell.walls as unknown[])[1] = { prop1: 1, style: 99, sequence: 98, type: 15, hidden: false, unknown1: 0, unknown2: 0 }
;(staleCell.shadows as unknown[])[0] = { prop1: 1, style: 99, sequence: 97, hidden: false, unknown1: 0, unknown2: 0 }
const patch = makePatchDs1(2, 2, 11, 22, 33, 44)
const written = stampSubstitutionPatch(canvas, patch, 4, 4)
expect(written).toBe(4)
const updatedCell = canvas.cells[4]![4]!
expect(updatedCell.floors[0]?.sequence).toBe(11)
expect(updatedCell.walls[0]?.sequence).toBe(22)
expect(updatedCell.walls[0]?.type).toBe(1)
expect(updatedCell.walls[1]?.sequence).toBe(33)
expect(updatedCell.walls[1]?.type).toBe(15)
expect(updatedCell.shadows[0]?.sequence).toBe(44)
expect(updatedCell.substitutions[0]?.value).toBe(1)
expect(canvas.objects).toHaveLength(1)
})
it('applies theme-indexed Prob0..4, Trials0..4, and Max0..4 with multi-variant selection', () => {
const gridWidth = 10
const gridHeight = 10
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 2, 1, 1)
const claimed = Array.from({ length: gridHeight }, () => Array.from({ length: gridWidth }, () => false))
const variantA = makePatchDs1(8, 8, 101)
const variantB = makePatchDs1(8, 8, 102)
const rows: WildernessSubstitution[] = [
{
name: 'Theme 2 Multi-Variant Bivouac',
type: 1,
bordType: -1,
gridSize: 1,
dt1Mask: 0,
prob: [0, 0, 100, 0, 0],
trials: [0, 0, 20, 0, 0],
max: [0, 0, 4, 0, 0],
levels: [variantA, variantB],
},
]
const stats = {
levelId: 2,
seed: 12345,
subTheme: 2,
gridWidth,
gridHeight,
substitutions: [] as { name: string; role: string; enabled: boolean; clusters: number }[],
borderPieces: {},
unresolved: [],
notes: [],
}
const total = applySubstitutions(canvas, rows, 2, gridWidth, gridHeight, new Rng(12345), stats as never, claimed)
expect(total).toBe(4)
expect(stats.substitutions[0]?.enabled).toBe(true)
expect(stats.substitutions[0]?.clusters).toBe(4)
})
})

View File

@ -1,94 +1,5 @@
import { describe, expect, test } from 'vitest'
import { pickVariant, type PoolTile } from '../src/game/d2map.ts'
import { generateWilderness, type WildernessPiece } from '../src/game/wilderness.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import { ACT5_SIEGE_STRIP_NAMES } from '../src/game/wilderness-siege.ts'
function makeMockDs1(
width: number,
height: number,
hasGapInWalls: boolean = false,
floorStyle: number = 1,
wallStyle: number = 1,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] },
]
}
function makeMockStripDs1(width: number, height: number, tagSequence: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: x === 0 && y === 0 ? tagSequence : 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function createMockSiegePieces(): WildernessPiece[] {
return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({
name,
border: false,
levels: [makeMockStripDs1(16, 48, 100 + idx)],
}))
}
describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => {
describe('pickVariant 2D spatial coordinate hash and diagonal decorrelation', () => {
@ -194,172 +105,4 @@ describe('Variant Distribution & Valid DT1 Tile Styles (Issue #116)', () => {
expect(ratio1).toBeLessThan(0.80)
})
})
describe('Kurast causeway valid stone floor styles (Level 82 & 79~81)', () => {
test('Kurast Causeway (82) uses valid stone floor style 20 and balustrade wall style 20', () => {
const groundDs1 = makeMockDs1(48, 16)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Ground', border: false, levels: [groundDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
let foundCausewayFloor = false
let foundBalustradeWall = false
let foundInvalidStyle5 = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const floor of cell.floors) {
if (floor.style === 5) foundInvalidStyle5 = true
if (floor.style === 20 && floor.prop1 === 194) foundCausewayFloor = true
}
for (const wall of cell.walls) {
if (wall.style === 20 && wall.prop1 === 129) foundBalustradeWall = true
}
}
}
// Must NOT use fictional style 5 (not present in Kurast DT1s)
expect(foundInvalidStyle5).toBe(false)
// Must use valid Kurast stone floor style 20
expect(foundCausewayFloor).toBe(true)
// Must use valid Kurast balustrade wall style 20
expect(foundBalustradeWall).toBe(true)
})
test('Kurast Urban Districts (79 Lower Kurast) avenues and bridges use valid style 20', () => {
const pieces = makeMockBorderPieces('Act 3')
const result = generateWilderness({
levelId: 79,
levelName: 'Lower Kurast',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 79001,
pieces,
substitutions: [],
})
let foundAvenueFloor = false
let foundBridgeFloor = false
let foundInvalidStyle5 = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const floor of cell.floors) {
if (floor.style === 5) foundInvalidStyle5 = true
if (floor.style === 20 && floor.prop1 === 194) foundAvenueFloor = true
}
}
}
expect(foundInvalidStyle5).toBe(false)
expect(foundAvenueFloor).toBe(true)
})
})
describe('Mesa canyon and chasm valid tile styles (Levels 104, 105, 106)', () => {
test.each([
{ levelId: 104, levelName: 'Outer Steppes' },
{ levelId: 105, levelName: 'Plains of Despair' },
{ levelId: 106, levelName: 'City of the Damned' },
])('$levelName ($levelId) uses Mesa floor style 10 instead of Lava style 20', ({ levelId, levelName }) => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId,
levelName,
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: levelId * 1000 + 1,
pieces,
substitutions: [],
})
let foundMesaChasmFloor = false
let foundMesaBridgeFloor = false
let foundLavaStyle20 = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const floor of cell.floors) {
if (floor.style === 20) foundLavaStyle20 = true
if (floor.style === 10 && floor.prop1 === 2) {
if (floor.sequence === 0) foundMesaChasmFloor = true
}
}
}
}
// Mesa DT1s have no style 20 (only present in Lava.dt1)
expect(foundLavaStyle20).toBe(false)
// Valid Mesa chasm floor is style 10
expect(foundMesaChasmFloor).toBe(true)
})
})
describe('Siege barricades valid fence sequence numbers (Level 110)', () => {
test('Bloody Foothills (110) barricades use fence style 2 with valid sequence numbers 10 and 11', () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: 'Bloody Foothills',
levelTypeName: 'Act 5 - Siege',
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
let foundBarricadePalisade = false
let foundSplinteredEndCap = false
let foundInvalidSequence = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
for (const wall of cell.walls) {
if (wall.style === 2 && wall.prop1 === 129) {
// Intact palisade (type 1)
if (wall.type === 1) {
if (wall.sequence === 10) foundBarricadePalisade = true
else foundInvalidSequence = true
}
// Splintered end-cap post (type 7)
if (wall.type === 7) {
if (wall.sequence === 10 || wall.sequence === 11) foundSplinteredEndCap = true
else foundInvalidSequence = true
}
}
}
}
}
expect(foundInvalidSequence).toBe(false)
expect(foundBarricadePalisade).toBe(true)
expect(foundSplinteredEndCap).toBe(true)
})
})
})

View File

@ -1,825 +0,0 @@
import { describe, expect, test } from 'vitest'
import {
generateWilderness,
getPerimeterOpenings,
UNIMPLEMENTED_PASSES,
} from '../src/game/wilderness.ts'
import type { WildernessPiece } from '../src/game/wilderness.ts'
import {
act2OasisRadius,
act5FrozenLakeRadius,
jitterFrac,
} from '../src/game/wilderness-acts.ts'
import type {
Act2TopographyStats,
Act3TopographyStats,
Act4TopographyStats,
Act5TopographyStats,
} from '../src/game/wilderness-acts.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(
width: number,
height: number,
hasGapInWalls: boolean = false,
floorStyle: number = 1,
wallStyle: number = 1,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: wallStyle, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] },
]
}
describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
test('UNIMPLEMENTED_PASSES has 5 outdoor passes removed', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDESR_InitAct2OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTPLACE_InitAct3OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGVER_CreateVertices')
expect(UNIMPLEMENTED_PASSES).toEqual([
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDOORS_SpawnAct3Mephisto',
])
})
describe('Act 2: DRLGOUTDESR_InitAct2OutdoorLevel', () => {
test('Rocky Waste (41) generates sand caravan trails and canyon ridge walls', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 41001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.sandTrailCells).toBeGreaterThan(50)
expect(topo.canyonWalls).toBeGreaterThan(20)
let foundSandTrail = false
let foundCanyonWall = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundSandTrail = true
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundCanyonWall = true
}
}
expect(foundSandTrail).toBe(true)
expect(foundCanyonWall).toBe(true)
})
test('Far Oasis (43) generates lush water oasis pools', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 43001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.oasisPoolCells).toBeGreaterThan(15)
let foundOasisWater = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundOasisWater = true
}
}
expect(foundOasisWater).toBe(true)
})
test('Valley of Snakes (45) creates narrow canyon gorge', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 45,
levelName: 'Valley of Snakes',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 45001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.canyonWalls).toBeGreaterThan(30)
})
test('Canyon of the Magi (46) places 7 Tal Rasha tombs, central dais, and has 0 perimeter openings', () => {
const pieces = makeMockBorderPieces('Act 2')
expect(getPerimeterOpenings(46, 10, 10).size).toBe(0)
const result = generateWilderness({
levelId: 46,
levelName: 'Canyon of the Magi',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 46001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.tombEntrances).toBe(7)
// Verify central Arcane Sanctuary warp platform (portal/dais)
let foundCentralDais = false
for (let y = 35; y <= 45; y += 1) {
for (let x = 35; x <= 45; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 3)) foundCentralDais = true
}
}
expect(foundCentralDais).toBe(true)
})
})
describe('Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles', () => {
test('Jungles (76 Spider Forest) generate river channel, wooden river bridges, and jungle paths', () => {
const tailDs1 = makeMockDs1(64, 32)
const headDs1 = makeMockDs1(64, 32)
const clearingDs1 = makeMockDs1(32, 32)
const riverDs1 = makeMockDs1(32, 32)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [tailDs1] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [headDs1] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [clearingDs1] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [riverDs1] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
seed: 76001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.riverBridges).toBeGreaterThanOrEqual(2)
expect(topo.junglePathCells).toBeGreaterThan(20)
// Verify wooden river bridge planks (style 4, prop1 2) spanning across East channel
let foundBridgePlank = false
for (let y = 70; y <= 90; y += 1) {
for (let x = 32; x < 60; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) foundBridgePlank = true
}
}
expect(foundBridgePlank).toBe(true)
})
test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => {
const groundDs1 = makeMockDs1(48, 16)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Ground', border: false, levels: [groundDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.causewayCells).toBeGreaterThan(50)
expect(topo.canalCells).toBeGreaterThan(100)
// Verify central walkway (style 5), stone balustrade walls (style 1), and canal water (style 2)
let foundWalkway = false
let foundBalustrade = false
let foundCanalWater = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => (f.style === 5 || f.style === 20) && f.prop1 === 194)) foundWalkway = true
if (cell.walls.some(w => (w.style === 1 || w.style === 20) && w.prop1 === 129)) foundBalustrade = true
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true
}
}
expect(foundWalkway).toBe(true)
expect(foundBalustrade).toBe(true)
expect(foundCanalWater).toBe(true)
})
test('Kurast Causeway (82) preserves preset when Act 3 - Bridge is provided', () => {
const bridgeDs1 = makeMockDs1(48, 16, false, 29, 31)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
let foundPresetFloor = false
let foundPresetWall = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 29)) foundPresetFloor = true
if (cell.walls.some(w => w.style === 31)) foundPresetWall = true
}
}
expect(foundPresetFloor).toBe(true)
expect(foundPresetWall).toBe(true)
})
test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => {
const pieces = makeMockBorderPieces('Act 3')
const result = generateWilderness({
levelId: 79,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 79001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.causewayCells).toBeGreaterThan(50)
expect(topo.canalCells).toBeGreaterThan(30)
expect(topo.riverBridges).toBeGreaterThanOrEqual(1)
})
})
describe('Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel', () => {
test('Outer Steppes (104) generates lava chasms, basalt lips, and rocky bridge causeways', () => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 104001,
pieces,
substitutions: [],
})
expect(result.stats.act4Topography).toBeDefined()
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.lavaChasmCells).toBeGreaterThan(100)
expect(topo.rockyBridges).toBeGreaterThan(5)
// Verify boiling molten lava (style 20, sequence 3, prop1 2) and rocky bridges (style 10, sequence 0, prop1 2)
let foundLava = false
let foundRockyBridge = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => (f.style === 20 || f.style === 10) && f.prop1 === 2)) foundLava = true
if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true
}
}
expect(foundLava).toBe(true)
expect(foundRockyBridge).toBe(true)
})
test('Plains of Despair (105) creates Izual prison plateau', () => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId: 105,
levelName: 'Plains of Despair',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 105001,
pieces,
substitutions: [],
})
expect(result.stats.act4Topography).toBeDefined()
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.plateauCells).toBeGreaterThan(20)
})
})
describe('Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel', () => {
test('Frigid Highlands (111) generates transverse siege barricades, choke-point gates, and trenches', () => {
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 111001,
pieces,
substitutions: [],
})
expect(result.stats.act5Topography).toBeDefined()
const topo = result.stats.act5Topography as Act5TopographyStats
expect(topo.barricadeWalls).toBeGreaterThan(40)
expect(topo.trenchCells).toBeGreaterThan(30)
// Verify spiked palisade walls (style 2, prop1 129) and trench floors (style 0/6)
let foundBarricadeWall = false
let foundTrench = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) foundBarricadeWall = true
if (cell.floors.some(f => (f.style === 0 || f.style === 6) && f.prop1 === 2)) foundTrench = true
}
}
expect(foundBarricadeWall).toBe(true)
expect(foundTrench).toBe(true)
})
test('Arreat Plateau (112) generates snow crater frozen lakes with ice floors', () => {
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112001,
pieces,
substitutions: [],
})
expect(result.stats.act5Topography).toBeDefined()
const topo = result.stats.act5Topography as Act5TopographyStats
expect(topo.frozenLakeCells).toBeGreaterThan(20)
let foundIceFloor = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)) foundIceFloor = true
}
}
expect(foundIceFloor).toBe(true)
})
})
describe('Phase D2: Organic Geometries & Fractional Jitter (Milestone M2)', () => {
function computePolarIsoperimetricQuotient(
radiusFn: (theta: number) => number,
steps: number = 1000,
): { area: number; perimeter: number; quotient: number } {
const dTheta = (2 * Math.PI) / steps
let area = 0
let perimeter = 0
for (let i = 0; i < steps; i += 1) {
const t1 = i * dTheta
const t2 = (i + 1) * dTheta
const r1 = radiusFn(t1)
const r2 = radiusFn(t2)
area += 0.5 * r1 * r2 * Math.sin(dTheta)
const x1 = r1 * Math.cos(t1)
const y1 = r1 * Math.sin(t1)
const x2 = r2 * Math.cos(t2)
const y2 = r2 * Math.sin(t2)
perimeter += Math.hypot(x2 - x1, y2 - y1)
}
const quotient = (perimeter * perimeter) / area
return { area, perimeter, quotient }
}
test('Act 2 Oasis satisfies organic isoperimetric quotient P^2/A > 4pi * 1.15', () => {
const threshold = 4 * Math.PI * 1.15
// 1. Analytical contour integration across various phase angles
const testPhases = [
[0, 0],
[0.5, 1.2],
[Math.PI / 3, Math.PI / 4],
[2.1, 0.9],
]
for (const [phi1, phi2] of testPhases) {
const { quotient } = computePolarIsoperimetricQuotient(
theta => act2OasisRadius(10, theta, phi1, phi2),
1000,
)
expect(quotient).toBeGreaterThan(threshold)
}
// 2. Discrete raster grid verification on generated Far Oasis level
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 43002,
pieces,
substitutions: [],
})
const cells = result.level.cells
const W = result.level.width
const H = result.level.height
const isOasis = (x: number, y: number): boolean => {
if (x < 0 || x >= W || y < 0 || y >= H) return false
return cells[y]![x]!.floors.some(f => f.style === 2 && f.prop1 === 2)
}
let area = 0
let perimeter = 0
for (let y = 0; y < H; y += 1) {
for (let x = 0; x < W; x += 1) {
if (isOasis(x, y)) {
area += 1
let exposedEdges = 0
if (!isOasis(x + 1, y)) exposedEdges += 1
if (!isOasis(x - 1, y)) exposedEdges += 1
if (!isOasis(x, y + 1)) exposedEdges += 1
if (!isOasis(x, y - 1)) exposedEdges += 1
perimeter += exposedEdges
}
}
}
expect(area).toBeGreaterThan(15)
const discreteQuotient = (perimeter * perimeter) / area
expect(discreteQuotient).toBeGreaterThan(threshold)
})
test('Act 5 Frozen Lake satisfies organic isoperimetric quotient P^2/A > 4pi * 1.15', () => {
const threshold = 4 * Math.PI * 1.15
// 1. Analytical contour integration across various phase angles
const testPhases = [
[0, 0],
[0.7, 1.5],
[Math.PI / 2, Math.PI / 6],
[1.8, 2.3],
]
for (const [phi1, phi2] of testPhases) {
const { quotient } = computePolarIsoperimetricQuotient(
theta => act5FrozenLakeRadius(10, theta, phi1, phi2),
1000,
)
expect(quotient).toBeGreaterThan(threshold)
}
// 2. Discrete raster grid verification on generated Arreat Plateau level
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112002,
pieces,
substitutions: [],
})
const cells = result.level.cells
const W = result.level.width
const H = result.level.height
const isLake = (x: number, y: number): boolean => {
if (x < 0 || x >= W || y < 0 || y >= H) return false
return cells[y]![x]!.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)
}
let area = 0
let perimeter = 0
for (let y = 0; y < H; y += 1) {
for (let x = 0; x < W; x += 1) {
if (isLake(x, y)) {
area += 1
let exposedEdges = 0
if (!isLake(x + 1, y)) exposedEdges += 1
if (!isLake(x - 1, y)) exposedEdges += 1
if (!isLake(x, y + 1)) exposedEdges += 1
if (!isLake(x, y - 1)) exposedEdges += 1
perimeter += exposedEdges
}
}
}
expect(area).toBeGreaterThan(20)
const discreteQuotient = (perimeter * perimeter) / area
expect(discreteQuotient).toBeGreaterThan(threshold)
})
test('Act 4 Lava Fissure guarantees >= 90% walkable flood-fill connectivity', () => {
const pieces = makeMockBorderPieces('Act 4')
const testSeeds = [104001, 104002, 104003, 104004]
for (const seed of testSeeds) {
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
const W = result.level.width
const H = result.level.height
const hasWall = (x: number, y: number): boolean => {
const cell = result.level.cells[y]![x]!
return cell.walls.some(w => w.prop1 !== 0 || w.style !== 0)
}
const isWalkable = (x: number, y: number): boolean => !hasWall(x, y)
// Find all walkable cells in the playable interior [8..W-8] x [8..H-8]
let totalWalkable = 0
let startX = -1
let startY = -1
for (let y = 8; y < H - 8; y += 1) {
for (let x = 8; x < W - 8; x += 1) {
if (isWalkable(x, y)) {
totalWalkable += 1
if (startX === -1 && y < 16) {
startX = x
startY = y
}
}
}
}
expect(totalWalkable).toBeGreaterThan(1000)
expect(startX).toBeGreaterThanOrEqual(8)
// BFS flood-fill from starting walkable cell
const visited = new Set<number>()
const queue: [number, number][] = [[startX, startY]]
visited.add(startY * W + startX)
while (queue.length > 0) {
const [cx, cy] = queue.shift()!
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const nx = cx + dx
const ny = cy + dy
if (nx >= 8 && nx < W - 8 && ny >= 8 && ny < H - 8) {
const key = ny * W + nx
if (!visited.has(key) && isWalkable(nx, ny)) {
visited.add(key)
queue.push([nx, ny])
}
}
}
}
const reachableRatio = visited.size / totalWalkable
expect(reachableRatio).toBeGreaterThanOrEqual(0.90)
}
})
test('Act 5 Barricade palisades cut horizontally across X when height > width with gate at hubX', () => {
const pieces = makeMockBorderPieces('Act 5')
// 1. Vertical corridor (height > width): palisades run horizontally across X
const vertResult = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 48,
sizeY: 160,
subType: 0,
subTheme: 0,
seed: 111001,
pieces,
substitutions: [],
})
const vW = vertResult.level.width
const vH = vertResult.level.height
const vGridW = Math.floor(48 / 8)
const vInX = Math.floor(vGridW / 2)
const vHubX = vInX * 8 + 4
// Locate horizontal barricade rows (at least 15 palisade wall tiles across the row)
const horizontalBarricadeRows: number[] = []
for (let y = 8; y < vH - 8; y += 1) {
let count = 0
for (let x = 8; x < vW - 8; x += 1) {
const cell = vertResult.level.cells[y]![x]!
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
count += 1
}
}
if (count >= 15) {
horizontalBarricadeRows.push(y)
}
}
expect(horizontalBarricadeRows.length).toBeGreaterThanOrEqual(2)
// Verify each horizontal barricade row has an open choke-point gate at vHubX
for (const by of horizontalBarricadeRows) {
const gateCell = vertResult.level.cells[by]![vHubX]!
const hasWall = gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0)
expect(hasWall).toBe(false)
expect(gateCell.floors.some(f => (f.style === 0 || f.style === 6) && f.prop1 === 2)).toBe(true)
}
// 2. Horizontal corridor (width >= height): palisades run vertically across Y
const horizResult = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 160,
sizeY: 48,
subType: 0,
subTheme: 0,
seed: 111002,
pieces,
substitutions: [],
})
const hW = horizResult.level.width
const hH = horizResult.level.height
const hGridH = Math.floor(48 / 8)
const hOutY = Math.floor(hGridH / 2)
const hHubY = hOutY * 8 + 4
const verticalBarricadeCols: number[] = []
for (let x = 8; x < hW - 8; x += 1) {
let count = 0
for (let y = 8; y < hH - 8; y += 1) {
const cell = horizResult.level.cells[y]![x]!
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) {
count += 1
}
}
if (count >= 15) {
verticalBarricadeCols.push(x)
}
}
expect(verticalBarricadeCols.length).toBeGreaterThanOrEqual(2)
// Verify each vertical barricade col has an open choke-point gate at hHubY
for (const bx of verticalBarricadeCols) {
const gateCell = horizResult.level.cells[hHubY]![bx]!
const hasWall = gateCell.walls.some(w => w.prop1 !== 0 || w.style !== 0)
expect(hasWall).toBe(false)
expect(gateCell.floors.some(f => (f.style === 0 || f.style === 6) && f.prop1 === 2)).toBe(true)
}
})
test('Topographical jitter is bounded within +-0.05 and varies across seeds', () => {
const rng = new Rng(0x98765432)
const samples: number[] = []
for (let i = 0; i < 300; i += 1) {
const val = jitterFrac(rng, 0.50, 0.05)
expect(val).toBeGreaterThanOrEqual(0.45)
expect(val).toBeLessThanOrEqual(0.55)
samples.push(val)
}
const mean = samples.reduce((a, b) => a + b, 0) / samples.length
expect(Math.abs(mean - 0.50)).toBeLessThan(0.02)
const min = Math.min(...samples)
const max = Math.max(...samples)
expect(max - min).toBeGreaterThan(0.08)
// Verify that across different seeds, generated topographical feature counts vary organically
const pieces = makeMockBorderPieces('Act 2')
const seeds = [43001, 43002, 43003, 43004, 43005]
const results = seeds.map(s =>
generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: s,
pieces,
substitutions: [],
}),
)
const sandTrailStats = results.map(
r => (r.stats.act2Topography as Act2TopographyStats).sandTrailCells,
)
const oasisPoolStats = results.map(
r => (r.stats.act2Topography as Act2TopographyStats).oasisPoolCells,
)
// Confirm organic variation across runs
const uniqueSandTrails = new Set(sandTrailStats)
const uniqueOasisPools = new Set(oasisPoolStats)
expect(uniqueSandTrails.size).toBeGreaterThan(1)
expect(uniqueOasisPools.size).toBeGreaterThan(1)
})
})
})

View File

@ -1,280 +0,0 @@
import { describe, expect, test } from "vitest"
import {
resolveUnsetSize,
UnresolvedLevelSizeError,
generateWilderness,
type WildernessPiece,
} from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, floorStyle: number = 1): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
describe("Adversarial Stress Testing: resolveUnsetSize and Barricade Extent", () => {
test("resolveUnsetSize throws UnresolvedLevelSizeError when pieces array is empty for Level 111 and 112", () => {
for (const levelId of [111, 112]) {
expect(() => {
resolveUnsetSize({
levelId,
levelName: `Act 5 - Barricade ${levelId - 110}`,
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
}).toThrow(UnresolvedLevelSizeError)
}
})
test("resolveUnsetSize throws UnresolvedLevelSizeError for non-barricade levels without pieces across Acts 1..4", () => {
const testCases = [
{ levelId: 2, name: "Blood Moor", type: "Act 1 - Wilderness" },
{ levelId: 41, name: "Rocky Waste", type: "Act 2 - Desert" },
{ levelId: 76, name: "Spider Forest", type: "Act 3 - Jungle" },
{ levelId: 104, name: "Outer Steppes", type: "Act 4 - Mesa" },
]
for (const tc of testCases) {
expect(() => {
resolveUnsetSize({
levelId: tc.levelId,
levelName: tc.name,
levelTypeName: tc.type,
sizeX: -1,
sizeY: -1,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
}).toThrow(UnresolvedLevelSizeError)
}
})
test("resolveUnsetSize throws UnresolvedLevelSizeError for non-barricade levels with non-barricade pieces", () => {
const mockBorderPiece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [makeMockDs1(8, 8)],
}
expect(() => {
resolveUnsetSize({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: -1,
sizeY: -1,
subType: 6,
subTheme: 0,
seed: 12345,
pieces: [mockBorderPiece],
substitutions: [],
})
}).toThrow(UnresolvedLevelSizeError)
})
test("resolveUnsetSize handles combinations of entrance only, exit only, multiple entrances, and multiple exits", () => {
const entranceDs1 = makeMockDs1(16, 32)
const exitDs1 = makeMockDs1(16, 32)
const wallDs1 = makeMockDs1(17, 17)
// Entrance only
const entranceOnly: WildernessPiece[] = [
{ name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] },
{ name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] },
]
const resEntranceOnly = resolveUnsetSize({
levelId: 111,
levelName: "Act 5 - Barricade 1",
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: entranceOnly,
substitutions: [],
})
expect(resEntranceOnly.sizeY).toBeGreaterThan(resEntranceOnly.sizeX)
expect(resEntranceOnly.sizeX).toBeGreaterThanOrEqual(48)
expect(resEntranceOnly.sizeX).toBeLessThanOrEqual(64)
expect(resEntranceOnly.sizeY).toBeGreaterThanOrEqual(160)
expect(resEntranceOnly.sizeY).toBeLessThanOrEqual(192)
// Exit only
const exitOnly: WildernessPiece[] = [
{ name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] },
{ name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] },
]
const resExitOnly = resolveUnsetSize({
levelId: 111,
levelName: "Act 5 - Barricade 1",
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: exitOnly,
substitutions: [],
})
expect(resExitOnly.sizeY).toBeGreaterThan(resExitOnly.sizeX)
expect(resExitOnly.sizeX).toBeGreaterThanOrEqual(48)
expect(resExitOnly.sizeX).toBeLessThanOrEqual(64)
expect(resExitOnly.sizeY).toBeGreaterThanOrEqual(160)
expect(resExitOnly.sizeY).toBeLessThanOrEqual(192)
// Multiple entrances & exits
const multiplePieces: WildernessPiece[] = [
{ name: "Act 5 - Barricade Entrance A", border: false, levels: [entranceDs1] },
{ name: "Act 5 - Barricade Entrance B", border: false, levels: [makeMockDs1(24, 40)] },
{ name: "Act 5 - Barricade Exit A", border: false, levels: [exitDs1] },
{ name: "Act 5 - Barricade Exit B", border: false, levels: [makeMockDs1(32, 48)] },
{ name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] },
]
const resMultiple = resolveUnsetSize({
levelId: 111,
levelName: "Act 5 - Barricade 1",
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: multiplePieces,
substitutions: [],
})
expect(resMultiple.sizeY).toBeGreaterThan(resMultiple.sizeX)
expect(resMultiple.sizeX).toBeGreaterThanOrEqual(48)
expect(resMultiple.sizeX).toBeLessThanOrEqual(64)
expect(resMultiple.sizeY).toBeGreaterThanOrEqual(160)
expect(resMultiple.sizeY).toBeLessThanOrEqual(192)
})
test("resolveUnsetSize guarantees sizeY > sizeX across 1,000 combinatorial fuzzing trials", () => {
const mockWallPiece: WildernessPiece = {
name: "Act 5 - Barricade Wall 1",
border: false,
levels: [makeMockDs1(17, 17)],
}
for (let trial = 0; trial < 1000; trial += 1) {
const numEntrances = trial % 5
const numExits = (trial * 3) % 5
const pieces: WildernessPiece[] = [mockWallPiece]
for (let i = 0; i < numEntrances; i += 1) {
const w = 1 + ((trial * 7 + i * 11) % 128)
const h = 1 + ((trial * 13 + i * 17) % 256)
pieces.push({
name: `Act 5 - Barricade Entrance ${i}`,
border: false,
levels: [makeMockDs1(w, h)],
})
}
for (let i = 0; i < numExits; i += 1) {
const w = 1 + ((trial * 19 + i * 23) % 128)
const h = 1 + ((trial * 29 + i * 31) % 256)
pieces.push({
name: `Act 5 - Barricade Exit ${i}`,
border: false,
levels: [makeMockDs1(w, h)],
})
}
const res = resolveUnsetSize({
levelId: 111,
levelName: "Act 5 - Barricade 1",
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: trial,
pieces,
substitutions: [],
})
expect(res.sizeX).toBeGreaterThanOrEqual(48)
expect(res.sizeX).toBeLessThanOrEqual(64)
expect(res.sizeX % 8).toBe(0)
expect(res.sizeY).toBeGreaterThanOrEqual(160)
expect(res.sizeY).toBeLessThanOrEqual(192)
expect(res.sizeY % 8).toBe(0)
expect(res.sizeY).toBeGreaterThan(res.sizeX)
expect(res.sizeY / res.sizeX).toBeGreaterThanOrEqual(2.5)
expect(res.source).toBe("LvlPrest Barricade corridor dynamic extent")
}
})
test("generateWilderness generates Level 111 and 112 conforming to vertical corridor bounds", () => {
const entranceDs1 = makeMockDs1(17, 33)
const exitDs1 = makeMockDs1(17, 33)
const wallDs1 = makeMockDs1(17, 17)
const cliffDs1 = makeMockDs1(17, 17)
const pieces: WildernessPiece[] = [
{ name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] },
{ name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] },
{ name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] },
{ name: "Act 5 - Barricade 2", border: false, levels: [wallDs1] },
{ name: "Act 5 - Barricade Cliff Border 1", border: true, levels: [cliffDs1] },
]
for (const levelId of [111, 112]) {
const generated = generateWilderness({
levelId,
levelName: `Act 5 - Barricade ${levelId - 110}`,
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 0x301cd095 + levelId,
pieces,
substitutions: [],
})
expect(Number(generated.stats.sizeX)).toBeGreaterThanOrEqual(48)
expect(Number(generated.stats.sizeX)).toBeLessThanOrEqual(64)
expect(Number(generated.stats.sizeY)).toBeGreaterThanOrEqual(160)
expect(Number(generated.stats.sizeY)).toBeLessThanOrEqual(192)
expect(Number(generated.stats.sizeY)).toBeGreaterThan(Number(generated.stats.sizeX))
expect(generated.level.width).toBe(Number(generated.stats.sizeX))
expect(generated.level.height).toBe(Number(generated.stats.sizeY))
}
})
})

View File

@ -1,426 +0,0 @@
import { describe, expect, test } from "vitest"
import {
classifyBorderPiece,
getCellOrientation,
selectBorderPiece,
selectBorderVariant,
isClosedBorder,
getPerimeterOpenings,
DRLGOUTPLACE_PlaceAct1245OutdoorBorders,
layBorder,
generateWilderness,
type WildernessPiece,
type WildernessStats,
type BorderOrientation,
} from "../src/game/wilderness.ts"
import { createCanvas } from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockBorderDs1(width: number, height: number, orientation: BorderOrientation, isOpen: boolean): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
let isWall: boolean
if (isOpen) {
// Open variant: gap in the middle of the border edge
if (orientation === "south" || orientation === "north") {
isWall = x !== Math.floor(width / 2)
} else if (orientation === "west" || orientation === "east") {
isWall = y !== Math.floor(height / 2)
} else {
isWall = true
}
} else {
// Closed variant: continuous wall along the border
isWall = true
}
row.push({
walls: isWall ? [{ prop1: 129, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function createEmptyStats(): WildernessStats {
return {
substitutions: [],
borderPieces: {},
unresolved: [],
notes: [],
borderStamped: 0,
groundCells: 0,
groundTile: null,
sizeSource: "test",
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
proceduralRoadCells: 0,
proceduralDividerCells: 0,
proceduralWaterCells: 0,
proceduralWallCells: 0,
}
}
describe("Issue #67: DRLGOUTPLACE_PlaceAct1245OutdoorBorders & Multi-Act Border Classification", () => {
describe("Task 1A: classifyBorderPiece across Acts 1, 2, 3, 4, 5", () => {
test("Act 1: Wild Border 1..12 and Wild Cliff Border 2..10 (including 6A-C and corner 10)", () => {
// Edges: 1=S, 2=W, 3=N, 4=E
expect(classifyBorderPiece("Act 1 - Wild Border 1")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 2")).toEqual({ orientation: "west", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 3")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 4")).toEqual({ orientation: "east", isCliff: false })
// Corners: 5/9=SW, 6/10=NW, 7/11=NE, 8/12=SE
expect(classifyBorderPiece("Act 1 - Wild Border 5")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 6")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 7")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 8")).toEqual({ orientation: "corner-se", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 9")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 10")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 11")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Border 12")).toEqual({ orientation: "corner-se", isCliff: false })
// Wild Cliff borders: isCliff: true
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 2")).toEqual({ orientation: "west", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 3")).toEqual({ orientation: "north", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6A")).toEqual({ orientation: "corner-nw", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6B")).toEqual({ orientation: "corner-nw", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 6C")).toEqual({ orientation: "corner-nw", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 7")).toEqual({ orientation: "corner-ne", isCliff: true })
expect(classifyBorderPiece("Act 1 - Wild Cliff Border 10")).toEqual({ orientation: "corner-nw", isCliff: true })
// Caves are excluded from generic borders
expect(classifyBorderPiece("Act 1 - Wild Cliff Cave Right")).toEqual({ orientation: null, isCliff: false })
expect(classifyBorderPiece("Act 1 - Wild Cliff Cave Left")).toEqual({ orientation: null, isCliff: false })
})
test("Act 2: Desert Border 1..12 and Desert Cliff Left/Right/Top/Tomb variants", () => {
// Desert Border 1..12
expect(classifyBorderPiece("Act 2 - Desert Border 1")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 2")).toEqual({ orientation: "west", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 3")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 4")).toEqual({ orientation: "east", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 5")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 6")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 7")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 8")).toEqual({ orientation: "corner-se", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 9")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 10")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 11")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 2 - Desert Border 12")).toEqual({ orientation: "corner-se", isCliff: false })
// Desert Cliff Left / Right / Top variants
expect(classifyBorderPiece("Act 2 - Desert Cliff Left Wall")).toEqual({ orientation: "west", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Left Path")).toEqual({ orientation: "west", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Left Ends")).toEqual({ orientation: "west", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Left King Tomb")).toEqual({ orientation: "west", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Right Wall")).toEqual({ orientation: "east", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Right Path")).toEqual({ orientation: "east", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Right Ends")).toEqual({ orientation: "east", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Right King Tomb")).toEqual({ orientation: "east", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Top")).toEqual({ orientation: "north", isCliff: true })
expect(classifyBorderPiece("Act 2 - Desert Cliff Top King Tomb")).toEqual({ orientation: "north", isCliff: true })
})
test("Act 3: Slums/Burbs/Metro Border cardinal directions (N, S, E, W, NE, NW, SE, SW)", () => {
expect(classifyBorderPiece("Act 3 - Slums Border N")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border S")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border E")).toEqual({ orientation: "east", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border W")).toEqual({ orientation: "west", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border NE")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border NW")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border SE")).toEqual({ orientation: "corner-se", isCliff: false })
expect(classifyBorderPiece("Act 3 - Slums Border SW")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 3 - Burbs Border N")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 3 - Burbs Border SW")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 3 - Metro Border E")).toEqual({ orientation: "east", isCliff: false })
expect(classifyBorderPiece("Act 3 - Metro Border SE")).toEqual({ orientation: "corner-se", isCliff: false })
})
test("Act 4: Mesa Border 1..12", () => {
expect(classifyBorderPiece("Act 4 - Mesa Border 1")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 2")).toEqual({ orientation: "west", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 3")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 4")).toEqual({ orientation: "east", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 5")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 6")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 7")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 8")).toEqual({ orientation: "corner-se", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 9")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 10")).toEqual({ orientation: "corner-nw", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 11")).toEqual({ orientation: "corner-ne", isCliff: false })
expect(classifyBorderPiece("Act 4 - Mesa Border 12")).toEqual({ orientation: "corner-se", isCliff: false })
})
test("Act 5: Barricade Cliff Border 1..12, Barricade Ravine Border 1..6, and Snow variants", () => {
// Barricade Cliff: isCliff: true
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 1")).toEqual({ orientation: "south", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 2")).toEqual({ orientation: "west", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 3")).toEqual({ orientation: "north", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 4")).toEqual({ orientation: "east", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 6")).toEqual({ orientation: "corner-nw", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 7")).toEqual({ orientation: "corner-ne", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 8")).toEqual({ orientation: "corner-se", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 9")).toEqual({ orientation: "corner-sw", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 10")).toEqual({ orientation: "corner-nw", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 11")).toEqual({ orientation: "corner-ne", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 12")).toEqual({ orientation: "corner-se", isCliff: true })
// Barricade Ravine: isCliff: false
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 1")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 2")).toEqual({ orientation: "west", isCliff: false })
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 3")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 4")).toEqual({ orientation: "east", isCliff: false })
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 5")).toEqual({ orientation: "corner-sw", isCliff: false })
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 6")).toEqual({ orientation: "corner-nw", isCliff: false })
// Corner join / transition pieces
expect(classifyBorderPiece("Act 5 - Barricade Ravine-Cliff Border 5")).toEqual({ orientation: "corner-sw", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Cliff Ravine Border 7")).toEqual({ orientation: "corner-ne", isCliff: true })
// Snow variants
expect(classifyBorderPiece("Act 5 - Barricade Cliff Border 1 Snow")).toEqual({ orientation: "south", isCliff: true })
expect(classifyBorderPiece("Act 5 - Barricade Ravine Border 1 Snow")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 5 - Snow Border 1")).toEqual({ orientation: "south", isCliff: false })
expect(classifyBorderPiece("Act 5 - Snow Border 2")).toEqual({ orientation: "west", isCliff: false })
expect(classifyBorderPiece("Act 5 - Snow Border 3")).toEqual({ orientation: "north", isCliff: false })
expect(classifyBorderPiece("Act 5 - Snow Border 4")).toEqual({ orientation: "east", isCliff: false })
expect(classifyBorderPiece("Act 5 - Snow Border 6")).toEqual({ orientation: "corner-nw", isCliff: false })
})
})
describe("Task 1B: selectBorderPiece cliff preference", () => {
test("prefers cliff pieces when preferCliff is true and non-cliff when false", () => {
const cliffPiece: WildernessPiece = {
name: "Act 1 - Wild Cliff Border 3",
border: true,
levels: [makeMockBorderDs1(8, 8, "north", false)],
}
const fencePiece: WildernessPiece = {
name: "Act 1 - Wild Border 3",
border: true,
levels: [makeMockBorderDs1(8, 8, "north", false)],
}
const classified = [
{ piece: cliffPiece, orientation: "north" as const, isCliff: true },
{ piece: fencePiece, orientation: "north" as const, isCliff: false },
]
const rng = new Rng(100)
const pickedCliff = selectBorderPiece("north", classified, rng, true)
expect(pickedCliff?.name).toBe("Act 1 - Wild Cliff Border 3")
const pickedFence = selectBorderPiece("north", classified, rng, false)
expect(pickedFence?.name).toBe("Act 1 - Wild Border 3")
})
test("falls back gracefully when only one cliff status is available", () => {
const fencePiece: WildernessPiece = {
name: "Act 4 - Mesa Border 3",
border: true,
levels: [makeMockBorderDs1(8, 8, "north", false)],
}
const classified = [{ piece: fencePiece, orientation: "north" as const, isCliff: false }]
const rng = new Rng(200)
// Even if preferCliff is true, falls back to fencePiece
const picked = selectBorderPiece("north", classified, rng, true)
expect(picked?.name).toBe("Act 4 - Mesa Border 3")
})
})
describe("Task 1C & 2: DRLGOUTPLACE_PlaceAct1245OutdoorBorders & layBorder outer ring coverage", () => {
function buildMultiActPieces(prefix: string, hasCliffs = false): WildernessPiece[] {
const pieces: WildernessPiece[] = []
// Numbers 1..8
for (let i = 1; i <= 8; i += 1) {
const orientation = i === 1 ? "south" : i === 2 ? "west" : i === 3 ? "north" : i === 4 ? "east" :
i === 5 ? "corner-sw" : i === 6 ? "corner-nw" : i === 7 ? "corner-ne" : "corner-se"
const closedDs1 = makeMockBorderDs1(8, 8, orientation, false)
const openDs1 = makeMockBorderDs1(8, 8, orientation, true)
pieces.push({
name: `${prefix} Border ${i}`,
border: true,
// 4 variants: 0..2 closed, 3 open
levels: [closedDs1, closedDs1, closedDs1, openDs1],
})
}
if (hasCliffs) {
// Add cliff pieces for north and west
pieces.push({
name: `${prefix} Cliff Border 2`,
border: true,
levels: [makeMockBorderDs1(8, 8, "west", false)],
})
pieces.push({
name: `${prefix} Cliff Border 3`,
border: true,
levels: [makeMockBorderDs1(8, 8, "north", false)],
})
}
return pieces
}
test.each([
{ act: 1, name: "Act 1 - Wild", hasCliffs: true },
{ act: 2, name: "Act 2 - Desert", hasCliffs: false },
{ act: 4, name: "Act 4 - Mesa", hasCliffs: false },
{ act: 5, name: "Act 5 - Barricade", hasCliffs: true },
])("Act $act ($name): 100% outer ring coverage with 0 procedural fallback walls", ({ name, hasCliffs }) => {
const gridWidth = 8
const gridHeight = 8
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0)
const pieces = buildMultiActPieces(name, hasCliffs)
const stats = createEmptyStats()
const rng = new Rng(42)
const stamped = DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
canvas,
pieces,
gridWidth,
gridHeight,
rng,
stats,
2, // Blood Moor level ID with South and East openings
)
// Expected ring block count: 2 * (w + h) - 4 = 2 * (8 + 8) - 4 = 28 blocks
const expectedRingBlocks = 2 * (gridWidth + gridHeight) - 4
expect(stamped).toBe(expectedRingBlocks)
expect(stats.borderStamped).toBe(expectedRingBlocks)
expect(stats.proceduralWallCells).toBe(0)
// Verify every perimeter cell on the canvas has floor and valid wall placement
for (let x = 0; x < canvas.width; x += 1) {
// North edge (y=0) and South edge (y=canvas.height-1)
expect(canvas.cells[0]?.[x]?.floors.length).toBeGreaterThan(0)
expect(canvas.cells[canvas.height - 1]?.[x]?.floors.length).toBeGreaterThan(0)
}
for (let y = 0; y < canvas.height; y += 1) {
// West edge (x=0) and East edge (x=canvas.width-1)
expect(canvas.cells[y]?.[0]?.floors.length).toBeGreaterThan(0)
expect(canvas.cells[y]?.[canvas.width - 1]?.floors.length).toBeGreaterThan(0)
}
})
test("Opening blocks select Open DS1 variants while closed blocks select Closed DS1 variants", () => {
const gridWidth = 8
const gridHeight = 8
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0)
const pieces = buildMultiActPieces("Act 1 - Wild", true)
const stats = createEmptyStats()
const rng = new Rng(12345)
// Level 2 has default entrance on South edge (mid-X, y=7) and East edge (x=7, mid-Y)
DRLGOUTPLACE_PlaceAct1245OutdoorBorders(canvas, pieces, gridWidth, gridHeight, rng, stats, 2)
const midX = Math.floor(gridWidth / 2) // block (4, 7)
const midY = Math.floor(gridHeight / 2) // block (7, 4)
// South opening block: cell (4*8 + 4, 7*8 + y) should have the open gateway gap
const southOpenCenterCell = canvas.cells[7 * 8 + 4]?.[midX * 8 + 4]
expect(southOpenCenterCell).toBeDefined()
// Gateway center in open variant has no wall
expect(southOpenCenterCenterWallCount(canvas, midX * 8, 7 * 8, 8, 8)).toBe(false)
// Closed South block: cell (1*8, 7*8) should have solid continuous wall
expect(isBlockClosed(canvas, 1 * 8, 7 * 8, 8, 8)).toBe(true)
})
function southOpenCenterCenterWallCount(canvas: any, bx: number, by: number, w: number, h: number): boolean {
const centerX = bx + Math.floor(w / 2)
// Check if all cells in column centerX of this block are walls
for (let y = 0; y < h; y += 1) {
if (!canvas.cells[by + y]?.[centerX]?.walls.some((w: any) => !w.hidden && w.prop1 !== 0)) {
return false // There is an open path through this column!
}
}
return true
}
function isBlockClosed(canvas: any, bx: number, by: number, w: number, h: number): boolean {
for (let y = 0; y < h; y += 1) {
let hasWallRow = true
for (let x = 0; x < w; x += 1) {
if (!canvas.cells[by + y]?.[bx + x]?.walls.some((w: any) => !w.hidden && w.prop1 !== 0)) {
hasWallRow = false
break
}
}
if (hasWallRow) return true
}
return false
}
test("Docking support: claimedBorderBlocks are skipped during border stamping", () => {
const gridWidth = 8
const gridHeight = 8
const canvas = createCanvas(gridWidth * 8, gridHeight * 8, 1, 1, 0)
const pieces = buildMultiActPieces("Act 1 - Wild", false)
const stats = createEmptyStats()
const rng = new Rng(777)
// Claim 3 blocks along the West edge for a Town Transition / Facade docking
const claimed = new Set(["0,2", "0,3", "0,4"])
const stamped = DRLGOUTPLACE_PlaceAct1245OutdoorBorders(
canvas,
pieces,
gridWidth,
gridHeight,
rng,
stats,
2,
undefined,
claimed,
)
// 28 total perimeter blocks - 3 claimed blocks = 25 stamped blocks
expect(stamped).toBe(25)
expect(stats.borderStamped).toBe(25)
// Verified claimed blocks were skipped: canvas floor/walls at block (0, 3) were NOT painted by border
expect(canvas.cells[3 * 8]?.[0]?.walls[0]?.style).toBe(0)
expect(canvas.cells[1 * 8]?.[0]?.walls[0]?.style).toBe(1)
})
test("layBorder is an exported alias for DRLGOUTPLACE_PlaceAct1245OutdoorBorders", () => {
expect(layBorder).toBe(DRLGOUTPLACE_PlaceAct1245OutdoorBorders)
})
test("getCellOrientation correctly maps 4 corners and 4 edges", () => {
expect(getCellOrientation(0, 0, 10, 10)).toBe("corner-nw")
expect(getCellOrientation(9, 0, 10, 10)).toBe("corner-ne")
expect(getCellOrientation(9, 9, 10, 10)).toBe("corner-se")
expect(getCellOrientation(0, 9, 10, 10)).toBe("corner-sw")
expect(getCellOrientation(5, 0, 10, 10)).toBe("north")
expect(getCellOrientation(9, 5, 10, 10)).toBe("east")
expect(getCellOrientation(5, 9, 10, 10)).toBe("south")
expect(getCellOrientation(0, 5, 10, 10)).toBe("west")
})
})
})

View File

@ -1,318 +0,0 @@
import { describe, expect, test } from "vitest"
import {
isClosedBorder,
selectBorderVariant,
getPerimeterOpenings,
SPECIAL_PRESETS_BY_LEVEL,
generateWilderness,
resolveUnsetSize,
UnresolvedLevelSizeError,
} from "../src/game/wilderness.ts"
import type { WildernessPiece } from "../src/game/wilderness.ts"
import { Rng } from "../src/game/rng.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
describe("Wilderness Features & Border Rules", () => {
test("isClosedBorder identifies continuous vs open wall presets", () => {
const closed = makeMockDs1(8, 8, false)
const open = makeMockDs1(8, 8, true)
expect(isClosedBorder(closed, "south")).toBe(true)
expect(isClosedBorder(closed, "north")).toBe(true)
expect(isClosedBorder(open, "south")).toBe(false)
expect(isClosedBorder(open, "north")).toBe(false)
})
test("getPerimeterOpenings enforces 0 exits for Cow Level and 1 entrance/1 exit for Blood Moor", () => {
const cowOpenings = getPerimeterOpenings(39, 10, 10)
expect(cowOpenings.size).toBe(0)
const bloodMoorOpenings = getPerimeterOpenings(2, 10, 10)
expect(bloodMoorOpenings.size).toBe(2)
expect(bloodMoorOpenings.has("5,9")).toBe(true)
expect(bloodMoorOpenings.has("9,5")).toBe(true)
})
test("getPerimeterOpenings defaults to open borders for Act 3 Jungles and outdoor levels", () => {
// Act 3: Spider Forest (76), Great Marsh (77), Flayer Jungle (78)
const spiderForestOpenings = getPerimeterOpenings(76, 10, 10)
expect(spiderForestOpenings.size).toBe(2)
expect(spiderForestOpenings.has("5,9")).toBe(true)
expect(spiderForestOpenings.has("9,5")).toBe(true)
// Act 5: Arreat Plateau (113)
const arreatOpenings = getPerimeterOpenings(113, 10, 10)
expect(arreatOpenings.size).toBe(2)
})
test("getPerimeterOpenings maps planned gates to perimeter block coordinates", () => {
const plannedGates = [
{ side: 'north' as const, toLevelId: 75, label: 'To Town' },
{ side: 'east' as const, toLevelId: 77, label: 'To Marsh' },
]
const openings = getPerimeterOpenings(76, 12, 14, plannedGates)
expect(openings.size).toBe(2)
expect(openings.has("6,0")).toBe(true) // north: (12/2, 0)
expect(openings.has("11,7")).toBe(true) // east: (12-1, 14/2)
})
test("selectBorderVariant picks open variant for openings and closed variant for closed walls", () => {
const closedDs1 = makeMockDs1(8, 8, false)
const openDs1 = makeMockDs1(8, 8, true)
const piece: WildernessPiece = {
name: "Act 1 - Wild Border 1",
border: true,
levels: [closedDs1, openDs1],
}
const rng = new Rng(12345)
const pickedOpen = selectBorderVariant(piece, "south", true, rng)
expect(isClosedBorder(pickedOpen, "south")).toBe(false)
const pickedClosed = selectBorderVariant(piece, "south", false, rng)
expect(isClosedBorder(pickedClosed, "south")).toBe(true)
})
test("SPECIAL_PRESETS_BY_LEVEL has no Act I outdoor entries (the DRLG port places those presets)", () => {
for (const levelId of [2, 3, 4, 5, 6, 7, 17, 39]) {
expect(SPECIAL_PRESETS_BY_LEVEL[levelId]).toBeUndefined()
}
expect(SPECIAL_PRESETS_BY_LEVEL[41]).toContain("Act 2 - Desert Tomb 1")
})
test("Act 5 Bloody Foothills (Level 110) stamps 15 canonical siege strips in West-to-East order", () => {
const stripNames = [
"Act 5 - Siege To Barricade",
"Act 5 - Siege Strip 13",
"Act 5 - Siege Strip 12",
"Act 5 - Siege Strip 11",
"Act 5 - Siege Strip 10",
"Act 5 - Siege Strip 9",
"Act 5 - Siege Strip 8",
"Act 5 - Siege Strip 7",
"Act 5 - Siege Strip 6",
"Act 5 - Siege Strip 5",
"Act 5 - Siege Strip 4",
"Act 5 - Siege Strip 3",
"Act 5 - Siege Strip 2",
"Act 5 - Siege Strip 1",
"Act 5 - Siege To Town",
]
const pieces: WildernessPiece[] = stripNames.map((name, idx) => {
const baseDs1 = makeMockDs1(17, 49, false)
// Create a copy with tagged floor sequence at (0,0) and one object
const cells = baseDs1.cells.map((row, y) =>
row.map((cell, x) =>
x === 0 && y === 0
? {
...cell,
floors: [{ ...cell.floors[0], sequence: idx + 10 }],
}
: cell,
),
)
const ds1: Ds1 = {
...baseDs1,
cells,
objects: [
{
type: 2,
id: 100 + idx,
x: 5,
y: 5,
flags: 0,
},
],
}
return {
name,
border: false,
levels: [ds1],
}
})
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 })
expect(result.stats.substitutedClusters).toBe(15)
expect(result.level.width).toBe(240)
expect(result.level.height).toBe(48)
expect(result.level.objects.filter(o => o.type === 2).length).toBe(15)
expect(result.level.objects.length).toBe(17)
// Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
expect(result.level.cells[0][224].floors[0].sequence).toBe(24)
})
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
const names = [
"Act 3 - Travincal NW",
"Act 3 - Travincal N ", // trailing space as in LvlPrest.txt
"Act 3 - Travincal NE",
"Act 3 - Travincal SW",
"Act 3 - Travincal S ", // trailing space as in LvlPrest.txt
"Act 3 - Travincal SE",
]
const pieces: WildernessPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(name.includes(" N ") || name.includes(" S ") ? 33 : 17, 33, false)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, border: false, levels: [ds1] }
})
const result = generateWilderness({
levelId: 83,
levelName: "Travincal",
levelTypeName: "Act 3 - Kurast",
sizeX: 64,
sizeY: 64,
subType: -1,
subTheme: -1,
seed: 83,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(64)
expect(result.level.height).toBe(64)
expect(result.stats.substitutedClusters).toBe(6)
expect(result.level.objects.length).toBe(6)
})
test("resolveUnsetSize dynamically derives vertical corridor dimensions for Level 111 and 112", () => {
const entranceDs1 = makeMockDs1(17, 33, false)
const exitDs1 = makeMockDs1(17, 33, false)
const wallDs1 = makeMockDs1(17, 17, false)
const cliffDs1 = makeMockDs1(17, 17, false)
const pieces: WildernessPiece[] = [
{ name: "Act 5 - Barricade Entrance 16x32", border: false, levels: [entranceDs1] },
{ name: "Act 5 - Barricade Exit 16x32", border: false, levels: [exitDs1] },
{ name: "Act 5 - Barricade 1", border: false, levels: [wallDs1] },
{ name: "Act 5 - Barricade 2", border: false, levels: [wallDs1] },
{ name: "Act 5 - Barricade Cliff Border 1", border: true, levels: [cliffDs1] },
]
for (const levelId of [111, 112]) {
const resolved = resolveUnsetSize({
levelId,
levelName: `Act 5 - Barricade ${levelId - 110}`,
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces,
substitutions: [],
})
expect(resolved.sizeX).toBeGreaterThanOrEqual(48)
expect(resolved.sizeX).toBeLessThanOrEqual(64)
expect(resolved.sizeY).toBeGreaterThanOrEqual(160)
expect(resolved.sizeY).toBeLessThanOrEqual(192)
expect(resolved.sizeY).toBeGreaterThan(resolved.sizeX)
expect(resolved.sizeY / resolved.sizeX).toBeGreaterThanOrEqual(2.5)
expect(resolved.source).toBe("LvlPrest Barricade corridor dynamic extent")
}
})
test("resolveUnsetSize throws UnresolvedLevelSizeError when pieces are absent (anti-silent failure)", () => {
expect(() => {
resolveUnsetSize({
levelId: 111,
levelName: "Act 5 - Barricade 1",
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
subType: 10,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
}).toThrow(UnresolvedLevelSizeError)
})
test("resolveUnsetSize throws UnresolvedLevelSizeError when non-barricade level lacks barricade pieces", () => {
const mockDs1 = makeMockDs1(8, 8, false)
expect(() => {
resolveUnsetSize({
levelId: 2,
levelName: "Blood Moor",
levelTypeName: "Act 1 - Wilderness",
sizeX: -1,
sizeY: -1,
subType: 6,
subTheme: 0,
seed: 12345,
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [mockDs1] }],
substitutions: [],
})
}).toThrow(UnresolvedLevelSizeError)
})
test("resolveUnsetSize respects sizeOverride when provided", () => {
const resolved = resolveUnsetSize({
levelId: 111,
levelName: "Act 5 - Barricade 1",
levelTypeName: "Act 5 - Barricade",
sizeX: -1,
sizeY: -1,
sizeOverride: { x: 80, y: 120 },
subType: 10,
subTheme: 0,
seed: 12345,
pieces: [],
substitutions: [],
})
expect(resolved.sizeX).toBe(80)
expect(resolved.sizeY).toBe(120)
expect(resolved.source).toBe("sizeOverride")
})
})

View File

@ -1,705 +0,0 @@
import { describe, expect, test } from "vitest"
import {
fillGround,
sampleGroundTile,
deriveGroundTilesFromDt1Libraries,
deriveGroundTiles,
deriveGroundTile,
cellHash,
smoothNoise2D,
createCanvas,
generateWilderness,
CANONICAL_GROUND_TILES,
CANONICAL_GROUND_POOLS,
DRLGROOMTILE_GetTileCache,
} from "../src/game/wilderness.ts"
import type {
WeightedGroundTile,
WildernessPiece,
TileCacheQuery,
TileCacheResult,
} from "../src/game/wilderness.ts"
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(
width: number,
height: number,
floorStyle = 0,
floorSequence = 0,
customFloor?: (x: number, y: number) => { readonly style: number; readonly sequence: number } | undefined,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const custom = customFloor?.(x, y)
const style = custom ? custom.style : floorStyle
const sequence = custom ? custom.sequence : floorSequence
row.push({
walls: [],
floors: [{ prop1: 2, sequence, style, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockDt1(tiles: (Partial<Dt1Tile> & { rarity?: number; Rarity?: number })[]): Dt1 {
return {
warnings: [],
versionMajor: 7,
versionMinor: 6,
tiles: tiles.map(t => ({
direction: 0,
height: -80,
width: 160,
type: t.type ?? 0,
style: t.style ?? 0,
sequence: t.sequence ?? 0,
materialFlags: 0,
roofHeight: 0,
animated: t.animated ?? 0,
rarityFrameIndex: t.rarityFrameIndex ?? 1,
subTileFlags: [],
minBlockY: 0,
bitmapHeight: 80,
blocks: [],
...t,
})),
}
}
function makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
]
}
describe("Wilderness Ground Tile Blending (Issue #52)", () => {
describe("PRNG & Noise Primitives", () => {
test("cellHash produces deterministic values in [0, 1)", () => {
const v1 = cellHash(10, 20, 12345)
const v2 = cellHash(10, 20, 12345)
const v3 = cellHash(10, 20, 99999)
const v4 = cellHash(11, 20, 12345)
expect(v1).toBe(v2)
expect(v1).toBeGreaterThanOrEqual(0)
expect(v1).toBeLessThan(1)
expect(v1).not.toBe(v3)
expect(v1).not.toBe(v4)
})
test("smoothNoise2D produces values in [0, 1] with spatial smoothness", () => {
const n0 = smoothNoise2D(10, 10, 8, 42)
const n1 = smoothNoise2D(10.1, 10.1, 8, 42)
const nFar = smoothNoise2D(50, 50, 8, 42)
expect(n0).toBeGreaterThanOrEqual(0)
expect(n0).toBeLessThanOrEqual(1)
// Small step produces small difference
expect(Math.abs(n1 - n0)).toBeLessThan(0.1)
expect(nFar).toBeGreaterThanOrEqual(0)
expect(nFar).toBeLessThanOrEqual(1)
})
})
describe("Single-Tile Fallback (Backward Compatibility)", () => {
test("fillGround with a single tile object fills entire area uniformly", () => {
const canvas = createCanvas(20, 20, 1, 1, 0)
const written = fillGround(canvas, { style: 3, sequence: 7 }, 0, 0, 20, 20)
expect(written).toBe(400)
for (let y = 0; y < 20; y += 1) {
for (let x = 0; x < 20; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style).toBe(3)
expect(floor.sequence).toBe(7)
expect(floor.hidden).toBe(false)
}
}
})
test("fillGround with a 1-element array fills entire area uniformly", () => {
const canvas = createCanvas(15, 15, 1, 1, 0)
const written = fillGround(canvas, [{ style: 1, sequence: 4, weight: 100 }], 0, 0, 15, 15)
expect(written).toBe(225)
for (let y = 0; y < 15; y += 1) {
for (let x = 0; x < 15; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style).toBe(1)
expect(floor.sequence).toBe(4)
}
}
})
test("sampleGroundTile with a single tile returns it directly", () => {
const single = { style: 2, sequence: 5 }
expect(sampleGroundTile(single, 10, 10, 123)).toEqual(single)
const singleArr = [{ style: 4, sequence: 9, weight: 1 }]
expect(sampleGroundTile(singleArr, 10, 10, 123)).toEqual(singleArr[0])
})
})
describe("Multi-Tile Ground Blending & Weight Adherence", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 70 },
{ style: 0, sequence: 1, weight: 20 },
{ style: 0, sequence: 2, weight: 10 },
]
test("fillGround in spatial mode generates all candidate tiles and respects weight ratios", () => {
const width = 60
const height = 60
const totalCells = width * height
const canvas = createCanvas(width, height, 1, 1, 0)
fillGround(canvas, candidates, 0, 0, width, height, {
seed: 424242,
mode: "spatial",
spatialScale: 6,
spatialCoherence: 0.6,
})
const counts = new Map<number, number>()
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
counts.set(seq, (counts.get(seq) ?? 0) + 1)
}
}
// All candidate sequences must be present
expect(counts.has(0)).toBe(true)
expect(counts.has(1)).toBe(true)
expect(counts.has(2)).toBe(true)
const c0 = counts.get(0)! / totalCells
const c1 = counts.get(1)! / totalCells
const c2 = counts.get(2)! / totalCells
// Expected: ~70% sequence 0, ~20% sequence 1, ~10% sequence 2
// Spatial noise percentile ranking guarantees global proportions very close to exact weights
expect(c0).toBeGreaterThanOrEqual(0.60)
expect(c0).toBeLessThanOrEqual(0.80)
expect(c1).toBeGreaterThanOrEqual(0.12)
expect(c1).toBeLessThanOrEqual(0.28)
expect(c2).toBeGreaterThanOrEqual(0.04)
expect(c2).toBeLessThanOrEqual(0.18)
})
test("fillGround in CDF mode generates all candidate tiles and respects weight ratios", () => {
const width = 60
const height = 60
const totalCells = width * height
const canvas = createCanvas(width, height, 1, 1, 0)
fillGround(canvas, candidates, 0, 0, width, height, {
seed: 88888,
mode: "cdf",
})
const counts = new Map<number, number>()
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
counts.set(seq, (counts.get(seq) ?? 0) + 1)
}
}
expect(counts.has(0)).toBe(true)
expect(counts.has(1)).toBe(true)
expect(counts.has(2)).toBe(true)
const c0 = counts.get(0)! / totalCells
const c1 = counts.get(1)! / totalCells
const c2 = counts.get(2)! / totalCells
// Law of large numbers for 3600 samples: within +-8% of true probabilities
expect(c0).toBeGreaterThanOrEqual(0.62)
expect(c0).toBeLessThanOrEqual(0.78)
expect(c1).toBeGreaterThanOrEqual(0.14)
expect(c1).toBeLessThanOrEqual(0.26)
expect(c2).toBeGreaterThanOrEqual(0.05)
expect(c2).toBeLessThanOrEqual(0.16)
})
test("spatial mode creates contiguous natural patches (higher neighborhood correlation than random)", () => {
const width = 40
const height = 40
const canvasSpatial = createCanvas(width, height, 1, 1, 0)
const canvasCdf = createCanvas(width, height, 1, 1, 0)
fillGround(canvasSpatial, candidates, 0, 0, width, height, { seed: 100, mode: "spatial", spatialScale: 6 })
fillGround(canvasCdf, candidates, 0, 0, width, height, { seed: 100, mode: "cdf" })
// Count horizontal neighbor matches: how often cell(x, y) has the same sequence as cell(x+1, y)
let spatialMatches = 0
let cdfMatches = 0
let totalPairs = 0
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width - 1; x += 1) {
totalPairs += 1
if (canvasSpatial.cells[y]![x]!.floors[0]!.sequence === canvasSpatial.cells[y]![x + 1]!.floors[0]!.sequence) {
spatialMatches += 1
}
if (canvasCdf.cells[y]![x]!.floors[0]!.sequence === canvasCdf.cells[y]![x + 1]!.floors[0]!.sequence) {
cdfMatches += 1
}
}
}
// Spatial coherent noise should have significantly higher neighbor continuity than independent CDF noise
const spatialCorr = spatialMatches / totalPairs
const cdfCorr = cdfMatches / totalPairs
expect(spatialCorr).toBeGreaterThan(cdfCorr)
expect(spatialCorr).toBeGreaterThan(0.60)
})
})
describe("Deterministic PRNG Consistency", () => {
test("same seed produces byte-for-byte identical tile layouts", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 60 },
{ style: 0, sequence: 1, weight: 30 },
{ style: 0, sequence: 2, weight: 10 },
]
const canvasA = createCanvas(25, 25, 1, 1, 0)
const canvasB = createCanvas(25, 25, 1, 1, 0)
fillGround(canvasA, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
fillGround(canvasB, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
for (let y = 0; y < 25; y += 1) {
for (let x = 0; x < 25; x += 1) {
const floorA = canvasA.cells[y]![x]!.floors[0]!
const floorB = canvasB.cells[y]![x]!.floors[0]!
expect(floorA.style).toBe(floorB.style)
expect(floorA.sequence).toBe(floorB.sequence)
}
}
})
test("different seeds produce different ground layouts", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 50 },
{ style: 0, sequence: 1, weight: 50 },
]
const canvasA = createCanvas(20, 20, 1, 1, 0)
const canvasB = createCanvas(20, 20, 1, 1, 0)
fillGround(canvasA, candidates, 0, 0, 20, 20, { seed: 111 })
fillGround(canvasB, candidates, 0, 0, 20, 20, { seed: 999 })
let differences = 0
for (let y = 0; y < 20; y += 1) {
for (let x = 0; x < 20; x += 1) {
if (canvasA.cells[y]![x]!.floors[0]!.sequence !== canvasB.cells[y]![x]!.floors[0]!.sequence) {
differences += 1
}
}
}
expect(differences).toBeGreaterThan(50)
})
})
describe("DT1 Library Derivation (deriveGroundTilesFromDt1Libraries)", () => {
test("filters libraries strictly according to dwDt1Mask", () => {
const lib0 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 50 },
])
const lib1 = makeMockDt1([
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const lib2 = makeMockDt1([
{ type: 0, style: 0, sequence: 2, rarityFrameIndex: 10 },
])
const libraries = [lib0, lib1, lib2]
// Mask 0b001 = 1 (only lib 0)
const onlyLib0 = deriveGroundTilesFromDt1Libraries(libraries, 1)
expect(onlyLib0).toHaveLength(1)
expect(onlyLib0[0]).toMatchObject({ style: 0, sequence: 0, weight: 50 })
// Mask 0b101 = 5 (lib 0 and lib 2, skipping lib 1)
const lib0and2 = deriveGroundTilesFromDt1Libraries(libraries, 5)
expect(lib0and2).toHaveLength(2)
expect(lib0and2.map(t => t.sequence)).toEqual([0, 2])
// Mask 0b000 = 0 (no libraries active)
const empty = deriveGroundTilesFromDt1Libraries(libraries, 0)
expect(empty).toHaveLength(0)
})
test("filters out non-floor tiles (type !== 0) and weights by rarityFrameIndex", () => {
const dt1 = makeMockDt1([
// Floor tiles (type === 0)
{ type: 0, style: 1, sequence: 0, rarityFrameIndex: 60 },
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 40 },
// Same style/seq variant in library: weights should accumulate
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 10 },
// Wall tile (type === 1) - must be excluded
{ type: 1, style: 1, sequence: 0, rarityFrameIndex: 99 },
// Roof tile (type === 2) - must be excluded
{ type: 2, style: 1, sequence: 0, rarityFrameIndex: 99 },
])
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
expect(result).toHaveLength(2)
// Expected ordering: highest weight first
// seq 0 has 60, seq 1 has 40 + 10 = 50
expect(result[0]).toMatchObject({ style: 1, sequence: 0, weight: 60 })
expect(result[1]).toMatchObject({ style: 1, sequence: 1, weight: 50 })
})
test("handles empty libraries or libraries with no floor tiles gracefully", () => {
expect(deriveGroundTilesFromDt1Libraries([], 0xff)).toEqual([])
const noFloors = makeMockDt1([{ type: 1, style: 0, sequence: 0 }])
expect(deriveGroundTilesFromDt1Libraries([noFloors], 1)).toEqual([])
})
})
describe("Piece Floor Derivation (deriveGroundTiles & deriveGroundTile)", () => {
test("derives candidate ground tiles with frequencies from interior floor cells", () => {
// Create a piece with interior floor tiles:
// 8x8 piece has interior from x=1..6, y=1..6 (36 interior cells)
// Set cells x=2..4, y=2..4 to sequence 1 (9 cells)
const level = makeMockDs1(8, 8, 0, 0, (x, y) => {
if (x >= 2 && x <= 4 && y >= 2 && y <= 4) {
return { style: 0, sequence: 1 }
}
return undefined
})
const pieces: WildernessPiece[] = [{ name: "Test Piece", border: true, levels: [level] }]
const tiles = deriveGroundTiles(pieces)
expect(tiles.length).toBe(2)
// Sequence 0 is the majority, sequence 1 has 9 cells
expect(tiles[0]!.sequence).toBe(0)
expect(tiles[1]!.sequence).toBe(1)
expect(tiles[1]!.weight).toBe(9)
// deriveGroundTile should return the most frequent interior tile
const primary = deriveGroundTile(pieces)
expect(primary).toEqual({ style: 0, sequence: 0 })
})
})
describe("generateWilderness Integration", () => {
test("generates wilderness with explicit groundTiles blend", () => {
const pieces = makeTestPieces()
const candidateTiles: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 70 },
{ style: 0, sequence: 1, weight: 30 },
]
const result = generateWilderness({
levelId: 41,
levelName: "Rocky Waste Blend Test",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 123456,
pieces,
substitutions: [],
groundTiles: candidateTiles,
})
expect(result.stats.groundTiles).toBeDefined()
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(statsTiles).toHaveLength(2)
// Verify level cells contain both sequences
let seq0Count = 0
let seq1Count = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const floor = result.level.cells[y]![x]!.floors[0]
if (floor && !floor.hidden) {
if (floor.sequence === 0) seq0Count += 1
if (floor.sequence === 1) seq1Count += 1
}
}
}
expect(seq0Count).toBeGreaterThan(0)
expect(seq1Count).toBeGreaterThan(0)
expect(seq0Count).toBeGreaterThan(seq1Count)
})
test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 41,
levelName: "Rocky Waste Single Tile",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 654321,
pieces,
substitutions: [],
groundTile: { style: 0, sequence: 0 },
})
expect(result.stats.groundTile).toEqual({ style: 0, sequence: 0 })
})
test("generates wilderness with dt1Libraries deriving ground tiles automatically", () => {
const pieces = makeTestPieces()
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const result = generateWilderness({
levelId: 41,
levelName: "Rocky Waste DT1 Test",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 777777,
pieces,
substitutions: [],
dt1Libraries: [dt1],
})
expect(result.stats.groundTiles).toBeDefined()
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(statsTiles).toHaveLength(2)
expect(statsTiles[0]!.sequence).toBe(0)
expect(statsTiles[1]!.sequence).toBe(1)
})
})
describe("DRLGROOMTILE_GetTileCache & Multi-DT1 Rarity Weighting (Issue #70)", () => {
test("filters candidate tiles strictly by dwDt1Mask", () => {
const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 10, rarityFrameIndex: 50 }])
const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 20, rarityFrameIndex: 50 }])
const libraries = [dt1A, dt1B]
// dwDt1Mask = 0x01 (slot 0 only)
const res0 = DRLGROOMTILE_GetTileCache(libraries, 0x01, { type: 0, seed: 0 })
expect(res0).not.toBeNull()
expect(res0?.sequence).toBe(10)
expect(res0?.libraryIndex).toBe(0)
// dwDt1Mask = 0x02 (slot 1 only)
const res1 = DRLGROOMTILE_GetTileCache(libraries, 0x02, { type: 0, seed: 0 })
expect(res1).not.toBeNull()
expect(res1?.sequence).toBe(20)
expect(res1?.libraryIndex).toBe(1)
// dwDt1Mask = 0x00 (no slots active)
const resNone = DRLGROOMTILE_GetTileCache(libraries, 0x00, { type: 0, seed: 0 })
expect(resNone).toBeNull()
})
test("filters candidate tiles by query type, style, and sequence", () => {
const dt1 = makeMockDt1([
{ type: 0, style: 2, sequence: 24, rarityFrameIndex: 10 },
{ type: 0, style: 2, sequence: 0, rarityFrameIndex: 10 },
{ type: 1, style: 2, sequence: 24, rarityFrameIndex: 10 }, // wall
])
// Query specific sequence
const resSeq = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 0, style: 2, sequence: 24, seed: 0 })
expect(resSeq?.type).toBe(0)
expect(resSeq?.style).toBe(2)
expect(resSeq?.sequence).toBe(24)
// Query wall type
const resWall = DRLGROOMTILE_GetTileCache([dt1], 0x01, { type: 1, seed: 0 })
expect(resWall?.type).toBe(1)
// Non-existent style
const resMissing = DRLGROOMTILE_GetTileCache([dt1], 0x01, { style: 99, seed: 0 })
expect(resMissing).toBeNull()
})
test("supports tile rarityFrameIndex, rarity, and Rarity field names", () => {
const dt1A = makeMockDt1([{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 45 }])
const dt1B = makeMockDt1([{ type: 0, style: 0, sequence: 2, rarity: 75 }])
const dt1C = makeMockDt1([{ type: 0, style: 0, sequence: 3, Rarity: 95 }])
const resA = DRLGROOMTILE_GetTileCache([dt1A], 1, { type: 0, seed: 0 })
expect(resA?.weight).toBe(45)
const resB = DRLGROOMTILE_GetTileCache([dt1B], 1, { type: 0, seed: 0 })
expect(resB?.weight).toBe(75)
const resC = DRLGROOMTILE_GetTileCache([dt1C], 1, { type: 0, seed: 0 })
expect(resC?.weight).toBe(95)
})
test("distributes tile selection proportionally to rarity weights", () => {
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
let count0 = 0
let count1 = 0
const totalSamples = 5000
for (let i = 0; i < totalSamples; i += 1) {
// Roll deterministically with coordinates (i, 0) and seed
const res = DRLGROOMTILE_GetTileCache([dt1], 1, { type: 0, seed: 12345, x: i, y: 0 })
if (res?.sequence === 0) count0 += 1
if (res?.sequence === 1) count1 += 1
}
const ratio0 = count0 / totalSamples
const ratio1 = count1 / totalSamples
// Expected ~0.80 and ~0.20 within +/- 0.04
expect(ratio0).toBeGreaterThan(0.75)
expect(ratio0).toBeLessThan(0.85)
expect(ratio1).toBeGreaterThan(0.15)
expect(ratio1).toBeLessThan(0.25)
})
test("deriveGroundTilesFromDt1Libraries respects dwDt1Mask, rarities and sorts descending", () => {
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarity: 15 },
{ type: 0, style: 0, sequence: 1, Rarity: 85 },
])
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
expect(result).toHaveLength(2)
// Sequence 1 has higher rarity (85), so it must be sorted first
expect(result[0]!.sequence).toBe(1)
expect(result[0]!.weight).toBe(85)
expect(result[1]!.sequence).toBe(0)
expect(result[1]!.weight).toBe(15)
})
})
describe('Canonical Ground Pools (Phase D3)', () => {
test('contains authentic pools for all wilderness level types', () => {
const expectedKeys = [
'Act 2 - Desert',
'Act 3 - Jungle',
'Act 3 - Kurast',
'Act 4 - Mesa',
'Act 4 - Lava',
'Act 5 - Siege',
'Act 5 - Barricade',
'Act 5 - Barricade Snow',
]
for (const key of expectedKeys) {
const pool = CANONICAL_GROUND_POOLS[key]
expect(pool).toBeDefined()
expect(pool!.length).toBeGreaterThanOrEqual(1)
for (const tile of pool!) {
expect(tile.weight).toBeGreaterThan(0)
expect(tile.style).toBeGreaterThanOrEqual(0)
expect(tile.sequence).toBeGreaterThanOrEqual(0)
}
}
// Act I outdoor ground comes from the DRLG port, not from a canonical pool.
expect(CANONICAL_GROUND_POOLS['Act 1 - Wilderness']).toBeUndefined()
})
test('Act 3 Kurast canonical ground pool uses authentic style 1 from DarkGrass.dt1', () => {
expect(CANONICAL_GROUND_TILES['Act 3 - Kurast'].style).toBe(1)
const kurastPool = CANONICAL_GROUND_POOLS['Act 3 - Kurast']!
for (const tile of kurastPool) {
expect(tile.style).toBe(1)
}
})
test('Act 5 Barricade distinguishes dirt (style 0) from snow (style 6)', () => {
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade'].style).toBe(0)
expect(CANONICAL_GROUND_TILES['Act 5 - Barricade Snow'].style).toBe(6)
for (const tile of CANONICAL_GROUND_POOLS['Act 5 - Barricade']!) {
expect(tile.style).toBe(0)
}
for (const tile of CANONICAL_GROUND_POOLS['Act 5 - Barricade Snow']!) {
expect(tile.style).toBe(6)
}
})
test('generateWilderness resolves snow barricade ground tiles when isSnowBarricade is true', () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 117,
levelName: 'Frozen Tundra',
levelTypeName: 'Act 5 - Barricade',
sizeX: 64,
sizeY: 64,
subType: 11,
subTheme: 0,
seed: 42,
pieces,
substitutions: [],
})
expect(result.stats.groundTile?.style).toBe(6)
const tiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(tiles).toBeDefined()
expect(tiles.every(t => t.style === 6)).toBe(true)
})
test('generateWilderness resolves Kurast ground tiles with style 1', () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 79,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 42,
pieces,
substitutions: [],
})
expect(result.stats.groundTile?.style).toBe(1)
const tiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(tiles).toBeDefined()
expect(tiles.every(t => t.style === 1)).toBe(true)
})
})
})

View File

@ -1,401 +0,0 @@
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
buildJungleTopology,
DRLG_GenerateJungles,
type WildernessPiece,
type WildernessRequest,
} from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
/**
* Creates a mock DS1 piece of arbitrary size (e.g. 32x32 for jungle macro-cells, 64x32 for head/tail).
*/
function makeMockDs1(width: number, height: number, isWater = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [
{
prop1: isWater ? 194 : 2,
sequence: 0,
style: isWater ? 1 : 0,
unknown1: 0,
unknown2: 0,
hidden: false,
},
],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 3,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
/**
* Creates a complete mock suite of Act 3 Jungle pieces.
*/
function makeMockJunglePieces(): WildernessPiece[] {
const pieces: WildernessPiece[] = []
// 1. Head (TransL.ds1, 64x32) and Tail (TravL.ds1, 64x32)
pieces.push({
name: 'Act 3 - Jungle Head',
border: false,
levels: [makeMockDs1(64, 32)],
})
pieces.push({
name: 'Act 3 - Jungle Tail',
border: false,
levels: [makeMockDs1(64, 32)],
})
// 2. Wooden Bridge (Act 3 - Bridge, 32x32)
pieces.push({
name: 'Act 3 - Bridge',
border: false,
levels: [makeMockDs1(32, 32)],
})
// 3. Directional River Presets (32x32)
const riverTokens = [
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
'NSE', 'NSW', 'NEW', 'SEW', 'NSEW',
'NS W', 'NS E', 'EW N', 'EW S',
]
for (const token of riverTokens) {
pieces.push({
name: `Act 3 - Jungle ${token}`,
border: false,
levels: [makeMockDs1(32, 32, true)],
})
}
// 4. Clearings for each of the 3 jungle areas (32x32)
const clearingThemes = ['Webby', 'Boggy', 'Pygmy']
const openings = ['', ' E', ' W', ' EW', ' NE', ' NW', ' SE', ' SW']
for (const theme of clearingThemes) {
for (const opening of openings) {
pieces.push({
name: `Act 3 - Clearing ${theme}${opening}`,
border: false,
levels: [makeMockDs1(32, 32)],
})
}
}
return pieces
}
describe('Act 3 Jungle Branching Topology (buildJungleTopology)', () => {
test('guarantees Flayer Jungle (78) is always reachable from Spider Forest (76)', () => {
// Test across 100 random seeds
for (let seed = 1; seed <= 100; seed += 1) {
const topology = buildJungleTopology(seed)
expect(topology.zones).toHaveLength(3)
const spider = topology.zones.find(z => z.levelId === 76)!
const marsh = topology.zones.find(z => z.levelId === 77)!
const flayer = topology.zones.find(z => z.levelId === 78)!
expect(spider.name).toBe('Spider Forest')
expect(marsh.name).toBe('Great Marsh')
expect(flayer.name).toBe('Flayer Jungle')
// Check graph reachability: BFS from 76 to 78
const visited = new Set<number>([76])
const queue = [76]
while (queue.length > 0) {
const curr = queue.shift()!
const outgoing = topology.connections.filter(c => c.fromLevelId === curr)
for (const conn of outgoing) {
if (!visited.has(conn.toLevelId)) {
visited.add(conn.toLevelId)
queue.push(conn.toLevelId)
}
}
}
expect(visited.has(78)).toBe(true)
}
})
test('exhibits both Great Marsh skip active and inactive across different seeds', () => {
let skipsSeen = 0
let noSkipsSeen = 0
let marshDeadEndsSeen = 0
let marshThroughwaysSeen = 0
for (let seed = 1; seed <= 100; seed += 1) {
const topology = buildJungleTopology(seed)
if (topology.hasMarshSkip) {
skipsSeen += 1
const directSkipConn = topology.connections.find(
c => c.fromLevelId === 76 && c.toLevelId === 78,
)
expect(directSkipConn).toBeDefined()
expect(directSkipConn!.connectionType).toBe('skip')
} else {
noSkipsSeen += 1
// Without skip, Great Marsh must connect forward to Flayer Jungle
const marshToFlayer = topology.connections.find(
c => c.fromLevelId === 77 && c.toLevelId === 78,
)
expect(marshToFlayer).toBeDefined()
expect(topology.marshIsDeadEnd).toBe(false)
}
if (topology.marshIsDeadEnd) {
marshDeadEndsSeen += 1
expect(topology.hasMarshSkip).toBe(true)
} else {
marshThroughwaysSeen += 1
}
}
expect(skipsSeen).toBeGreaterThan(0)
expect(noSkipsSeen).toBeGreaterThan(0)
expect(marshDeadEndsSeen).toBeGreaterThan(0)
expect(marshThroughwaysSeen).toBeGreaterThan(0)
})
})
describe('DRLG_GenerateJungles River Network Generation', () => {
test('generates meandering river paths that wander across columns', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 42,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout).toBeDefined()
expect(layout.riverNetwork).toBeDefined()
const { meanderingPath, meanderCount } = layout.riverNetwork
expect(meanderingPath.length).toBeGreaterThanOrEqual(6)
expect(meanderCount).toBeGreaterThanOrEqual(1)
// Verify that the river visits multiple columns
const columnsVisited = new Set(meanderingPath.map((p: any) => p.col))
expect(columnsVisited.size).toBeGreaterThanOrEqual(2)
})
test('places river forks / tributary junctions with branching presets', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 77,
levelName: 'Great Marsh',
levelTypeName: 'Act 3 - Jungle',
seed: 12345,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.forks.length).toBeGreaterThan(0)
const fork = layout.riverNetwork.forks[0]!
expect(fork.row).toBeGreaterThanOrEqual(1)
expect(fork.row).toBeLessThanOrEqual(4)
expect(result.stats.riverForks).toBe(layout.riverNetwork.forks.length)
})
test('identifies and creates river island clearings flanked by river channels', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 999,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.islands.length).toBeGreaterThan(0)
const island = layout.riverNetwork.islands[0]!
expect(island.col).toBeGreaterThanOrEqual(0)
expect(island.row).toBeGreaterThanOrEqual(1)
expect(result.stats.riverIslands).toBe(layout.riverNetwork.islands.length)
// Check that clearings array flags the island
const islandClearing = layout.riverNetwork.clearings.find(
(c: any) => c.col === island.col && c.row === island.row,
)
expect(islandClearing).toBeDefined()
expect(islandClearing.isIsland).toBe(true)
})
test('places wooden bridges crossing river channels to connect islands and trails', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 78,
levelName: 'Flayer Jungle',
levelTypeName: 'Act 3 - Jungle',
seed: 777,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.bridges.length).toBeGreaterThan(0)
// Bridge recorded in stats.substitutions and special presets
const bridgeSub = (result.stats.substitutions as any[]).find(
s => s.name === 'Act 3 - Bridge',
)
expect(bridgeSub).toBeDefined()
expect(bridgeSub.enabled).toBe(true)
expect(result.stats.bridgesPlaced).toBe(layout.riverNetwork.bridges.length)
// Bridge coordinates must align to grid cells
for (const bridge of layout.riverNetwork.bridges) {
expect(bridge.x).toBe(bridge.col * 32)
expect(bridge.y).toBe(bridge.row * 32)
expect(bridge.pieceName).toBe('Act 3 - Bridge')
}
})
})
describe('End-to-End generateWilderness Integration for Act 3 Jungles', () => {
test('DRLG_GenerateJungles is removed from UNIMPLEMENTED_PASSES', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
})
test('generates Spider Forest (76) with Webby clearings and complete jungle metadata', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 555,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(64)
expect(result.level.height).toBe(192)
expect(result.level.act).toBe(3)
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Webby'))).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Jungle Tail')).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Jungle Head')).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Bridge')).toBe(true)
expect(result.stats.jungleLayout).toBeDefined()
expect(result.stats.jungleTopology).toBeDefined()
})
test('generates Great Marsh (77) with Boggy clearings', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 77,
levelName: 'Great Marsh',
levelTypeName: 'Act 3 - Jungle',
seed: 888,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Boggy'))).toBe(true)
})
test('generates Flayer Jungle (78) with Pygmy clearings', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 78,
levelName: 'Flayer Jungle',
levelTypeName: 'Act 3 - Jungle',
seed: 999,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Pygmy'))).toBe(true)
})
test('supports wider 3-column (96x192) jungle layouts', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 1234,
sizeX: 96,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(96)
expect(result.level.height).toBe(192)
const layout = result.stats.jungleLayout as any
expect(layout.gridColumns).toBe(3)
expect(layout.gridRows).toBe(6)
expect(layout.riverNetwork.meanderCount).toBeGreaterThanOrEqual(1)
})
})

View File

@ -1,151 +0,0 @@
import { describe, expect, test } from "vitest"
import { UNIMPLEMENTED_PASSES } from "../src/game/wilderness.ts"
import {
rollEliteModifiers,
applyEliteModifiers,
CANONICAL_ELITE_MODIFIERS,
planLevelMonsters,
} from "../src/game/monsters.ts"
import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts"
import { parseTable } from "../src/game/acts.ts"
import { Rng } from "../src/game/rng.ts"
// Act I outdoor population (the Act I waypoints, shrines, Flavie and the SuperUniques of Cold Plains,
// Stony Field, Dark Wood, Burial Grounds and Moo Moo Farm) comes from the DRLG port and is checked on
// its output in tests/drlg-act1-population.test.ts.
describe("Wilderness Population Phase (Issue #36)", () => {
test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => {
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint")
expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines")
})
test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => {
const rng = new Rng(0xabcdef)
const mods = rollEliteModifiers("unique", rng)
expect(mods.length).toBeGreaterThanOrEqual(1)
const allowedNames = CANONICAL_ELITE_MODIFIERS.map(m => m.name)
for (const m of mods) {
expect(allowedNames).toContain(m)
}
const baseStats = {
id: "fallen1",
name: "Fallen",
hp: 20,
damage: 4,
speed: 2,
reach: 32,
aggroRadius: 220,
cooldownTicks: 25,
xp: 10,
}
const strongFast = applyEliteModifiers(baseStats, "unique", ["strong", "fast"], "Rakanishu")
expect(strongFast.rank).toBe("unique")
expect(strongFast.superUniqueId).toBe("Rakanishu")
expect(strongFast.modifiers).toEqual(["strong", "fast"])
expect(strongFast.damage).toBe(6) // 4 * 1.5
expect(strongFast.speed).toBeCloseTo(2.8) // 2 * 1.4
})
test("spawnMonsterPacks enforces safeZones exclusion radius and anchors fixedPosition packs", () => {
const world = createWorld(500, 500)
const safeZones: SafeZone[] = [
{ x: 500, y: 500, radius: 200 },
]
const packs: MonsterPack[] = [
{
members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }],
},
{
members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }],
},
{
superUniqueId: "Rakanishu",
fixedPosition: { x: 1200, y: 1200 },
members: [{ id: "fallen1", name: "Rakanishu", hp: 80, damage: 10, speed: 3, reach: 30, aggroRadius: 250, cooldownTicks: 20, xp: 50, rank: "unique", superUniqueId: "Rakanishu" }],
},
]
const placed = spawnMonsterPacks(world, packs, { x: 500, y: 500 }, 800, { overlap: () => 0 }, safeZones)
expect(placed).toBe(3)
expect(world.monsters.length).toBe(3)
for (const monster of world.monsters) {
if (monster.stats.superUniqueId === "Rakanishu") {
// Anchored to fixedPosition (1200, 1200)
expect(Math.hypot(monster.x - 1200, monster.y - 1200)).toBeLessThan(80)
} else {
// Must respect safeZone around (500, 500) with radius 200
const dist = Math.hypot(monster.x - 500, monster.y - 500)
expect(dist).toBeGreaterThanOrEqual(200)
}
}
})
test("planLevelMonsters plans fixed SuperUniques for Cold Plains (3), Stony Field (4), Dark Wood (5), and Burial Grounds (17)", () => {
const enc = new TextEncoder()
const levelsTxt = parseTable(
enc.encode(
[
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
"3\tCold Plains\t600\t2\t4\t2\tfallen1\tfallen2\tzombie1",
"4\tStony Field\t600\t2\t4\t4\tfallen1\tfallen2\tskeleton1",
"5\tDark Wood\t600\t2\t4\t5\tbrute1\tfallen1\tskeleton1",
"17\tBurial Grounds\t500\t1\t2\t3\tzombie1\tskeleton1\tcorruptrogue1",
].join("\n"),
),
)
const monstatsTxt = parseTable(
enc.encode(
[
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"fallen1\tfallen1\tFallen\t1\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"fallen2\tfallen1\tCarver\t2\t3\t6\t8\t12\t8\t25\t12\t20\t2\t4\t28\t1\t1\t1\t1",
"fallenshaman1\tfallenshaman1\tFallen Shaman\t2\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
"zombie1\tzombie1\tZombie\t1\t2\t4\t5\t8\t6\t18\t14\t22\t2\t5\t22\t1\t1\t1\t1",
"skeleton1\tskeleton1\tSkeleton\t2\t3\t5\t7\t10\t8\t22\t12\t18\t2\t5\t26\t1\t1\t1\t1",
"brute1\tbrute1\tGargantuan Beast\t5\t2\t4\t7\t10\t18\t65\t30\t45\t5\t10\t45\t1\t1\t1\t1",
"corruptrogue1\tcorruptrogue1\tCorrupt Rogue\t3\t3\t5\t8\t12\t12\t30\t15\t24\t3\t6\t35\t1\t1\t1\t1",
].join("\n"),
),
)
const monlvlTxt = parseTable(
enc.encode(
[
"Level\tAC\tTH\tHP\tDM\tXP",
"1\t100\t100\t100\t100\t100",
"2\t100\t100\t100\t100\t100",
"3\t100\t100\t100\t100\t100",
"4\t100\t100\t100\t100\t100",
"5\t100\t100\t100\t100\t100",
"6\t100\t100\t100\t100\t100",
"7\t100\t100\t100\t100\t100",
"8\t100\t100\t100\t100\t100",
].join("\n"),
),
)
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
for (const [lvlId, suName] of [
[3, "Bishibosh"],
[4, "Rakanishu"],
[5, "Treehead WoodFist"],
[17, "Blood Raven"],
] as const) {
const planned = planLevelMonsters(tables, lvlId, 6400, 0x12345 + lvlId, 3.5, "normal", {
landmarks: [{ id: suName, tileX: 40, tileY: 40, fixedPosition: { x: 1000, y: 1000 } }],
})
expect(planned.superUniques).toContain(suName)
const suPack = planned.packs.find(p => p.superUniqueId === suName)
expect(suPack).toBeDefined()
expect(suPack?.fixedPosition).toEqual({ x: 1000, y: 1000 })
expect(suPack?.landmarkTile).toEqual({ tileX: 40, tileY: 40 })
expect(suPack?.members[0]?.rank).toBe("unique")
expect(suPack?.members[0]?.superUniqueId).toBe(suName)
expect(suPack?.members[0]?.modifiers?.length).toBeGreaterThan(0)
}
})
})

View File

@ -1,416 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
createCanvas,
stampAct2DesertRoads,
stampAct2SandstoneBerms,
stampAct3KurastAvenues,
stampAct3KurastBalustrades,
stampAct5SnowRoads,
stampAct5FrozenTrailDivides,
stampOutdoorRoads,
stampOutdoorDividers,
generateWilderness,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { WildernessPiece, PlacedPresetRect, Canvas } from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell, Ds1Wall } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function countFloorTiles(canvas: Canvas, style: number, prop1: number): number {
let count = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell && cell.floors.some(f => f.style === style && f.prop1 === prop1)) {
count += 1
}
}
}
return count
}
function countWallTiles(canvas: Canvas, style: number, prop1: number): number {
let count = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell && cell.walls.some(w => w.style === style && w.prop1 === prop1)) {
count += 1
}
}
}
return count
}
describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
const gridW = 10
const gridH = 10
const width = gridW * TILES_PER_BLOCK
const height = gridH * TILES_PER_BLOCK
const inX = Math.floor(gridW / 2)
const outY = Math.floor(gridH / 2)
describe('Act 2 Desert Roads & Sandstone Berms', () => {
it('stampAct2DesertRoads paints caravan routes with style: 1, prop1: 194 and connects spurs', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 2 - Desert Tomb 1', x: 24, y: 24, w: 16, h: 16 },
]
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
const roadFloors = countFloorTiles(canvas, 1, 194)
expect(roadFloors).toBeGreaterThan(50)
// Verify the main highway corridor has caravan road floors
const highwayTile = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
expect(highwayTile).toBeDefined()
expect(highwayTile?.floors.some(f => f.style === 1 && f.prop1 === 194)).toBe(true)
// Verify spur path connected towards the Tomb preset
let spurFound = false
for (let y = 30; y <= 34; y += 1) {
for (let x = 38; x <= 44; x += 1) {
const c = canvas.cells[y]?.[x]
if (c?.floors.some(f => f.style === 1 && f.prop1 === 194)) {
spurFound = true
break
}
}
}
expect(spurFound).toBe(true)
})
it('stampAct2SandstoneBerms is a no-op removing artificial cross dividers (Issue #65)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const placedRects: PlacedPresetRect[] = []
// First stamp roads so the divider can create 3-tile archway breaks across them
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct2SandstoneBerms(canvas, gridW, gridH, inX, outY, placedRects, rng)
const bermWalls = countWallTiles(canvas, 1, 129)
expect(bermWalls).toBe(0)
})
})
describe('Act 3 Kurast Avenues & Balustrades', () => {
it('stampAct3KurastAvenues paints flagstone avenues with style: 1, prop1: 194', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(100)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 3 - Burbs Temple', x: 20, y: 20, w: 16, h: 16 },
]
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
const avenueFloors = countFloorTiles(canvas, 1, 194)
expect(avenueFloors).toBeGreaterThan(50)
})
it('stampAct3KurastBalustrades is a no-op removing artificial cross dividers (Issue #65)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(100)
const placedRects: PlacedPresetRect[] = []
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct3KurastBalustrades(canvas, gridW, gridH, inX, outY, placedRects, rng)
const balustradeWalls = countWallTiles(canvas, 1, 129)
expect(balustradeWalls).toBe(0)
})
})
describe('Act 5 Snow Roads & Frozen Trail Divides', () => {
it('stampAct5SnowRoads paints snow march roads with style: 1, prop1: 194', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(555)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 5 - Barricade Building', x: 20, y: 20, w: 16, h: 16 },
]
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
const snowRoadFloors = countFloorTiles(canvas, 1, 194)
expect(snowRoadFloors).toBeGreaterThan(50)
})
it('stampAct5FrozenTrailDivides is a no-op removing artificial cross dividers (Issue #65)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(555)
const placedRects: PlacedPresetRect[] = []
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct5FrozenTrailDivides(canvas, gridW, gridH, inX, outY, placedRects, rng)
const frozenDivides = countWallTiles(canvas, 2, 129)
expect(frozenDivides).toBe(0)
})
})
describe('Unified Dispatchers: stampOutdoorRoads & stampOutdoorDividers', () => {
it('dispatches Act 2 Desert based on levelId 41 without artificial cross dividers', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 1, 129)).toBe(0)
})
it('dispatches Act 3 Kurast based on levelId 80 without artificial cross dividers', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 1, 129)).toBe(0)
})
it('dispatches Act 5 Snow based on levelId 111 without artificial cross dividers', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 2, 129)).toBe(0)
})
it('refuses Act I outdoor levels (levelId 3): they come from the DRLG port (src/game/drlg)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
expect(() => stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/)
expect(() => stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)).toThrow(/DRLG port/)
})
it('dispatches based on levelTypeName when levelId is generic', () => {
const c2 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
stampOutdoorDividers(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c2, 1, 129)).toBe(0)
const c3 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
stampOutdoorDividers(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
expect(countFloorTiles(c3, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c3, 1, 129)).toBe(0)
const c5 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
stampOutdoorDividers(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
expect(countFloorTiles(c5, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c5, 2, 129)).toBe(0)
})
it('dispatches based on act number fallback', () => {
const c2 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
stampOutdoorDividers(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c2, 1, 129)).toBe(0)
})
it('refuses the Cow Level (39): an Act I outdoor level generated by the DRLG port', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
expect(() => stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/)
expect(() => stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')).toThrow(/DRLG port/)
})
})
describe('generateWilderness Integration for Acts 2, 3, 5', () => {
const borderDs1 = makeMockDs1(8, 8)
const borderPiece: WildernessPiece = {
name: 'Act 2 - Desert Border 1',
border: true,
levels: [borderDs1],
}
it('generates Act 2 Rocky Waste (Level 41) with caravan routes and authentic canyon topography walls', () => {
const result = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 77777,
pieces: [borderPiece],
substitutions: [],
})
expect(result.level.act).toBe(2)
let caravanRoads = 0
let sandstoneBerms = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) caravanRoads += 1
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) sandstoneBerms += 1
}
}
expect(caravanRoads).toBeGreaterThan(30)
expect(sandstoneBerms).toBeGreaterThan(15)
})
it('generates Act 3 Kurast Bazaar (Level 80) with Kurast avenues and authentic causeway balustrades', () => {
const act3Border: WildernessPiece = {
name: 'Act 3 - Kurast Border',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 80,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 88888,
pieces: [act3Border],
substitutions: [],
})
expect(result.level.act).toBe(3)
let avenues = 0
let balustrades = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 1 || f.style === 5 || f.style === 20) && f.prop1 === 194)) avenues += 1
if (cell.walls.some(w => (w.style === 1 || w.style === 20) && w.prop1 === 129)) balustrades += 1
}
}
expect(avenues).toBeGreaterThan(30)
expect(balustrades).toBeGreaterThan(15)
})
it('generates Act 5 Frigid Highlands (Level 111) with snow roads and authentic siege barricade walls', () => {
const act5Border: WildernessPiece = {
name: 'Act 5 - Barricade Border',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 99999,
pieces: [act5Border],
substitutions: [],
})
expect(result.level.act).toBe(5)
let snowRoads = 0
let frozenDivides = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) snowRoads += 1
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) frozenDivides += 1
}
}
expect(snowRoads).toBeGreaterThan(30)
expect(frozenDivides).toBeGreaterThan(15)
})
it.each([2, 3, 4, 5, 6, 7, 17, 39])('refuses Act I outdoor level %i: it comes from the DRLG port (src/game/drlg)', (levelId) => {
const act1Border: WildernessPiece = {
name: 'Act 1 - Wild Border 1',
border: true,
levels: [borderDs1],
}
expect(() => generateWilderness({
levelId,
levelName: `Act I level ${String(levelId)}`,
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [act1Border],
substitutions: [],
})).toThrow(/DRLG port/)
})
it('stampThemedRoads preserves preset walls on the highway spine', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// Preset directly intersects the road spine
const placedRects: PlacedPresetRect[] = [
{ name: 'Protected Preset', x: hubX - 2, y: hubY - 2, w: 5, h: 5 },
]
// Paint preset wall inside the preset on the highway spine
const targetCell = canvas.cells[hubY]?.[hubX]
expect(targetCell).toBeDefined()
;(targetCell!.walls as Ds1Wall[]).push({
prop1: 100,
sequence: 0,
style: 99,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// Act 2 caravan routes go through the shared stampThemedRoads rasterizer.
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
// The wall inside the preset must NOT have been wiped
expect(targetCell!.walls.some(w => w.style === 99 && w.prop1 === 100)).toBe(true)
})
})
})

View File

@ -1,311 +0,0 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
getPerimeterOpenings,
type WildernessPiece,
type SeamConstraint,
type GateOpening,
type PlannedGate,
} from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] },
{ name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] },
{ name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] },
]
}
describe("Wilderness Seam Alignment and Coordinate Interlocking (Issue #69)", () => {
describe("getPerimeterOpenings unit tests", () => {
test("uses forced gate.blockCoord directly", () => {
const gates: PlannedGate[] = [
{ label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 5 },
]
const openings = getPerimeterOpenings(2, 10, 10, gates)
// East side is at x = 9, y = 5
expect(openings.has("9,5")).toBe(true)
})
test("clamps gate.blockCoord away from corners [1, size - 2]", () => {
const gates: PlannedGate[] = [
{ label: "Stony Field", side: "north", toLevelId: 4, blockCoord: 0 }, // Out of bounds / corner -> clamp to 1
{ label: "Dark Wood", side: "south", toLevelId: 5, blockCoord: 9 }, // Out of bounds / corner -> clamp to 8
]
const openings = getPerimeterOpenings(3, 10, 10, gates)
// North opening at x = 1, y = 0
expect(openings.has("1,0")).toBe(true)
// South opening at x = 8, y = 9
expect(openings.has("8,9")).toBe(true)
})
test("matches seamConstraints by side and level ID", () => {
const gates: PlannedGate[] = [
{ label: "Blood Moor", side: "west", toLevelId: 2 },
]
const seamConstraints: SeamConstraint[] = [
{ side: "west", fromLevelId: 2, toLevelId: 3, blockCoord: 4 },
]
const openings = getPerimeterOpenings(3, 10, 10, gates, seamConstraints)
// West opening at x = 0, y = 4
expect(openings.has("0,4")).toBe(true)
})
test("converts tileCoord to blockCoord when blockCoord is not specified", () => {
const gates: PlannedGate[] = [
{ label: "Stony Field", side: "north", toLevelId: 4, tileCoord: { x: 48, y: 0 } },
]
// tile x: 48 -> block x: 48 / 8 = 6
const openings = getPerimeterOpenings(3, 10, 10, gates)
expect(openings.has("6,0")).toBe(true)
})
test("handles extreme out-of-bounds coordinates safely", () => {
const gates: PlannedGate[] = [
{ label: "Blood Moor", side: "west", toLevelId: 2, blockCoord: -100 },
{ label: "Cold Plains", side: "east", toLevelId: 3, blockCoord: 1000 },
]
const openings = getPerimeterOpenings(2, 10, 10, gates)
expect(openings.has("0,1")).toBe(true)
expect(openings.has("9,8")).toBe(true)
})
})
// Act I outdoor levels come from the DRLG port (src/game/drlg); their seams are checked on the
// port's act layout in tests/drlg-act1-population.test.ts. The planned-gate / seam-constraint
// machinery below is shared by Acts 2-5 and runs on the Act II desert levels (41 Rocky Waste,
// 42 Dry Hills, 43 Far Oasis, 56 Halls of the Dead).
describe("PlannedGate forced blockCoord and tileCoord in generateWilderness", () => {
test("honors gates with explicit blockCoord", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x999999,
pieces,
substitutions: [],
gates: [
{ label: "Rocky Waste", side: "west", toLevelId: 41, blockCoord: 3 },
{ label: "Far Oasis", side: "north", toLevelId: 43, blockCoord: 6 },
{ label: "Halls of the Dead", side: "east", toLevelId: 56, blockCoord: 7 },
],
})
const openings = result.stats.gateOpenings
expect(openings).toBeDefined()
const west = openings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(3)
const north = openings!.find(g => g.side === "north")
expect(north?.blockCoord).toBe(6)
const east = openings!.find(g => g.side === "east")
expect(east?.blockCoord).toBe(7)
})
test("honors gates with explicit tileCoord and positions road anchors", () => {
const pieces = makeTestPieces()
const targetTileY = 8 * 4 + 3 // block 4, tile 35
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x777777,
pieces,
substitutions: [],
gates: [
{ label: "Rocky Waste", side: "west", toLevelId: 41, tileCoord: { x: 3, y: targetTileY } },
],
})
const openings = result.stats.gateOpenings
expect(openings).toBeDefined()
const west = openings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(4)
expect(west?.tileCoord).toEqual({ x: 3, y: targetTileY })
})
})
describe("Bidirectional level links & seam alignment consistency", () => {
test("level A (East) and level B (West) have matching seam and opening block", () => {
const pieces = makeTestPieces()
const targetSeamY = 5
const levelA = generateWilderness({
levelId: 41,
levelName: "Level A",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x111,
pieces,
substitutions: [],
gates: [{ label: "Level B", side: "east", toLevelId: 42, blockCoord: targetSeamY }],
})
const levelB = generateWilderness({
levelId: 42,
levelName: "Level B",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x222,
pieces,
substitutions: [],
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: targetSeamY }],
})
const gateA = levelA.stats.gateOpenings!.find(g => g.side === "east")!
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "west")!
expect(gateA.blockCoord).toBe(targetSeamY)
expect(gateB.blockCoord).toBe(targetSeamY)
// Along-seam tile coordinate: Y coordinate matches
expect(gateA.tileCoord!.y).toBe(gateB.tileCoord!.y)
})
test("level B (North) and level C (South) have matching seam and opening block", () => {
const pieces = makeTestPieces()
const targetSeamX = 6
const levelB = generateWilderness({
levelId: 42,
levelName: "Level B",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x333,
pieces,
substitutions: [],
gates: [{ label: "Level C", side: "north", toLevelId: 43, blockCoord: targetSeamX }],
})
const levelC = generateWilderness({
levelId: 43,
levelName: "Level C",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x444,
pieces,
substitutions: [],
// Only the west/east gates are defaults; the south seam gate is planned and takes its block
// from the seam constraint.
gates: [{ label: "Level B", side: "south", toLevelId: 42 }],
seamConstraints: [{ side: "south", fromLevelId: 42, toLevelId: 43, blockCoord: targetSeamX }],
})
const gateB = levelB.stats.gateOpenings!.find(g => g.side === "north")!
const gateC = levelC.stats.gateOpenings!.find(g => g.side === "south")!
expect(gateB.blockCoord).toBe(targetSeamX)
expect(gateC.blockCoord).toBe(targetSeamX)
// Along-seam tile coordinate: X coordinate matches
expect(gateB.tileCoord!.x).toBe(gateC.tileCoord!.x)
})
})
describe("Out of bounds clamping gracefully preserves valid playable borders", () => {
test("clamps negative blockCoord to 1 without crashing", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x555,
pieces,
substitutions: [],
seamConstraints: [{ side: "west", fromLevelId: 41, toLevelId: 42, blockCoord: -10 }],
})
const west = result.stats.gateOpenings!.find(g => g.side === "west")
expect(west?.blockCoord).toBe(1)
})
test("clamps excessive blockCoord to gridHeight - 2 without crashing", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x666,
pieces,
substitutions: [],
seamConstraints: [{ side: "east", fromLevelId: 43, toLevelId: 42, blockCoord: 999 }],
})
const east = result.stats.gateOpenings!.find(g => g.side === "east")
expect(east?.blockCoord).toBe(8) // 80 / 8 = 10 blocks -> max is 10 - 2 = 8
})
})
})

View File

@ -1,410 +0,0 @@
/**
* Wilderness Seed Sensitivity Test Suite (Milestone M1).
*
* Verifies:
* 1. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5.
* 2. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway).
* 3. Deterministic re-run invariance holds unconditionally.
*
* Act I outdoor levels come from the DRLG port (src/game/drlg) and are covered by
* tests/drlg-act1-oracle.test.ts (bit-exact parity with D2) and tests/drlg-act1-population.test.ts.
*/
import { describe, expect, it, beforeAll } from 'vitest'
import { existsSync } from 'node:fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts'
import type { D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
createCanvas,
type Canvas,
type WildernessPiece,
type WildernessSubstitution,
type WildernessStats,
} from '../src/game/wilderness.ts'
import { stampAct3KurastCauseway } from '../src/game/wilderness-acts.ts'
import { stampAct5SiegeStrips } from '../src/game/wilderness-siege.ts'
import { Rng } from '../src/game/rng.ts'
/* ------------------------------------------------------------------------- *
* FNV-1a Canonical Map Hasher
* ------------------------------------------------------------------------- */
class Hasher {
private hash = 2166136261
push(value: number): this {
const word = Math.trunc(value) | 0
for (let byte = 0; byte < 4; byte += 1) {
this.hash ^= (word >>> (byte * 8)) & 0xff
this.hash = Math.imul(this.hash, 16777619) >>> 0
}
return this
}
get digest(): string {
return (this.hash >>> 0).toString(16).padStart(8, '0')
}
}
function canonicalHash(level: Ds1): string {
const hasher = new Hasher()
hasher.push(level.version).push(level.width).push(level.height).push(level.act)
hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers)
for (let y = 0; y < level.height; y += 1) {
const row = level.cells[y]
if (row === undefined) continue
for (let x = 0; x < level.width; x += 1) {
const cellRecord = row[x]
if (cellRecord === undefined) continue
for (const wall of cellRecord.walls) {
hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0)
}
for (const floor of cellRecord.floors) {
hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0)
}
for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style)
for (const substitution of cellRecord.substitutions) hasher.push(substitution.value)
}
}
for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags)
return hasher.digest
}
/* ------------------------------------------------------------------------- *
* Mock Helpers for Hermetic Testing
* ------------------------------------------------------------------------- */
function makeMockDs1(width: number, height: number, floorStyle: number = 1): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const ds1 = makeMockDs1(8, 8)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [ds1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [ds1] },
]
}
/* ------------------------------------------------------------------------- *
* Canonical Master Test Seeds
* ------------------------------------------------------------------------- */
const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
/* ------------------------------------------------------------------------- *
* Test Suite 1: Hermetic API & Strict Contract Safety
* ------------------------------------------------------------------------- */
describe('Hermetic Contract & RNG Parameter Safety', () => {
it('stampAct5SiegeStrips rejects missing or invalid Rng parameter with Error', () => {
const canvas: Canvas = createCanvas(64, 64, 1, 1, 1)
const stats: WildernessStats = {
substitutions: [],
notes: [],
} as unknown as WildernessStats
expect(() => {
stampAct5SiegeStrips(canvas, [], null as unknown as Rng, stats)
}).toThrow(/missing required Rng parameter/)
expect(() => {
stampAct5SiegeStrips(canvas, [], {} as unknown as Rng, stats)
}).toThrow(/missing required Rng parameter/)
})
it('stampAct5SiegeStrips rejects missing WildernessStats parameter with Error', () => {
const canvas: Canvas = createCanvas(64, 64, 1, 1, 1)
const rng = new Rng(42)
expect(() => {
stampAct5SiegeStrips(canvas, [], rng, null as unknown as WildernessStats)
}).toThrow(/missing required WildernessStats parameter/)
})
it('stampAct3KurastCauseway rejects missing or invalid Rng parameter with Error', () => {
const canvas: Canvas = createCanvas(64, 64, 1, 1, 1)
const stats: WildernessStats = {
substitutions: [],
notes: [],
} as unknown as WildernessStats
expect(() => {
stampAct3KurastCauseway(canvas, [], null as unknown as Rng, stats)
}).toThrow(/missing required Rng instance/)
expect(() => {
stampAct3KurastCauseway(canvas, [], {} as unknown as Rng, stats)
}).toThrow(/missing required Rng instance/)
})
it('hermetic mock generation produces distinct hashes for different seeds and deterministic re-runs', () => {
const pieces = makeMockBorderPieces('Act 2 - Desert')
const hashes: string[] = []
for (const seed of TEST_SEEDS) {
const run1 = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste Mock',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
const run2 = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste Mock',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
const hash1 = canonicalHash(run1.level)
const hash2 = canonicalHash(run2.level)
expect(hash1).toBe(hash2)
hashes.push(hash1)
}
// Changing seed produces distinct layouts
expect(new Set(hashes).size).toBe(TEST_SEEDS.length)
})
})
/* ------------------------------------------------------------------------- *
* Test Suite 2: MPQ Ground-Truth Baseline & Seed Sensitivity
* ------------------------------------------------------------------------- */
const hasMpqArchives = existsSync('samples/d2/d2data.mpq')
// Act I outdoor levels come from the DRLG port (src/game/drlg): its determinism and seed sensitivity
// are checked in tests/drlg-act1-population.test.ts, its parity with D2 in tests/drlg-act1-oracle.test.ts.
describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 2..5', () => {
let archives: MountedArchives
let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table }
let lvlsub: D2Table
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(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
}
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(value))
}
return levels
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
async function buildWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
}
async function buildSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(file)]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0),
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0),
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0),
levels,
})
}
return rows
}
beforeAll(async () => {
archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
} catch {
// Skip missing optional archive
}
}
const actTables = await loadActTables(archives)
tables = {
levels: actTables.levels,
lvltypes: actTables.lvltypes,
lvlprest: actTables.lvlprest,
}
lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
})
async function generateLevelForSeed(levelId: number, seed: number): Promise<Ds1> {
const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)!
const typeId = cell(tables.levels, levelRow, 'LevelType')
const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
const name = cell(tables.levels, levelRow, 'Name')
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await buildWildernessPieces(levelTypeName)
const subs = await buildSubstitutions(subType)
const shrineSubs = await buildSubstitutions(subShrine)
const result = generateWilderness({
levelId,
levelName: name,
levelTypeName,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
})
return result.level
}
it('Act 2 procedural outdoor levels produce distinct hashes across seeds', async () => {
const act2Levels = [41, 42, 43, 44, 45, 46]
for (const levelId of act2Levels) {
const hashes: string[] = []
for (const seed of TEST_SEEDS) {
const level = await generateLevelForSeed(levelId, seed)
hashes.push(canonicalHash(level))
}
expect(new Set(hashes).size).toBe(3)
}
}, 60000)
it('Act 3 procedural outdoor levels produce distinct hashes across seeds', async () => {
const act3Levels = [76, 77, 78, 79, 80, 81]
for (const levelId of act3Levels) {
const hashes: string[] = []
for (const seed of TEST_SEEDS) {
const level = await generateLevelForSeed(levelId, seed)
hashes.push(canonicalHash(level))
}
expect(new Set(hashes).size).toBe(3)
}
}, 60000)
it('Act 4 procedural outdoor levels produce distinct hashes across seeds', async () => {
const act4Levels = [104, 105, 106]
for (const levelId of act4Levels) {
const hashes: string[] = []
for (const seed of TEST_SEEDS) {
const level = await generateLevelForSeed(levelId, seed)
hashes.push(canonicalHash(level))
}
expect(new Set(hashes).size).toBe(3)
}
}, 60000)
it('Act 5 procedural outdoor levels produce distinct hashes across seeds', async () => {
const act5Levels = [110, 111, 112, 117, 134]
for (const levelId of act5Levels) {
const hashes: string[] = []
for (const seed of TEST_SEEDS) {
const level = await generateLevelForSeed(levelId, seed)
hashes.push(canonicalHash(level))
}
expect(new Set(hashes).size).toBe(3)
}
}, 60000)
it('Wilderness generation determinism invariance holds across repeated runs', async () => {
// Spot check one level per Act (Act I: tests/drlg-act1-population.test.ts)
const sampleLevels = [41, 79, 104, 111]
for (const levelId of sampleLevels) {
const seed = TEST_SEEDS[0]
const first = await generateLevelForSeed(levelId, seed)
const second = await generateLevelForSeed(levelId, seed)
expect(canonicalHash(first)).toBe(canonicalHash(second))
}
}, 60000)
})

View File

@ -1,473 +0,0 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
} from "../src/game/wilderness.ts"
import type { WildernessPiece } from "../src/game/wilderness.ts"
import {
ACT5_SIEGE_STRIP_NAMES,
type SiegeTopographyStats,
} from "../src/game/wilderness-siege.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockStripDs1(
width: number,
height: number,
tagSequence: number,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [
{
prop1: 2,
sequence: x === 0 && y === 0 ? tagSequence : 0,
style: 0,
unknown1: 0,
unknown2: 0,
hidden: false,
},
],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function createMockSiegePieces(): WildernessPiece[] {
return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({
name,
border: false,
levels: [makeMockStripDs1(16, 48, 100 + idx)],
}))
}
describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => {
test("DRLGOUTSIEGE_InitAct5OutdoorLevel is removed from UNIMPLEMENTED_PASSES", () => {
expect(UNIMPLEMENTED_PASSES).not.toContain(
"DRLGOUTSIEGE_InitAct5OutdoorLevel",
)
})
test("generates 240x48 Bloody Foothills canvas with 15 sequential strips from West to East", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 12345,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(240)
expect(result.level.height).toBe(48)
expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 })
expect(result.stats.substitutedClusters).toBe(15)
// Strip 0 (Siege To Barricade) is at x=0, Strip 14 (Siege To Town) is at x=224
expect(result.level.cells[0]![0]!.floors[0]!.sequence).toBe(100)
expect(result.level.cells[0]![224]!.floors[0]!.sequence).toBe(114)
})
test("supports dynamic strip preset level variants selected by seed", () => {
const pieces: WildernessPiece[] = ACT5_SIEGE_STRIP_NAMES.map((name, idx) => {
// Provide 3 different DS1 variants with distinct floor tags at (0,1)
const varA = makeMockStripDs1(16, 48, 100 + idx)
const varB = makeMockStripDs1(16, 48, 200 + idx)
const varC = makeMockStripDs1(16, 48, 300 + idx)
;(varA.cells[1]![0]!.floors as any)[0].sequence = 1
;(varB.cells[1]![0]!.floors as any)[0].sequence = 2
;(varC.cells[1]![0]!.floors as any)[0].sequence = 3
return {
name,
border: false,
levels: [varA, varB, varC],
}
})
const runA = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const runB = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 999999,
pieces,
substitutions: [],
})
// Compare variant selections across strips
const tagsA = [16, 32, 48, 64, 80, 96, 112].map(
x => runA.level.cells[1]![x]!.floors[0]!.sequence,
)
const tagsB = [16, 32, 48, 64, 80, 96, 112].map(
x => runB.level.cells[1]![x]!.floors[0]!.sequence,
)
// At least one strip variant differs between the two seeds
expect(tagsA).not.toEqual(tagsB)
})
test("supports dynamic strip selection with named variant pieces", () => {
const pieces = createMockSiegePieces()
// Replace Strip 5 with a named variant piece
const strip5Idx = pieces.findIndex(p => p.name === "Act 5 - Siege Strip 5")
pieces[strip5Idx] = {
name: "Act 5 - Siege Strip 5 Variant Bravo",
border: false,
levels: [makeMockStripDs1(16, 48, 777)],
}
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 777,
pieces,
substitutions: [],
})
// Strip 5 is at slot index 9 (x = 9 * 16 = 144)
expect(result.level.cells[0]![144]!.floors[0]!.sequence).toBe(777)
})
test("generates battlefield trenches with cleared wall channels", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats).toBeDefined()
expect(stats.trenchCells).toBeGreaterThan(60)
// Scan level for trench floor cells (style: 5, prop1: 194)
let foundTrenchCells = 0
let wallsClearedInTrenches = true
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
const hasTrenchFloor = cell.floors.some(
f => f.style === 5 && f.prop1 === 194,
)
if (hasTrenchFloor) {
foundTrenchCells += 1
// Trenches must be cleared of blocking walls
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
wallsClearedInTrenches = false
}
}
}
}
expect(foundTrenchCells).toBe(stats.trenchCells)
expect(wallsClearedInTrenches).toBe(true)
})
test("generates breached wooden barricades with splintered end-caps and open breach gaps", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats.barricadeWalls).toBeGreaterThan(30)
// Check for barricade walls (style: 2, prop1: 129)
let foundBarricadeWalls = 0
let foundSplinteredEndCaps = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
const barricadeWall = cell.walls.find(
w => !w.hidden && w.style === 2 && w.prop1 === 129,
)
if (barricadeWall) {
foundBarricadeWalls += 1
if (barricadeWall.type === 7 && (barricadeWall.sequence === 10 || barricadeWall.sequence === 11)) {
foundSplinteredEndCaps += 1
}
}
}
}
expect(foundBarricadeWalls).toBe(stats.barricadeWalls)
// Splintered end-caps (type: 7, seq: 10/11) must flank the breaches
expect(foundSplinteredEndCaps).toBeGreaterThan(0)
})
test("generates catapult defense platforms with parapets", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats.catapultPlatforms).toBe(3)
// Scan for catapult platform floors (style: 4, prop1: 2)
let platformFloorCount = 0
let platformParapetCount = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) {
platformFloorCount += 1
}
if (cell.walls.some(w => !w.hidden && (w.style === 4 || w.style === 2) && w.prop1 === 2)) {
platformParapetCount += 1
}
}
}
expect(platformFloorCount).toBeGreaterThan(30)
expect(platformParapetCount).toBeGreaterThan(15)
})
test("generates fort ruins interspersed between siege strips", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats.fortRuins).toBe(4)
// Scan for fort ruin flagstone floors (style: 1 or 18, prop1: 2) and stone walls (style: 1 or 18, prop1: 129)
let ruinFloorCount = 0
let ruinWallCount = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => (f.style === 1 || f.style === 18) && f.prop1 === 2)) {
ruinFloorCount += 1
}
if (cell.walls.some(w => !w.hidden && (w.style === 1 || w.style === 18) && w.prop1 === 129)) {
ruinWallCount += 1
}
}
}
expect(ruinFloorCount).toBeGreaterThan(40)
expect(ruinWallCount).toBeGreaterThan(15)
})
test("enforces progressive frontline fortification density from East (Harrogath) to West (Barricade)", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
const { westFrontlineObstacles, midFieldObstacles, eastRearObstacles } =
stats.frontlineDensity
// West Frontline (Shenk / Arreat Barricade) > Mid Field > East Rear (Harrogath)
expect(westFrontlineObstacles).toBeGreaterThan(midFieldObstacles)
expect(midFieldObstacles).toBeGreaterThanOrEqual(eastRearObstacles)
expect(eastRearObstacles).toBeGreaterThan(0)
expect(westFrontlineObstacles).toBe(10)
expect(midFieldObstacles).toBe(6)
expect(eastRearObstacles).toBe(3)
})
test("preserves traversable central thoroughfare along y=24 from Harrogath exit to Barricade entrance", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
// Walk horizontally across y = 24 from East (x = 239) to West (x = 0)
const yCorridor = 24
let blockedCells = 0
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[yCorridor]![x]!
const hasBlockingWall = cell.walls.some(
w => !w.hidden && w.prop1 !== 0,
)
if (hasBlockingWall) {
blockedCells += 1
}
}
// Central line along y = 24 should have 0 blocking walls
expect(blockedCells).toBe(0)
})
test("records notes and telemetry in result.stats", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const notes = result.stats.notes as string[]
expect(notes).toContain("generated dynamic siege trenches and fortifications")
expect(result.stats.siegeTopography).toBeDefined()
})
test("generates varied obstacle layouts with different seeds (seed diversity)", () => {
const pieces = createMockSiegePieces()
const result1 = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 11001,
pieces,
substitutions: [],
})
const result2 = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 11002,
pieces,
substitutions: [],
})
// Collect obstacle wall coordinates for both seeds
const walls1 = new Set<string>()
const walls2 = new Set<string>()
for (let y = 0; y < result1.level.height; y += 1) {
for (let x = 0; x < result1.level.width; x += 1) {
if (result1.level.cells[y]![x]!.walls.some(w => !w.hidden && w.prop1 !== 0)) {
walls1.add(`${x},${y}`)
}
if (result2.level.cells[y]![x]!.walls.some(w => !w.hidden && w.prop1 !== 0)) {
walls2.add(`${x},${y}`)
}
}
}
// Both should have placed walls
expect(walls1.size).toBeGreaterThan(0)
expect(walls2.size).toBeGreaterThan(0)
// The obstacle coordinates must differ across different seeds
let diffCount = 0
for (const coord of walls1) {
if (!walls2.has(coord)) diffCount += 1
}
expect(diffCount).toBeGreaterThan(0)
})
})

View File

@ -1,253 +0,0 @@
import { describe, it, expect } from 'vitest'
import { Rng } from '../src/game/rng.ts'
import {
catmullRomPoint,
sampleCatmullRomSpline,
createDrlgVertices,
DRLGVER_CreateVertices,
buildOutdoorRoadPolylines,
buildRoadNetworkPolylines,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { Act1OutdoorPlan } from '../src/game/wilderness.ts'
// Act I outdoor levels (2-7, 17, 39) come from the 1:1 D2MOO DRLG port in
// src/game/drlg (verified by tests/drlg-act1-oracle.test.ts); the Act I dirt
// path rasterizer and its end-to-end cases were removed with the invented
// generator. The vertex-graph / spline helpers below are still used by the
// Acts 2-5 wilderness generator.
describe('Control Vertex Graph & Spline Roads (Issue #66)', () => {
describe('Catmull-Rom Spline Mathematics & Sampling', () => {
it('accurately interpolates start and end control points at t=0 and t=1', () => {
const p0 = { x: 0, y: 0 }
const p1 = { x: 10, y: 20 }
const p2 = { x: 30, y: 50 }
const p3 = { x: 60, y: 60 }
const start = catmullRomPoint(p0, p1, p2, p3, 0)
expect(start.x).toBeCloseTo(p1.x)
expect(start.y).toBeCloseTo(p1.y)
const end = catmullRomPoint(p0, p1, p2, p3, 1)
expect(end.x).toBeCloseTo(p2.x)
expect(end.y).toBeCloseTo(p2.y)
const mid = catmullRomPoint(p0, p1, p2, p3, 0.5)
expect(mid.x).toBeGreaterThan(p1.x)
expect(mid.x).toBeLessThan(p2.x)
expect(mid.y).toBeGreaterThan(p1.y)
expect(mid.y).toBeLessThan(p2.y)
})
it('generates smooth continuous points along a multi-segment spline', () => {
const controlPoints = [
{ x: 10, y: 10 },
{ x: 25, y: 18 },
{ x: 45, y: 35 },
{ x: 70, y: 65 },
]
const bounds = { width: 100, height: 100 }
const sampled = sampleCatmullRomSpline(controlPoints, bounds)
expect(sampled.length).toBeGreaterThan(controlPoints.length)
expect(sampled[0]).toEqual({ x: 10, y: 10 })
expect(sampled[sampled.length - 1]).toEqual({ x: 70, y: 65 })
// Verify continuity: adjacent points have step <= 2 cells
for (let i = 0; i < sampled.length - 1; i += 1) {
const dx = Math.abs(sampled[i + 1]!.x - sampled[i]!.x)
const dy = Math.abs(sampled[i + 1]!.y - sampled[i]!.y)
expect(Math.max(dx, dy)).toBeLessThanOrEqual(2)
}
})
it('exhibits natural non-orthogonal/non-45° curvature with continuous angles', () => {
// S-curve spline
const controlPoints = [
{ x: 10, y: 10 },
{ x: 20, y: 35 },
{ x: 50, y: 40 },
{ x: 60, y: 70 },
]
const bounds = { width: 80, height: 80 }
const sampled = sampleCatmullRomSpline(controlPoints, bounds)
// Calculate slopes / directions between points sampled along the spline
const uniqueAngles = new Set<number>()
for (let i = 0; i < sampled.length - 3; i += 3) {
const dx = sampled[i + 3]!.x - sampled[i]!.x
const dy = sampled[i + 3]!.y - sampled[i]!.y
if (dx !== 0 || dy !== 0) {
const angle = Math.round((Math.atan2(dy, dx) * 180) / Math.PI)
uniqueAngles.add(angle)
}
}
// In a pure 45°/90° grid path, angles would only be {0, 45, 90, 135, 180, -45, -90, -135}.
// A Catmull-Rom spline curves smoothly with many distinct angles across the S-curve.
expect(uniqueAngles.size).toBeGreaterThanOrEqual(4)
const nonGridAngles = Array.from(uniqueAngles).filter(a => a % 45 !== 0)
expect(nonGridAngles.length).toBeGreaterThan(0)
})
it('clamps spline points strictly within bounds', () => {
const controlPoints = [
{ x: -10, y: -5 },
{ x: 20, y: 30 },
{ x: 120, y: 110 },
]
const bounds = { width: 80, height: 80 }
const sampled = sampleCatmullRomSpline(controlPoints, bounds)
for (const pt of sampled) {
expect(pt.x).toBeGreaterThanOrEqual(0)
expect(pt.x).toBeLessThan(80)
expect(pt.y).toBeGreaterThanOrEqual(0)
expect(pt.y).toBeLessThan(80)
}
})
it('deflects spline points around obstacle blocks', () => {
const bounds = { width: 80, height: 80 }
const roadObstacleBlocks = Array.from({ length: 10 }, () =>
Array.from({ length: 10 }, () => false),
)
// Mark block (bx=3, by=3) as an obstacle (cells 24..31 in x and y)
roadObstacleBlocks[3]![3] = true
const controlPoints = [
{ x: 10, y: 28 },
{ x: 45, y: 28 },
]
const sampled = sampleCatmullRomSpline(controlPoints, bounds, roadObstacleBlocks)
// No point should fall inside the obstacle block (bx=3, by=3)
for (const pt of sampled) {
const bx = Math.floor(pt.x / TILES_PER_BLOCK)
const by = Math.floor(pt.y / TILES_PER_BLOCK)
expect(roadObstacleBlocks[by]?.[bx]).toBe(false)
}
})
})
describe('DRLGVER Control Vertex Graph Generation', () => {
it('creates vertices for gates, hub, presets, and inflections with polylines', () => {
const plan: Act1OutdoorPlan = {
openGates: new Map([
['top_4', 3],
['bottom_4', 3],
['left_4', 3],
]),
claimedBorderBlocks: new Set(['top_4', 'bottom_4', 'left_4']),
claimedBlocks: Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false)),
roadObstacleBlocks: Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false)),
specialPresets: [
{
name: 'Den of Evil',
ds1: null as any,
bx: 7,
by: 7,
blocksX: 2,
blocksY: 2,
},
],
anchors: [
{
label: 'North Gate',
anchorCell: { x: 36, y: 0 },
interiorCell: { x: 36, y: 8 },
side: 'north',
kind: 'gate',
},
{
label: 'South Gate',
anchorCell: { x: 36, y: 79 },
interiorCell: { x: 36, y: 71 },
side: 'south',
kind: 'gate',
},
{
label: 'West Gate',
anchorCell: { x: 0, y: 36 },
interiorCell: { x: 8, y: 36 },
side: 'west',
kind: 'gate',
},
{
label: 'Den of Evil Entrance',
anchorCell: { x: 56, y: 56 },
interiorCell: { x: 56, y: 52 },
kind: 'preset',
},
],
}
const rng = new Rng(0x1234_5678)
const res = createDrlgVertices(plan, 10, 10, rng)
expect(res.vertices.length).toBeGreaterThanOrEqual(4)
expect(res.polylines.length).toBe(plan.anchors.length)
expect(res.lines).toBe(res.polylines)
// Verify vertex types
const types = res.vertices.map(v => v.type)
expect(types).toContain('hub')
expect(types).toContain('gate')
expect(types).toContain('preset')
const hub = res.vertices.find(v => v.type === 'hub')!
expect(hub).toBeDefined()
expect(hub.x).toBeGreaterThan(0)
expect(hub.y).toBeGreaterThan(0)
// Verify DRLGVER_CreateVertices alias parity
const aliasRes = DRLGVER_CreateVertices(plan, 10, 10, new Rng(0x1234_5678))
expect(aliasRes.vertices.length).toBe(res.vertices.length)
expect(aliasRes.polylines.length).toBe(res.polylines.length)
// Verify buildOutdoorRoadPolylines and buildRoadNetworkPolylines
const polylines = buildOutdoorRoadPolylines(plan, 10, 10, new Rng(0x1234_5678))
expect(polylines.length).toBe(res.polylines.length)
const aliasPolylines = buildRoadNetworkPolylines(plan, 10, 10, new Rng(0x1234_5678))
expect(aliasPolylines).toEqual(polylines)
})
it('ensures central hub is relocated if primary centroid is blocked by obstacles', () => {
const obstacleBlocks = Array.from({ length: 10 }, () =>
Array.from({ length: 10 }, () => false),
)
// Block centroid block (5, 5)
obstacleBlocks[5]![5] = true
const plan: Act1OutdoorPlan = {
openGates: new Map(),
claimedBorderBlocks: new Set(),
claimedBlocks: obstacleBlocks,
roadObstacleBlocks: obstacleBlocks,
specialPresets: [],
anchors: [
{
label: 'West Gate',
anchorCell: { x: 0, y: 40 },
interiorCell: { x: 8, y: 40 },
side: 'west',
kind: 'gate',
},
{
label: 'East Gate',
anchorCell: { x: 79, y: 40 },
interiorCell: { x: 71, y: 40 },
side: 'east',
kind: 'gate',
},
],
}
const res = createDrlgVertices(plan, 10, 10, new Rng(42))
const hub = res.vertices.find(v => v.type === 'hub')!
const hubBx = Math.floor(hub.x / TILES_PER_BLOCK)
const hubBy = Math.floor(hub.y / TILES_PER_BLOCK)
expect(obstacleBlocks[hubBy]![hubBx]).toBe(false)
})
})
})

View File

@ -1,487 +0,0 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
buildLandmarkEntitiesFromTables,
spawnLandmarkEntities,
CANONICAL_LANDMARK_ENTITIES,
type LandmarkEntityDef,
type WildernessPiece,
} from "../src/game/wilderness.ts"
import {
planLevelMonsters,
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
CANONICAL_ELITE_MODIFIERS,
lookupSuperUnique,
buildSuperUniqueLandmarkSpecs,
CANONICAL_SUPERUNIQUES_TABLE,
} from "../src/game/monsters.ts"
import { parseTable } from "../src/game/acts.ts"
import type { D2Table } from "../src/game/acts.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function mockTable(tsv: string): D2Table {
const encoder = new TextEncoder()
return parseTable(encoder.encode(tsv.trim()))
}
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 1, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
test("contains all 19 canonical landmark entities across Acts 2-5", () => {
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(19)
// Act 1: none. Act I outdoor levels come from the DRLG port (src/game/drlg), whose preset units
// place Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven, the Cow King and Flavie; that is
// checked on the port's output in tests/drlg-act1-population.test.ts.
expect(CANONICAL_LANDMARK_ENTITIES.filter(e => e.act < 2 || e.levelIds.some(id => id < 40))).toEqual([])
// Act 2 (5 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dark Elder" && e.levelIds.includes(44))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Creeping Feature" && e.levelIds.includes(59))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bloodwitch the Wild" && e.levelIds.includes(60))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Fangskin" && e.levelIds.includes(61))).toBe(true)
// Act 3 (4 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Stormtree" && e.levelIds.includes(78))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Sszark the Burning" && e.levelIds.includes(84))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Witch Doctor Endugu" && e.levelIds.includes(91))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Battlemaid Sarina" && e.levelIds.includes(94))).toBe(true)
// Act 4 (3 entities: 2 SuperUniques + 1 NPC)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Izual" && e.levelIds.includes(105))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hephasto the Armorer" && e.levelIds.includes(107))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hadriel" && e.levelIds.includes(108))).toBe(true)
// Act 5 (7 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dac Farren" && e.levelIds.includes(110))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Shenk the Overseer" && e.levelIds.includes(110))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Eyeback the Unleashed" && e.levelIds.includes(111))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Thresh Socket" && e.levelIds.includes(112))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Frozenstein" && e.levelIds.includes(114))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bonesaw Breaker" && e.levelIds.includes(115))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Nihlathak" && e.levelIds.includes(124))).toBe(true)
})
})
describe("buildLandmarkEntitiesFromTables", () => {
test("returns canonical definitions when no tables are supplied", () => {
const entities = buildLandmarkEntitiesFromTables()
expect(entities.length).toBe(CANONICAL_LANDMARK_ENTITIES.length)
expect(entities[0].name).toBe(CANONICAL_LANDMARK_ENTITIES[0].name)
})
test("overrides boss modifiers and properties from SuperUniques.txt", () => {
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
const superUniquesTsv = [
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
"Beetleburst\tscarab3\t8\t9\t0\t2\t5\tscarab1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
"Dark Elder\tzombie4\t17\t6\t0\t3\t6\tzombie1\t0\t0\t0\tAct 2 Super A\tAct 2 Super A\tAct 2 Super A",
].join("\n")
const suTable = mockTable(superUniquesTsv)
const entities = buildLandmarkEntitiesFromTables(suTable)
const beetleburst = entities.find(e => e.id === "Beetleburst")
expect(beetleburst).toBeDefined()
expect(beetleburst?.monsterId).toBe("scarab3")
expect(beetleburst?.modifiers).toEqual(["magicresistant", "fireenchant"])
expect(beetleburst?.minionCount).toEqual([2, 5])
const darkElder = entities.find(e => e.id === "Dark Elder")
expect(darkElder).toBeDefined()
expect(darkElder?.monsterId).toBe("zombie4")
expect(darkElder?.modifiers).toEqual(["lightenchant", "fast"])
expect(darkElder?.minionCount).toEqual([3, 6])
})
test("resolves objectId from MonPreset.txt", () => {
const monPresetTsv = [
"Act\tPlace\tComponent\tObjectId",
"2\tDesert Oasis 1\t1\t105",
"4\tChaos Sanctuary\t1\t350",
].join("\n")
const mpTable = mockTable(monPresetTsv)
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
const beetleburst = entities.find(e => e.name === "Beetleburst")
expect(beetleburst?.objectId).toBe(105)
const hadriel = entities.find(e => e.name === "Hadriel")
expect(hadriel?.objectId).toBe(350)
})
})
describe("spawnLandmarkEntities in Wilderness Generator", () => {
// Act I SuperUniques (Rakanishu on the Cairn Stones, ...) are preset units of the DRLG port's
// maps; tests/drlg-act1-population.test.ts checks them. The same anchoring for Acts 2-5:
test("places Creeping Feature centered on the Desert Tomb 1 preset piece", () => {
const tombDs1 = makeMockDs1(16, 16)
const borderDs1 = makeMockDs1(8, 8)
const pieces: WildernessPiece[] = [
{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] },
{ name: "Act 2 - Desert Tomb 1", border: false, levels: [tombDs1] },
]
const result = generateWilderness({
levelId: 41,
levelName: "Rocky Waste",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 42,
pieces,
substitutions: [],
specialPresets: ["Act 2 - Desert Tomb 1"],
})
expect(result.stats.landmarkEntities).toBeDefined()
const creeping = result.stats.landmarkEntities?.find(e => e.name === "Creeping Feature")
expect(creeping).toBeDefined()
expect(creeping?.levelId).toBe(41)
// Verify subtile coordinates: tileX * 5 + 2
expect(creeping?.subtileX).toBe(creeping!.tileX * 5 + 2)
expect(creeping?.subtileY).toBe(creeping!.tileY * 5 + 2)
// Verify canvas.objects contains the Type 1 entity
const obj = result.level.objects.find(
o => o.type === 1 && o.x === creeping!.subtileX && o.y === creeping!.subtileY
)
expect(obj).toBeDefined()
expect(obj?.id).toBe(creeping?.objectId)
})
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
const stripDs1 = makeMockDs1(16, 48)
const pieces: WildernessPiece[] = [
{ name: "Act 5 - Siege To Barricade", border: false, levels: [stripDs1] },
]
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 110,
pieces,
substitutions: [],
})
const landmarks = result.stats.landmarkEntities ?? []
const dacFarren = landmarks.find(e => e.name === "Dac Farren")
const shenk = landmarks.find(e => e.name === "Shenk the Overseer")
expect(dacFarren).toBeDefined()
expect(shenk).toBeDefined()
expect(dacFarren?.objectId).toBe(87)
expect(shenk?.objectId).toBe(88)
// Both must be recorded in canvas.objects as type 1
const dacObj = result.level.objects.find(
o => o.type === 1 && o.x === dacFarren!.subtileX && o.y === dacFarren!.subtileY
)
const shenkObj = result.level.objects.find(
o => o.type === 1 && o.x === shenk!.subtileX && o.y === shenk!.subtileY
)
expect(dacObj).toBeDefined()
expect(shenkObj).toBeDefined()
})
test("places Act 4 Hadriel on Chaos Sanctuary (Level 108)", () => {
const csDs1 = makeMockDs1(25, 25)
const pieces: WildernessPiece[] = [
{ name: "Act 4 - Diablo Entry", border: false, levels: [csDs1] },
]
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
const hadriel = result.stats.landmarkEntities?.find(e => e.name === "Hadriel")
expect(hadriel).toBeDefined()
expect(hadriel?.objectId).toBe(86)
expect(hadriel?.role).toBe("npc_guard")
const obj = result.level.objects.find(
o => o.type === 1 && o.x === hadriel!.subtileX && o.y === hadriel!.subtileY
)
expect(obj).toBeDefined()
expect(obj?.id).toBe(86)
})
test("supports customLandmarks override in WildernessRequest", () => {
const borderDs1 = makeMockDs1(8, 8)
const customLandmarks: LandmarkEntityDef[] = [
{
id: "CustomBoss",
name: "Test Custom Boss",
role: "superunique",
act: 2,
levelIds: [42],
monsterId: "fallen1",
defaultLandmark: "Custom Landmark",
modifiers: ["lightenchant", "fast"],
minionCount: [4, 6],
minionMonsterId: "fallen1",
objectId: 999,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
]
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 123,
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
substitutions: [],
customLandmarks,
})
expect(result.stats.landmarkEntities?.length).toBe(1)
const custom = result.stats.landmarkEntities?.[0]
expect(custom?.name).toBe("Test Custom Boss")
expect(custom?.tileX).toBe(40)
expect(custom?.tileY).toBe(40)
expect(custom?.objectId).toBe(999)
})
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
const borderDs1 = makeMockDs1(8, 8)
const result = generateWilderness({
levelId: 43, // Far Oasis (Beetleburst)
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 100,
sizeY: 100,
subType: 6,
subTheme: 0,
seed: 99,
pieces: [{ name: "Act 2 - Desert Border 1", border: true, levels: [borderDs1] }],
substitutions: [],
})
const beetleburst = result.stats.landmarkEntities?.find(e => e.name === "Beetleburst")
expect(beetleburst).toBeDefined()
expect(beetleburst?.tileX).toBeGreaterThanOrEqual(1)
expect(beetleburst?.tileX).toBeLessThan(99)
expect(beetleburst?.tileY).toBeGreaterThanOrEqual(1)
expect(beetleburst?.tileY).toBeLessThan(99)
})
})
describe("Integration with planLevelMonsters (src/game/monsters.ts)", () => {
test("Aura Enchanted modifier exists in CANONICAL_ELITE_MODIFIERS", () => {
const aura = CANONICAL_ELITE_MODIFIERS.find(m => m.name === "aura")
expect(aura).toBeDefined()
expect(aura?.label).toBe("Aura Enchanted")
expect(aura?.id).toBe(30)
})
test("CANONICAL_SUPER_UNIQUES_BY_LEVEL maps all 5 Acts correctly", () => {
// Act 1
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[3]).toBeDefined() // Bishibosh
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[4]).toBeDefined() // Rakanishu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[5]).toBeDefined() // Treehead WoodFist
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[17]).toBeDefined() // Blood Raven
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[25]).toBeDefined() // Countess
// Act 2
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[43]).toBeDefined() // Beetleburst
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[44]).toBeDefined() // Dark Elder
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[59]).toBeDefined() // Creeping Feature
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[60]).toBeDefined() // Bloodwitch
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[61]).toBeDefined() // Fangskin
// Act 3
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[76]).toBeDefined() // Sszark
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[78]).toBeDefined() // Stormtree & Endugu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[84]).toBeDefined() // Sszark
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[88]).toBeDefined() // Endugu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[91]).toBeDefined() // Endugu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[94]).toBeDefined() // Sarina
// Act 4
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[105]).toBeDefined() // Izual
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[107]).toBeDefined() // Hephasto
// Act 5
const act5Bloody = CANONICAL_SUPER_UNIQUES_BY_LEVEL[110] // Dac Farren & Shenk
expect(Array.isArray(act5Bloody)).toBe(true)
expect((act5Bloody as readonly unknown[]).length).toBe(2)
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[111]).toBeDefined() // Eyeback
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[112]).toBeDefined() // Thresh Socket
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[114]).toBeDefined() // Frozenstein
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[115]).toBeDefined() // Bonesaw
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[124]).toBeDefined() // Nihlathak
})
test("planLevelMonsters creates deterministic boss packs for multi-boss level 110", () => {
const levelsTxt = mockTable([
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
"110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1",
].join("\n"))
const monstatsTxt = mockTable([
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"imp1\timp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",
].join("\n"))
const monlvlTxt = mockTable([
"Level\tAC\tTH\tHP\tDM\tXP",
"35\t100\t100\t100\t100\t100",
].join("\n"))
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, "normal")
expect(plan.superUniques).toContain("Dac Farren")
expect(plan.superUniques).toContain("Shenk the Overseer")
const dacPack = plan.packs.find(p => p.superUniqueId === "Dac Farren")
const shenkPack = plan.packs.find(p => p.superUniqueId === "Shenk the Overseer")
expect(dacPack).toBeDefined()
expect(shenkPack).toBeDefined()
expect(dacPack?.members[0]?.rank).toBe("unique")
expect(shenkPack?.members[0]?.rank).toBe("unique")
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
})
test("lookupSuperUnique retrieves canonical superuniques by id or name", () => {
const bishibosh = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "Bishibosh")
expect(bishibosh).toBeDefined()
expect(bishibosh?.monsterId).toBe("fallenshaman1")
expect(bishibosh?.minMinions).toBe(5)
expect(bishibosh?.maxMinions).toBe(7)
const caseInsensitive = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "rakanishu")
expect(caseInsensitive).toBeDefined()
expect(caseInsensitive?.id).toBe("Rakanishu")
expect(caseInsensitive?.monsterId).toBe("fallen1")
expect(lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "nonexistent")).toBeUndefined()
})
test("buildSuperUniqueLandmarkSpecs builds specs from custom SuperUniques table", () => {
const customSuTable = mockTable([
"Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)",
"Bishibosh\tBishibosh Custom\tfallenshaman2\t6\t28\t0\t8\t10\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A",
].join("\n"))
const specsByLevel = buildSuperUniqueLandmarkSpecs(customSuTable)
const bishiboshSpec = specsByLevel[3]
expect(bishiboshSpec).toBeDefined()
expect(Array.isArray(bishiboshSpec)).toBe(false)
const spec = bishiboshSpec as import("../src/game/monsters.ts").SuperUniqueLandmarkSpec
expect(spec.name).toBe("Bishibosh Custom")
expect(spec.monsterId).toBe("fallenshaman2")
expect(spec.minMinions).toBe(8)
expect(spec.maxMinions).toBe(10)
expect(spec.modifiers).toEqual(["fast", "stoneskin"])
})
test("planLevelMonsters skips boss without falling back to fallen1 when boss monster is missing", () => {
const levelsTxt = mockTable([
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
"4\tStony Field\t600\t2\t4\t7\tzombie1\tquillrat1\tbrute1",
].join("\n"))
// Only define zombie1 in monstats - Rakanishu is fallen1, which is absent
const monstatsTxt = mockTable([
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"zombie1\tzombie1\tZombie\t7\t1\t2\t4\t6\t10\t20\t10\t20\t2\t5\t20\t1\t1\t1\t1",
].join("\n"))
const monlvlTxt = mockTable([
"Level\tAC\tTH\tHP\tDM\tXP",
"7\t100\t100\t100\t100\t100",
].join("\n"))
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
const plan = planLevelMonsters(tables, 4, 6400, 12345, 3.5, "normal")
// Rakanishu should NOT be spawned as zombie1 or fallen1 (fallen1 fallback was removed)
expect(plan.superUniques?.includes("Rakanishu")).toBeFalsy()
const rakanishuPack = plan.packs.find(p => p.superUniqueId === "Rakanishu")
expect(rakanishuPack).toBeUndefined()
})
test("planLevelMonsters uses tables.superuniques when provided", () => {
const levelsTxt = mockTable([
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
"3\tCold Plains\t600\t2\t4\t5\tfallenshaman1\tfallen1\tzombie1",
].join("\n"))
const monstatsTxt = mockTable([
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"fallenshaman1\tfallenshaman1\tFallen Shaman\t5\t1\t1\t6\t9\t10\t20\t15\t25\t2\t4\t20\t1\t1\t1\t1",
"fallen1\tfallen1\tFallen\t5\t3\t5\t8\t12\t5\t10\t5\t10\t1\t2\t15\t1\t1\t1\t1",
].join("\n"))
const monlvlTxt = mockTable([
"Level\tAC\tTH\tHP\tDM\tXP",
"5\t100\t100\t100\t100\t100",
].join("\n"))
const customSuTable = mockTable([
"Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)",
"Bishibosh\tBishibosh Ultra\tfallenshaman1\t6\t0\t0\t3\t3\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A",
].join("\n"))
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt, superuniques: customSuTable }
const plan = planLevelMonsters(tables, 3, 6400, 54321, 3.5, "normal")
expect(plan.superUniques).toContain("Bishibosh")
const bishiboshPack = plan.packs.find(p => p.superUniqueId === "Bishibosh")
expect(bishiboshPack).toBeDefined()
expect(bishiboshPack?.members[0]?.name).toBe("Bishibosh Ultra")
expect(bishiboshPack?.members[0]?.modifiers).toEqual(["fast"])
// 1 boss + 3 minions = 4 members
expect(bishiboshPack?.members.length).toBe(4)
})
})
})

View File

@ -1,359 +0,0 @@
/**
* Wilderness telemetry must be measured, not invented (issue #58 item 6).
*
* Every number in `stats` is read by `npm run verify:generators` and by the
* in-game HUD to decide whether a level generated correctly. Four of them were
* literals or no-op arithmetic, so a level that produced nothing still reported
* a plausible-looking map:
*
* - `wilderness-jungles.ts` reported `junglePathCells: 100` and at least 2
* `riverBridges`, whatever was painted.
* - `wilderness-acts.ts` counted Act 4 plateau cells in a loop that painted
* nothing for levels 104 and 106.
* - `wilderness.ts` reported `subTheme: 0` on the special-preset return path.
* - `applySubstitutions` capped `BordType` 0 rows with `Math.min(1, Infinity)`,
* a budget that never constrained anything.
*/
import { describe, expect, test } from 'vitest'
import { createCanvas, generateWilderness, stampDs1 } from '../src/game/wilderness.ts'
import type {
SubstitutionReport,
WildernessPiece,
WildernessStats,
WildernessSubstitution,
} from '../src/game/wilderness.ts'
import type { Act3TopographyStats, Act4TopographyStats } from '../src/game/wilderness-acts.ts'
import { DRLG_GenerateJungles } from '../src/game/wilderness-jungles.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
/** A fresh, empty report collector. */
function makeStats(): WildernessStats {
return {
substitutions: [],
borderPieces: {},
unresolved: [],
notes: [],
borderStamped: 0,
groundCells: 0,
groundTile: null,
sizeSource: 'test',
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
proceduralRoadCells: 0,
proceduralDividerCells: 0,
proceduralWaterCells: 0,
proceduralWallCells: 0,
}
}
/** A solid block of floor, so stamped pieces are measurable. */
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 3,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8)
return Array.from({ length: 8 }, (_unused, i) => ({
name: `${actPrefix} - Border ${String(i + 1)}`,
border: true,
levels: [borderDs1],
}))
}
describe('Act 3 jungle topography is counted, not assumed', () => {
test('a jungle with no presets reports zero ground and zero water', () => {
const canvas = createCanvas(64, 192, 1, 1, 1)
const stats = makeStats()
DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1)
const topo = stats.act3Topography as Act3TopographyStats
// Nothing was stamped, so there is no jungle ground and no river water.
// This used to report 100 path cells regardless.
expect(topo.junglePathCells).toBe(0)
expect(topo.canalCells).toBe(0)
// The bridges that exist are the plank crossings the pass paints itself:
// the island/main-channel bridges plus both canonical crossings.
expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 2)
// Causeways belong to Kurast, and the jungle pass paints none.
expect(topo.causewayCells).toBe(0)
})
test('a canvas too short for the second crossing does not count it', () => {
const canvas = createCanvas(64, 96, 1, 1, 1)
const stats = makeStats()
DRLG_GenerateJungles(canvas, 76, [], new Rng(76001), stats, stampDs1)
const topo = stats.act3Topography as Act3TopographyStats
// The crossing at y = 142 falls off a 96-cell canvas and paints nothing.
// `Math.max(2, bridges.length)` used to report it anyway.
expect(topo.riverBridges).toBe((stats.bridgesPlaced ?? 0) + 1)
})
test('ground and water counts add up to what is on the canvas', () => {
const canvas = createCanvas(64, 192, 1, 1, 1)
const stats = makeStats()
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] },
]
DRLG_GenerateJungles(canvas, 76, pieces, new Rng(76001), stats, stampDs1)
let painted = 0
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const cell = canvas.cells[y]![x]!
const floor = cell.floors.find(
f => !f.hidden && (f.style !== 0 || f.sequence !== 0 || f.prop1 !== 0),
)
if (floor === undefined) continue
// Bridge planks are decks over the water, reported as bridges.
if (floor.style === 4 && floor.prop1 === 2) continue
painted += 1
}
}
const topo = stats.act3Topography as Act3TopographyStats
expect(painted).toBeGreaterThan(0)
expect(topo.junglePathCells + topo.canalCells).toBe(painted)
expect(topo.canalCells).toBeGreaterThan(0)
})
})
describe('Act 4 plateaus are only counted where they are painted', () => {
test('Outer Steppes (104) reports no plateau cells and names the gap', () => {
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 104001,
pieces: makeMockBorderPieces('Act 4'),
substitutions: [],
})
const topo = result.stats.act4Topography as Act4TopographyStats
// The 104/106 branch surveys a stride grid and paints nothing; it used to
// count every sample as a plateau cell.
expect(topo.plateauCells).toBe(0)
const notes = result.stats.notes as readonly string[]
expect(notes.some(note => note.includes('Act 4 basalt plateaus not implemented'))).toBe(true)
})
test('Plains of Despair (105) still counts the prison plateau it paints', () => {
const result = generateWilderness({
levelId: 105,
levelName: 'Plains of Despair',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 105001,
pieces: makeMockBorderPieces('Act 4'),
substitutions: [],
})
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.plateauCells).toBeGreaterThan(20)
})
})
describe('the special-preset path reports the real SubTheme', () => {
test('a jungle level keeps the SubTheme it was asked for', () => {
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [makeMockDs1(32, 32)] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 3,
seed: 76001,
pieces,
substitutions: [],
})
// This return path hardcoded 0, so every special level looked like theme 0.
expect(result.stats.subTheme).toBe(3)
})
test('an out-of-range SubTheme is clamped, like the main path clamps it', () => {
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [makeMockDs1(64, 32)] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [makeMockDs1(32, 32)] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 9,
seed: 76002,
pieces,
substitutions: [],
})
expect(result.stats.subTheme).toBe(4)
})
})
describe('BordType 0 really is one cluster for the whole level', () => {
/** Two clutter rows that both want a whole-level cluster. */
function makeBordType0Rows(): WildernessSubstitution[] {
return ['Trees', 'Rocks'].map(name => ({
name,
type: 1,
gridSize: 1,
bordType: 0,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [-1, -1, -1, -1, -1],
max: [5, 5, 5, 5, 5],
levels: [makeMockDs1(4, 4)],
}))
}
test('the second BordType 0 row gets nothing left to spend', () => {
const result = generateWilderness({
levelId: 42,
levelName: 'Dry Hills',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 1,
subTheme: 0,
seed: 2001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: makeBordType0Rows(),
})
const reported = result.stats.substitutions as readonly SubstitutionReport[]
const rows = reported.filter(row => row.name === 'Trees' || row.name === 'Rocks')
expect(rows).toHaveLength(2)
const spent = rows.reduce((sum, row) => sum + row.clusters, 0)
// `Math.min(1, Infinity)` handed each row its own cluster, so this used to
// be 2: a per-row cap wearing a whole-level cap's name.
expect(spent).toBeLessThanOrEqual(1)
expect(rows[0]!.clusters).toBe(1)
expect(rows[1]!.clusters).toBe(0)
})
})
// Act I outdoor levels (and their dirt paths: DRLGOUTDOORS_SpawnAct1DirtPaths) come from the DRLG
// port (src/game/drlg), so the telemetry below covers the procedural passes of Acts 2-5.
describe('Procedural terrain pass telemetry tracks real cell counts (Issue #62 & #65)', () => {
test('Act 2 desert tracks procedural roads, water (oasis), and walls (canyon + berms)', () => {
const rocky = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 41001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: [],
})
expect(rocky.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(rocky.stats.proceduralWallCells).toBeGreaterThan(0)
const oasis = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 64,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 43001,
pieces: makeMockBorderPieces('Act 2'),
substitutions: [],
})
expect(oasis.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(oasis.stats.proceduralWaterCells).toBeGreaterThan(0)
})
test('Act 3 Kurast Causeway tracks causeway, canal, and balustrade cells', () => {
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 32,
sizeY: 64,
subType: 0,
subTheme: 0,
seed: 82001,
pieces: [{ name: 'Act 3 - Bridge', border: false, levels: [makeMockDs1(16, 16)] }],
substitutions: [],
})
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralWaterCells).toBeGreaterThan(0)
expect(result.stats.proceduralWallCells).toBeGreaterThan(0)
})
test('Act 5 Siege levels track barricade walls, trenches, and frozen lakes', () => {
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112001,
pieces: makeMockBorderPieces('Act 5'),
substitutions: [],
})
expect(result.stats.proceduralWallCells).toBeGreaterThan(0)
expect(result.stats.proceduralRoadCells).toBeGreaterThan(0)
expect(result.stats.proceduralWaterCells).toBeGreaterThan(0)
})
})

View File

@ -1,734 +0,0 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
OUTDOOR_WAYPOINT_LEVELS,
actOfLevel,
hasOutdoorWaypoint,
ACT_POPULATION_CONFIG,
createCanvas,
DRLGOUTDOORS_SpawnAct12Waypoint,
DRLGOUTDOORS_SpawnAct12Shrines,
spawnOutdoorWaypoint,
spawnOutdoorShrines,
type Canvas,
type PlacedPresetRect,
type WildernessPiece,
type WildernessStats,
type WildernessSubstitution,
} from "../src/game/wilderness.ts"
import {
actOfLevel as actsActOfLevel,
hasOutdoorWaypoint as actsHasOutdoorWaypoint,
type D2Table,
} from "../src/game/acts.ts"
import { Rng } from "../src/game/rng.ts"
import {
findWaypointSpot,
type LinkGrid,
} from "../src/game/level-links.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
let isWall: boolean
if (isOpen) {
// Clearings have open interior floor with perimeter walls
isWall = (x === 0 || y === 0 || y === height - 1 || (x === width - 1 && !hasGapInWalls))
} else {
isWall = !(hasGapInWalls && x === Math.floor(width / 2))
}
row.push({
walls: isWall ? [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [],
floors: [{ prop1: 2, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function makeGenericOutdoorPieces(prefix: string, landmarkName?: string): WildernessPiece[] {
const pieces: WildernessPiece[] = []
for (let i = 1; i <= 12; i += 1) {
pieces.push({
name: `${prefix} Border ${i}`,
border: true,
levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)],
})
}
pieces.push({ name: `${prefix} Town Transition East`, border: true, levels: [makeMockDs1(8, 8, true)] })
pieces.push({ name: `${prefix} Town Transition South`, border: true, levels: [makeMockDs1(8, 8, true)] })
if (landmarkName) {
pieces.push({ name: landmarkName, border: false, levels: [makeMockDs1(8, 8, true, true)] })
}
return pieces
}
function makeAct3JunglePieces(): WildernessPiece[] {
const pieces: WildernessPiece[] = [
{ name: "Act 3 - Jungle Tail", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Jungle Head", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Clearing Pygmy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Clearing Webby E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Clearing Boggy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Jungle NS W", border: false, levels: [makeMockDs1(32, 32, true, false)] },
]
return pieces
}
describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () => {
test("actOfLevel correctly identifies acts from levelId or levelTypeName", () => {
expect(actOfLevel(3, "Act 1 - Wilderness")).toBe(1)
expect(actOfLevel(43, "Act 2 - Desert")).toBe(2)
expect(actOfLevel(78, "Act 3 - Jungle")).toBe(3)
expect(actOfLevel(106, "Act 4 - Mesa")).toBe(4)
expect(actOfLevel(112, "Act 5 - Barricade")).toBe(5)
// Falls back to numeric levelId ranges when levelTypeName is generic
expect(actOfLevel(4, undefined)).toBe(1)
expect(actOfLevel(42, undefined)).toBe(2)
expect(actOfLevel(76, undefined)).toBe(3)
expect(actOfLevel(107, undefined)).toBe(4)
expect(actOfLevel(117, undefined)).toBe(5)
})
test("actOfLevel resolves Act column from Levels.txt table when provided", () => {
const mockLevelsTable: D2Table = {
header: ["Id", "Name", "Act"],
rows: [
["2", "Blood Moor", "0"],
["42", "Dry Hills", "1"],
["76", "Spider Forest", "2"],
["106", "City of the Damned", "3"],
["112", "Arreat Plateau", "0"], // Expansion Act 5 row stores 0 in blizzard data
],
}
expect(actOfLevel(2, undefined, undefined, mockLevelsTable)).toBe(1)
expect(actOfLevel(42, undefined, undefined, mockLevelsTable)).toBe(2)
expect(actOfLevel(76, undefined, undefined, mockLevelsTable)).toBe(3)
expect(actOfLevel(106, undefined, undefined, mockLevelsTable)).toBe(4)
expect(actOfLevel(112, undefined, undefined, mockLevelsTable)).toBe(5)
// Re-export from acts.ts is identical
expect(actsActOfLevel).toBe(actOfLevel)
expect(actsHasOutdoorWaypoint).toBe(hasOutdoorWaypoint)
})
test("hasOutdoorWaypoint reads Levels.txt Waypoint column dynamically", () => {
const mockLevelsTable: D2Table = {
header: ["Id", "Name", "Act", "DrlgType", "Waypoint"],
rows: [
["1", "Rogue Encampment", "0", "1", "0"], // Town level -> false for outdoor waypoint
["2", "Blood Moor", "0", "3", "255"], // Outdoor, no waypoint (255) -> false
["3", "Cold Plains", "0", "3", "1"], // Outdoor, waypoint (1) -> true
["40", "Lut Gholein", "1", "1", "2"], // Town level -> false
["43", "Far Oasis", "1", "3", "3"], // Outdoor, waypoint -> true
["200", "Custom Field", "0", "3", "4"], // Custom outdoor level with waypoint -> true
["201", "Custom Field No WP", "0", "3", "255"], // Custom outdoor level without waypoint -> false
],
}
// Dynamic resolution via table
expect(hasOutdoorWaypoint(3, mockLevelsTable)).toBe(true)
expect(hasOutdoorWaypoint(2, mockLevelsTable)).toBe(false)
expect(hasOutdoorWaypoint(1, mockLevelsTable)).toBe(false) // Town
expect(hasOutdoorWaypoint(40, mockLevelsTable)).toBe(false) // Town
expect(hasOutdoorWaypoint(43, mockLevelsTable)).toBe(true)
expect(hasOutdoorWaypoint(200, mockLevelsTable)).toBe(true)
expect(hasOutdoorWaypoint(201, mockLevelsTable)).toBe(false)
// Fallback when no table provided
expect(hasOutdoorWaypoint(3)).toBe(true)
expect(hasOutdoorWaypoint(2)).toBe(false)
expect(hasOutdoorWaypoint(43)).toBe(true)
expect(hasOutdoorWaypoint(41)).toBe(false)
})
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
// Act 1
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
expect(OUTDOOR_WAYPOINT_LEVELS.has(4)).toBe(true) // Stony Field
expect(OUTDOOR_WAYPOINT_LEVELS.has(5)).toBe(true) // Dark Wood
expect(OUTDOOR_WAYPOINT_LEVELS.has(6)).toBe(true) // Black Marsh
expect(OUTDOOR_WAYPOINT_LEVELS.has(2)).toBe(false) // Blood Moor (no WP)
// Act 2
expect(OUTDOOR_WAYPOINT_LEVELS.has(42)).toBe(true) // Dry Hills
expect(OUTDOOR_WAYPOINT_LEVELS.has(43)).toBe(true) // Far Oasis
expect(OUTDOOR_WAYPOINT_LEVELS.has(44)).toBe(true) // Lost City
expect(OUTDOOR_WAYPOINT_LEVELS.has(46)).toBe(true) // Canyon of the Magi
expect(OUTDOOR_WAYPOINT_LEVELS.has(41)).toBe(false) // Rocky Waste (no WP)
// Act 3
expect(OUTDOOR_WAYPOINT_LEVELS.has(76)).toBe(true) // Spider Forest
expect(OUTDOOR_WAYPOINT_LEVELS.has(77)).toBe(true) // Great Marsh
expect(OUTDOOR_WAYPOINT_LEVELS.has(78)).toBe(true) // Flayer Jungle
expect(OUTDOOR_WAYPOINT_LEVELS.has(79)).toBe(true) // Lower Kurast
expect(OUTDOOR_WAYPOINT_LEVELS.has(80)).toBe(true) // Kurast Bazaar
expect(OUTDOOR_WAYPOINT_LEVELS.has(81)).toBe(true) // Upper Kurast
expect(OUTDOOR_WAYPOINT_LEVELS.has(83)).toBe(true) // Travincal
// Act 4
expect(OUTDOOR_WAYPOINT_LEVELS.has(106)).toBe(true) // City of the Damned
expect(OUTDOOR_WAYPOINT_LEVELS.has(107)).toBe(true) // River of Flame
expect(OUTDOOR_WAYPOINT_LEVELS.has(104)).toBe(false) // Outer Steppes (no WP)
// Act 5
expect(OUTDOOR_WAYPOINT_LEVELS.has(111)).toBe(true) // Frigid Highlands
expect(OUTDOOR_WAYPOINT_LEVELS.has(112)).toBe(true) // Arreat Plateau
expect(OUTDOOR_WAYPOINT_LEVELS.has(117)).toBe(true) // Frozen Tundra
expect(OUTDOOR_WAYPOINT_LEVELS.has(110)).toBe(false) // Bloody Foothills (no WP)
})
test("Act 2 roadless maps (Far Oasis 43, Lost City 44) spawn Waypoint id 10 near oasis/ruins or hub", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Ruin 1")
const result = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed: 0x434343,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
expect(result.stats.waypointTile).not.toBeNull()
// Waypoint object for Act 2 is ID 10
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 10)
expect(wpObj).toBeDefined()
// Shrines in Act 2 are from Act 2 shrine pool (54, 55, 103, 104, 105, 126)
const act2ShrinePool = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act2ShrinePool.has(o.id))
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
// Well for Act 2 is ID 234
const wellObj = result.level.objects.find(o => o.type === 2 && o.id === 234)
expect(wellObj).toBeDefined()
})
test("Act 2 Rocky Waste (level 41) does not spawn waypoint", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
const result = generateWilderness({
levelId: 41,
levelName: "Rocky Waste",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed: 0x414141,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(0)
expect(result.stats.waypointTile).toBeNull()
})
test("Act 3 Flayer Jungle (level 78) spawns Waypoint id 1 and Act 3 shrines in jungle clearings", () => {
const pieces = makeAct3JunglePieces()
const result = generateWilderness({
levelId: 78,
levelName: "Flayer Jungle",
levelTypeName: "Act 3 - Jungle",
sizeX: 64,
sizeY: 192,
subType: 10,
subTheme: 0,
seed: 0x787878,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
expect(result.stats.waypointTile).not.toBeNull()
// Waypoint object for Act 3 is ID 1
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1)
expect(wpObj).toBeDefined()
// Waypoint is inside the level bounds
const wpTile = result.stats.waypointTile as { x: number; y: number }
expect(wpTile.x).toBeGreaterThan(0)
expect(wpTile.x).toBeLessThan(64)
// Act 3 shrines spawned
const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool)
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act3ShrinePool.has(o.id))
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
})
test("Act 4 City of the Damned (level 106) spawns Waypoint id 0 and Act 4 shrines", () => {
const pieces = makeGenericOutdoorPieces("Act 4 - Mesa")
const result = generateWilderness({
levelId: 106,
levelName: "City of the Damned",
levelTypeName: "Act 4 - Mesa",
sizeX: 80,
sizeY: 80,
subType: 12,
subTheme: 0,
seed: 0x106106,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
// Act 4 waypoint id is 0
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 0)
expect(wpObj).toBeDefined()
// Outer Steppes (104) has no waypoint
const steppesResult = generateWilderness({
levelId: 104,
levelName: "Outer Steppes",
levelTypeName: "Act 4 - Mesa",
sizeX: 80,
sizeY: 80,
subType: 12,
subTheme: 0,
seed: 0x104104,
pieces,
substitutions: [],
})
expect(steppesResult.stats.waypointsSpawned).toBe(0)
expect(steppesResult.stats.waypointTile).toBeNull()
})
test("Act 5 Arreat Plateau (level 112) spawns Waypoint id 12 near ruins or central hub", () => {
const pieces = makeGenericOutdoorPieces("Act 5 - Barricade", "Act 5 - Ruins 1")
const result = generateWilderness({
levelId: 112,
levelName: "Arreat Plateau",
levelTypeName: "Act 5 - Barricade",
sizeX: 80,
sizeY: 80,
subType: 14,
subTheme: 0,
seed: 0x112112,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
// Act 5 waypoint id is 12
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 12)
expect(wpObj).toBeDefined()
// Act 5 shrines spawned
const act5ShrinePool = new Set(ACT_POPULATION_CONFIG[5].shrinePool)
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act5ShrinePool.has(o.id))
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
})
test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 96,
sizeY: 96,
subType: 6,
subTheme: 0,
seed: 0x8899aabb,
pieces,
substitutions: [],
})
const shrineIds = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id))
// If multiple shrines are spawned, check pairwise distance >= 7
if (shrines.length >= 2) {
for (let i = 0; i < shrines.length; i += 1) {
for (let j = i + 1; j < shrines.length; j += 1) {
const s1 = shrines[i]!
const s2 = shrines[j]!
const dist = Math.hypot(
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
)
expect(dist).toBeGreaterThanOrEqual(7)
}
}
}
})
test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
// Use multiple seeds to observe companion spawning (60% probability)
let foundClusteredCompanion = false
const act2ChestsAndScatter = new Set([
...ACT_POPULATION_CONFIG[2].chestPool,
...ACT_POPULATION_CONFIG[2].scatterPool,
])
const act2Shrines = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
for (let s = 0; s < 10; s += 1) {
const result = generateWilderness({
levelId: 42,
levelName: "Dry Hills",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x5000 + s * 17,
pieces,
substitutions: [],
})
const shrines = result.level.objects.filter(o => o.type === 2 && act2Shrines.has(o.id))
const companions = result.level.objects.filter(o => o.type === 2 && act2ChestsAndScatter.has(o.id))
for (const shrine of shrines) {
const sx = Math.floor(shrine.x / 5)
const sy = Math.floor(shrine.y / 5)
for (const comp of companions) {
const cx = Math.floor(comp.x / 5)
const cy = Math.floor(comp.y / 5)
const dist = Math.hypot(cx - sx, cy - sy)
if (dist >= 1.9 && dist <= 4.5) {
foundClusteredCompanion = true
break
}
}
if (foundClusteredCompanion) break
}
if (foundClusteredCompanion) break
}
expect(foundClusteredCompanion).toBe(true)
})
test("shrineSubstitutions scales shrine count based on LvlSub max", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
const mockShrineSub: WildernessSubstitution = {
name: "Act 2 - Shrines",
type: 8,
gridSize: 1,
bordType: 0,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [1, 1, 1, 1, 1],
max: [4, 4, 4, 4, 4],
levels: [],
}
const result = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 96,
sizeY: 96,
subType: 8,
subTheme: 0,
seed: 0x777777,
pieces,
substitutions: [],
shrineSubstitutions: [mockShrineSub],
})
// With max = 4 plus healing well, shrinesSpawned should scale up (>= 2)
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThanOrEqual(2)
})
test("Determinism: same seed produces identical waypoint and shrine layouts", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Oasis 1")
const seed = 0xabcdef12
const run1 = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
const run2 = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
expect(run1.stats.waypointsSpawned).toBe(run2.stats.waypointsSpawned)
expect(run1.stats.waypointTile).toEqual(run2.stats.waypointTile)
expect(run1.stats.shrinesSpawned).toBe(run2.stats.shrinesSpawned)
expect(run1.stats.interactivesSpawned).toBe(run2.stats.interactivesSpawned)
expect(run1.level.objects).toEqual(run2.level.objects)
})
})
describe("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines Topological Placement (Issue #68)", () => {
function makeOutdoorCanvas(width: number, height: number): Canvas {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{
prop1: 2,
sequence: 0,
style: 0,
unknown1: 0,
unknown2: 0,
hidden: false,
}],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
width,
height,
cells,
wallLayers: 1,
floorLayers: 1,
substitutionLayers: 1,
objects: [],
}
}
function makeEmptyStats(): WildernessStats {
return {
substitutions: [],
borderPieces: {},
unresolved: [],
notes: [],
borderStamped: 0,
groundCells: 0,
groundTile: null,
sizeSource: "default",
roadCells: 0,
roadSegments: 0,
anchors: 0,
specialPresets: [],
entrances: [],
waypointsSpawned: 0,
shrinesSpawned: 0,
interactivesSpawned: 0,
}
}
test("Priority 1: DRLGOUTDOORS_SpawnAct12Waypoint prioritizes preset waypoint objects over roadside and campsite", () => {
const canvas = makeOutdoorCanvas(48, 48)
// Pre-place a waypoint object at (12, 12) (tile 12 => x = 60, y = 60)
canvas.objects.push({
type: 2,
id: 37, // Act 1 waypoint id
x: 12 * 5 + 2,
y: 12 * 5 + 2,
flags: 0,
})
// Also supply a dirt path road down x = 24
const dirtPathGrid = new Uint8Array(48 * 48)
for (let y = 0; y < 48; y += 1) {
dirtPathGrid[y * 48 + 24] = 1
}
// Also place a campsite preset at (36, 36)
const placedRects: PlacedPresetRect[] = [
{ name: "Act 1 - Camp", x: 36, y: 36, w: 8, h: 8 },
]
const stats = makeEmptyStats()
const rng = new Rng(0x1234)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, placedRects, rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toEqual({ x: 12, y: 12 })
})
test("Branch A: DRLGOUTDOORS_SpawnAct12Waypoint uses Cold Plains special placement (0 RNG rolls)", () => {
const canvas = makeOutdoorCanvas(64, 64)
const placedRects: PlacedPresetRect[] = []
const stats = makeEmptyStats()
const rng = new Rng(0x5678)
const initialSeed = rng.seed
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, undefined, placedRects, rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
// Cold Plains special consumes exactly 0 RNG rolls
expect(rng.seed).toBe(initialSeed)
})
test("Branch B: DRLGOUTDOORS_SpawnAct12Waypoint uses 8x8 cell grid placement avoiding preset obstacles", () => {
const canvas = makeOutdoorCanvas(64, 64)
// Preset obstacle placed in cell (1, 1) -> tiles [8..15, 8..15]
const placedRects: PlacedPresetRect[] = [
{ name: "Obstacle", x: 8, y: 8, w: 8, h: 8 },
]
const stats = makeEmptyStats()
const rng = new Rng(0x9abc)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 43, 2, undefined, placedRects, rng, stats)
expect(stats.waypointsSpawned).toBe(1)
expect(stats.waypointTile).toBeDefined()
const wp = stats.waypointTile!
// Waypoint should be placed in a valid cell not overlapping the obstacle [8..15, 8..15]
const inObstacle = wp.x >= 8 && wp.x < 16 && wp.y >= 8 && wp.y < 16
expect(inObstacle).toBe(false)
})
test("Player landing safety buffer around placed waypoints is obstacle-free", () => {
const canvas = makeOutdoorCanvas(48, 48)
// Add walls at specific tiles to test obstacle-free landing buffer
const obstacleCell = canvas.cells[24]![22]!
canvas.cells[24]![22] = {
...obstacleCell,
walls: [{
prop1: 2,
sequence: 0,
style: 1,
type: 0,
unknown1: 0,
unknown2: 0,
hidden: false,
}],
}
const dirtPathGrid = new Uint8Array(48 * 48)
for (let y = 0; y < 48; y += 1) {
dirtPathGrid[y * 48 + 16] = 1
}
const stats = makeEmptyStats()
const rng = new Rng(0x1122)
DRLGOUTDOORS_SpawnAct12Waypoint(canvas, 3, 1, dirtPathGrid, [], rng, stats)
expect(stats.waypointsSpawned).toBe(1)
const wp = stats.waypointTile!
// Verify 2-tile perimeter (5x5 tiles around wp) is 100% obstacle-free
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
const tx = wp.x + dx
const ty = wp.y + dy
const cell = canvas.cells[ty]![tx]!
// Must have walkable floor
expect(cell.floors.some(f => !f.hidden && f.prop1 !== 0)).toBe(true)
// Must have NO walls
expect(cell.walls.some(w => !w.hidden && w.prop1 !== 0)).toBe(false)
}
}
})
test("DRLGOUTDOORS_SpawnAct12Shrines spaces shrines across exploration branches away from main road", () => {
const canvas = makeOutdoorCanvas(64, 64)
// Main spline road running vertically at x = 32
const dirtPathGrid = new Uint8Array(64 * 64)
for (let y = 0; y < 64; y += 1) {
dirtPathGrid[y * 64 + 32] = 1
}
const stats = makeEmptyStats()
const rng = new Rng(0x4455)
DRLGOUTDOORS_SpawnAct12Shrines(canvas, 3, 1, dirtPathGrid, [], rng, stats)
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
const shrines = canvas.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
// Shrines should be spawned along secondary branches (not directly on or within 2 tiles of main road)
for (const shrine of shrines) {
const sx = Math.floor(shrine.x / 5)
expect(Math.abs(sx - 32)).toBeGreaterThanOrEqual(3)
}
// Pairwise distance between shrines must be >= 10 tiles
for (let i = 0; i < shrines.length; i += 1) {
for (let j = i + 1; j < shrines.length; j += 1) {
const s1 = shrines[i]!
const s2 = shrines[j]!
const dist = Math.hypot(
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
)
expect(dist).toBeGreaterThanOrEqual(10)
}
}
})
test("findWaypointSpot in level-links.ts selects high-clearance topological locations rather than blind center search", () => {
// 40x40 sub-tiles grid
const W = 40
const H = 40
const blocked = new Uint8Array(W * H).fill(1) // all blocked initially
// Create a narrow 1-sub-tile corridor at the exact center (y = 20, x = 15..25)
for (let x = 15; x <= 25; x += 1) {
blocked[20 * W + x] = 0
}
// Create a wide open room at (10, 10) with 7x7 clear walkable sub-tiles
for (let y = 7; y <= 13; y += 1) {
for (let x = 7; x <= 13; x += 1) {
blocked[y * W + x] = 0
}
}
const grid: LinkGrid = {
cellsX: 8,
cellsY: 8,
gridWidth: W,
gridHeight: H,
blocked,
}
// The naive BFS from center would pick (20, 20) in the narrow corridor
// findWaypointSpot evaluates clearance (>= 3x3) and selects inside the open room!
const spot = findWaypointSpot(grid)
expect(spot).not.toBeNull()
expect(spot!.x).toBeGreaterThanOrEqual(8)
expect(spot!.x).toBeLessThanOrEqual(12)
expect(spot!.y).toBeGreaterThanOrEqual(8)
expect(spot!.y).toBeLessThanOrEqual(12)
})
})

View File

@ -1,11 +1,10 @@
import { describe, it, expect } from 'vitest'
import type { D2Table } from '../src/game/acts.ts'
import { ACT_TOWNS, townLevelForAct } from '../src/game/acts.ts'
import { actOfLevel } from '../src/game/wilderness.ts'
import { ACT_TOWNS, townLevelForAct, actOfLevel } from '../src/game/acts.ts'
import {
parseLevelRows,
buildWorldGraph,
assignGateSides,
PRESET_SEAM_SIDES,
seamlessPairs,
reachableFrom,
edgesFrom,
@ -16,8 +15,6 @@ import {
SIDES,
SEAMLESS_ADJACENCY,
DROPPED_VIS_EDGES,
resolveWorldOffsets,
type WorldOffset,
} from '../src/game/world-graph.ts'
import type { Side } from '../src/game/world-graph.ts'
import {
@ -216,92 +213,72 @@ describe('oppositeSide', () => {
})
})
describe('assignGateSides', () => {
/** A chain of five outdoor levels plus a branch, as Act 1 is shaped. */
const chain = buildWorldGraph(
parseLevelRows(levelsTable([{ Id: 1 }, { Id: 2 }, { Id: 3 }, { Id: 4 }, { Id: 5 }, { Id: 17 }])),
describe('PRESET_SEAM_SIDES', () => {
const allPresetAndSampleLevels = [1, 2, 3, 7, 26, 27, 28, 32, 33, 40, 41, 75, 76, 77, 78, 79, 103, 104, 107, 108, 109, 110]
const graph = buildWorldGraph(
parseLevelRows(
levelsTable(
allPresetAndSampleLevels.map(Id => {
if (Id === 26) return { Id, Vis1: 27 }
if (Id === 27) return { Id, Vis0: 26, Vis1: 28 }
if (Id === 28) return { Id, Vis0: 27 }
if (Id === 32) return { Id, Vis1: 33 }
if (Id === 33) return { Id, Vis0: 32 }
if (Id === 107) return { Id, Vis1: 108 }
if (Id === 108) return { Id, Vis0: 107 }
return { Id }
}),
),
),
)
it('finds a side for every seam', () => {
const graph = assignGateSides(chain, 7)
it('populates opposite sides for every preset-anchored seam', () => {
expect(PRESET_SEAM_SIDES.size).toBe(10)
for (const [key, expectedSide] of PRESET_SEAM_SIDES.entries()) {
const [aStr, bStr] = key.split(':')
const a = Number(aStr)
const b = Number(bStr)
const forward = graph.edges.find(edge => edge.from === a && edge.to === b)
const backward = graph.edges.find(edge => edge.from === b && edge.to === a)
expect(forward?.sideFrom).toBe(expectedSide)
expect(forward?.sideTo).toBe(oppositeSide(expectedSide))
expect(backward?.sideFrom).toBe(oppositeSide(expectedSide))
expect(backward?.sideTo).toBe(expectedSide)
}
})
it('leaves DRLG outdoor-to-outdoor seams without a static side', () => {
const outdoorEdge = graph.edges.find(edge => edge.from === 2 && edge.to === 3)
expect(outdoorEdge?.kind).toBe('seamless')
expect(outdoorEdge?.sideFrom).toBeNull()
expect(outdoorEdge?.sideTo).toBeNull()
})
it('never puts two of a level\u2019s preset seams on the same side', () => {
const used = new Map<number, Set<Side>>()
for (const edge of graph.edges) {
if (edge.kind !== 'seamless') continue
expect(edge.sideFrom).not.toBeNull()
expect(edge.sideTo).not.toBeNull()
}
})
it('always puts the two ends of a seam on opposite sides', () => {
for (let seed = 0; seed < 50; seed += 1) {
const graph = assignGateSides(chain, seed)
for (const edge of graph.edges) {
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
expect(edge.sideTo).toBe(oppositeSide(edge.sideFrom))
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
let sides = used.get(edge.from)
if (sides === undefined) {
sides = new Set<Side>()
used.set(edge.from, sides)
}
expect(sides.has(edge.sideFrom)).toBe(false)
sides.add(edge.sideFrom)
}
})
it('never puts two of a level\u2019s seams on the same side', () => {
// The documented constraint: in the Cold Plains the Blood Moor entrance
// and the two exits may not share an edge.
for (let seed = 0; seed < 50; seed += 1) {
const graph = assignGateSides(chain, seed)
const used = new Map<number, Set<Side>>()
for (const edge of graph.edges) {
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
let sides = used.get(edge.from)
if (sides === undefined) {
sides = new Set<Side>()
used.set(edge.from, sides)
}
expect(sides.has(edge.sideFrom)).toBe(false)
sides.add(edge.sideFrom)
}
}
})
it('replays the same layout for the same seed', () => {
const render = (seed: number): string =>
assignGateSides(chain, seed)
.edges.filter(edge => edge.kind === 'seamless')
.map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`)
.sort()
.join('|')
expect(render(4242)).toBe(render(4242))
})
it('produces different layouts for different seeds', () => {
// Side choice is per seed in the real game; pinning it would be stating a
// coincidence as a law.
const render = (seed: number): string =>
assignGateSides(chain, seed)
.edges.filter(edge => edge.kind === 'seamless')
.map(edge => `${String(edge.from)}>${String(edge.to)}:${String(edge.sideFrom)}`)
.sort()
.join('|')
const layouts = new Set(Array.from({ length: 20 }, (_unused, seed) => render(seed)))
expect(layouts.size).toBeGreaterThan(1)
})
it('honours the preset-anchored pin between town and the Blood Moor', () => {
// The Rogue Encampment's gate is part of a hand-authored preset: it faces
// south and cannot be re-rolled.
for (let seed = 0; seed < 20; seed += 1) {
const graph = assignGateSides(chain, seed)
const gate = graph.edges.find(edge => edge.from === 1 && edge.to === 2)
expect(gate?.sideFrom).toBe('south')
expect(gate?.sideTo).toBe('north')
}
it('includes the Act 3 Jungle 76<->78 alternate branch in SEAMLESS_ADJACENCY', () => {
const branch = graph.edges.find(edge => edge.from === 76 && edge.to === 78)
expect(branch).toBeDefined()
expect(branch?.kind).toBe('seamless')
})
it('leaves warp edges without a side', () => {
const graph = assignGateSides(
buildWorldGraph(
parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])),
),
1,
const warpGraph = buildWorldGraph(
parseLevelRows(levelsTable([{ Id: 2, Vis3: 8, Warp3: 0 }, { Id: 8, Vis0: 2, Warp0: 4 }])),
)
const warp = graph.edges.find(edge => edge.kind === 'warp')
const warp = warpGraph.edges.find(edge => edge.kind === 'warp')
expect(warp?.sideFrom).toBeNull()
})
})
@ -482,153 +459,3 @@ describe('ACT_TOWNS and townLevelForAct single source of truth', () => {
expect(actOfLevel(136)).toBe(5)
})
})
describe('resolveWorldOffsets', () => {
function doRectanglesOverlap(a: WorldOffset, b: WorldOffset): boolean {
return (
a.offsetX < b.offsetX + b.sizeX &&
a.offsetX + a.sizeX > b.offsetX &&
a.offsetY < b.offsetY + b.sizeY &&
a.offsetY + a.sizeY > b.offsetY
)
}
it('generates valid non-overlapping coordinates along SEAMLESS_ADJACENCY for Acts 1..5', () => {
for (const act of [1, 2, 3, 4, 5]) {
const offsets = resolveWorldOffsets(act, 42)
expect(offsets.size).toBeGreaterThan(0)
// Collect all seamless levels in this act
const seamlessInAct = SEAMLESS_ADJACENCY.filter(([a, b]) => offsets.has(a) && offsets.has(b))
const placedSeamlessIds = Array.from(new Set(seamlessInAct.flat()))
// Verify all coordinates are positive and non-empty
for (const id of placedSeamlessIds) {
const offset = offsets.get(id)!
expect(offset.offsetX).toBeGreaterThan(0)
expect(offset.offsetY).toBeGreaterThan(0)
expect(offset.sizeX).toBeGreaterThan(0)
expect(offset.sizeY).toBeGreaterThan(0)
}
// Verify no two placed seamless levels have overlapping interiors
for (let i = 0; i < placedSeamlessIds.length; i++) {
for (let j = i + 1; j < placedSeamlessIds.length; j++) {
const a = offsets.get(placedSeamlessIds[i]!)!
const b = offsets.get(placedSeamlessIds[j]!)!
expect(doRectanglesOverlap(a, b)).toBe(false)
}
}
}
})
it('aligns Act 5 Levels 110, 111, 112 West edge to 702 matching ka.ma0 Layer 79 automap cluster', () => {
const offsets = resolveWorldOffsets(5, 42)
const l109 = offsets.get(109)!
const l110 = offsets.get(110)!
const l111 = offsets.get(111)!
const l112 = offsets.get(112)!
expect(l109).toBeDefined()
expect(l110).toBeDefined()
expect(l111).toBeDefined()
expect(l112).toBeDefined()
// Harrogath at (1000, 1000)
expect(l109.offsetX).toBe(1000)
expect(l109.offsetY).toBe(1000)
// Bloody Foothills at (760, 1000), width 240, ending at X=1000 touching Harrogath
expect(l110.offsetX).toBe(760)
expect(l110.offsetY).toBe(1000)
expect(l110.offsetX + l110.sizeX).toBe(1000)
// Level 110 West gate connects West to Level 111
// With Level 111 derived width 58: offset X = 760 - 58 = 702
expect(l111.sizeX).toBe(58)
expect(l111.offsetX).toBe(702)
expect(l111.offsetX + l111.sizeX).toBe(760) // Zero gap to Level 110
// Level 111 connects North to Level 112
// Both 111 and 112 have West edge aligned to 702
expect(l112.sizeX).toBe(58)
expect(l112.offsetX).toBe(702)
// Combined bounding box spans X in [702..759] and Y in [717..1026] down to the single tile
const minX = Math.min(l111.offsetX, l112.offsetX)
const maxX = Math.max(l111.offsetX + l111.sizeX - 1, l112.offsetX + l112.sizeX - 1)
const minY = Math.min(l111.offsetY, l112.offsetY)
const maxY = Math.max(l111.offsetY + l111.sizeY, l112.offsetY + l112.sizeY)
expect(minX).toBe(702)
expect(maxX).toBe(759)
expect(minY).toBe(717)
expect(maxY).toBe(1026)
// Verify 111 and 112 touch seamlessly without overlap
expect(l112.offsetY + l112.sizeY).toBe(l111.offsetY)
expect(doRectanglesOverlap(l111, l112)).toBe(false)
expect(doRectanglesOverlap(l110, l111)).toBe(false)
expect(doRectanglesOverlap(l109, l110)).toBe(false)
})
it('preserves static offsets from Levels.txt for isolated levels across acts', () => {
// Act 1 isolated cave and crypt
const act1 = resolveWorldOffsets(1, 42)
expect(act1.get(8)?.offsetX).toBe(1500)
expect(act1.get(8)?.offsetY).toBe(1000)
expect(act1.get(18)?.offsetX).toBe(2000)
expect(act1.get(18)?.offsetY).toBe(1000)
// Act 2 isolated Canyon of the Magi
const act2 = resolveWorldOffsets(2, 42)
expect(act2.get(46)?.offsetX).toBe(2500)
expect(act2.get(46)?.offsetY).toBe(1000)
// Act 4 isolated Chaos Sanctuary
const act4 = resolveWorldOffsets(4, 42)
expect(act4.get(108)?.offsetX).toBe(1500)
expect(act4.get(108)?.offsetY).toBe(1000)
// Act 5 isolated Frozen Tundra
const act5 = resolveWorldOffsets(5, 42)
expect(act5.get(117)?.offsetX).toBe(2000)
expect(act5.get(117)?.offsetY).toBe(1896)
})
it('correctly resolves dependent levels relative to isolated parent levels (Level 33 on 32)', () => {
const act1 = resolveWorldOffsets(1, 42)
const l32 = act1.get(32)!
const l33 = act1.get(33)!
expect(l32).toBeDefined()
expect(l33).toBeDefined()
expect(l32.offsetX).toBe(4000)
expect(l32.offsetY).toBe(1000)
expect(l33.offsetX).toBe(3996) // 4000 + (-4)
expect(l33.offsetY).toBe(966) // 1000 + (-34)
expect(l33.offsetY + l33.sizeY).toBe(l32.offsetY) // North boundary sharing
expect(doRectanglesOverlap(l32, l33)).toBe(false)
})
it('supports caller-supplied custom WorldGraph', () => {
const custom = buildWorldGraph(
parseLevelRows(levelsTable([
{ Id: 109, Act: 4, OffsetX: 1000, OffsetY: 1000, SizeX: 40, SizeY: 40 },
{ Id: 110, Act: 4, OffsetX: 760, OffsetY: 1000, SizeX: 240, SizeY: 48 },
{ Id: 111, Act: 4, OffsetX: -1, OffsetY: -1, SizeX: -1, SizeY: -1 },
{ Id: 112, Act: 4, OffsetX: -1, OffsetY: -1, SizeX: -1, SizeY: -1 },
]))
)
const offsets = resolveWorldOffsets(5, 123, custom)
expect(offsets.get(110)?.offsetX).toBe(760)
expect(offsets.get(111)?.offsetX).toBe(702)
expect(offsets.get(112)?.offsetX).toBe(702)
})
it('replays identical offsets deterministically for the same seed', () => {
const run1 = resolveWorldOffsets(1, 9999)
const run2 = resolveWorldOffsets(1, 9999)
expect(Array.from(run1.entries())).toEqual(Array.from(run2.entries()))
})
})

View File

@ -25,8 +25,8 @@ fi
# 32-bit so every D2MOO struct has the exact 1.13c layout of the compiled .bin tables.
# -O0/-fwrapv/-fno-strict-aliasing keep MSVC-like integer and aliasing semantics.
FLAGS=(-m32 -std=c++17 -O0 -g -fwrapv -fno-strict-aliasing -fms-extensions -fpermissive
-Wno-invalid-offsetof -w "-DD2ORACLE_D2MOO_COMMIT=\"$PINNED_COMMIT\"")
FLAGS=(-m32 -std=c++17 -O0 -g -fwrapv -fno-strict-aliasing -fPIC -ffunction-sections -fms-extensions -fpermissive
-Wno-invalid-offsetof -w -DD2_VERSION_HAS_UBERS=1 -include DataTbls/LevelsIds.h "-DD2ORACLE_D2MOO_COMMIT=\"$PINNED_COMMIT\"")
INCLUDES=(-I"$HERE/include" -I"$HERE/src")
for component in D2Common D2CommonDefinitions Fog Storm D2CMP D2Lang D2Hell; do
INCLUDES+=(-I"$D2MOO_SRC/source/$component/include")
@ -75,8 +75,15 @@ for obj in "${d2moo_objs[@]}"; do
fi
done
WRAP_SYMBOL=_Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc
"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "-Wl,--wrap=$WRAP_SYMBOL" -o "$WORK/d2moo-oracle"
WRAP_FLAGS=(
-Wl,--wrap=_Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc
-Wl,--wrap=_Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc
-Wl,--wrap=_Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc
-Wl,--wrap=_Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc
-Wl,--wrap=_Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc
-Wl,--wrap=_Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii
)
"$CXX" -m32 "${d2moo_objs[@]}" "${oracle_objs[@]}" "${WRAP_FLAGS[@]}" -o "$WORK/d2moo-oracle"
mkdir -p "$OUT"
cp "$WORK/d2moo-oracle" "$OUT/d2moo-oracle.tmp"

View File

@ -181,3 +181,6 @@ inline void strcat_s(char (&dst)[N], const char* src) {
strncat(dst, src, N - strlen(dst) - 1);
}
#define sprintf_s snprintf
struct D2DrlgLevelLinkDataStrc;
BOOL __fastcall DRLGOUTPLACE_LinkAct5Ubers(D2DrlgLevelLinkDataStrc* pLevelLinkData, int nIteration);

View File

@ -238,14 +238,57 @@ std::string LevelGrid(D2DrlgLevelStrc* pLevel)
} // namespace
extern "C" void __real__Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel);
extern "C" void __real__Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel);
extern "C" void __real__Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel);
extern "C" void __real__Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel);
extern "C" D2DrlgMapStrc* __real__Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc(
D2DrlgLevelStrc* pLevel, int nLvlPrestId, D2DrlgCoordStrc* pDrlgCoord, D2SeedStrc* pSeed);
extern "C" BOOL __real__Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii(
D2DrlgLevelStrc* pLevel, int nX, int nY, int nWidth, int nHeight, int dwRoomFlags, int dwOutdoorFlags, int dwOutdoorFlagsEx, int dwDT1Mask);
// Linked with -Wl,--wrap: DRLGOUTDOORS_GenerateLevel's call to DRLGOUTWILD_InitAct1OutdoorLevel lands
// here, so the level grids are captured exactly between InitAct1OutdoorLevel and room creation.
// Linked with -Wl,--wrap: DRLGOUTDOORS_GenerateLevel's call to InitAct{1,2,3,5}OutdoorLevel (and
// for Act 4, where DRLGOUTDOORS_InitAct4OutdoorLevel is in the same TU, the first room creation
// immediately after it) lands here so the level grids are captured between InitAct*OutdoorLevel
// and room creation.
extern "C" void __wrap__Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel)
{
__real__Z32DRLGOUTWILD_InitAct1OutdoorLevelP15D2DrlgLevelStrc(pLevel);
gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel);
}
extern "C" void __wrap__Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel)
{
__real__Z32DRLGOUTDESR_InitAct2OutdoorLevelP15D2DrlgLevelStrc(pLevel);
gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel);
}
extern "C" void __wrap__Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel)
{
__real__Z33DRLGOUTPLACE_InitAct3OutdoorLevelP15D2DrlgLevelStrc(pLevel);
gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel);
}
extern "C" void __wrap__Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc(D2DrlgLevelStrc* pLevel)
{
__real__Z33DRLGOUTSIEGE_InitAct5OutdoorLevelP15D2DrlgLevelStrc(pLevel);
gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel);
}
extern "C" D2DrlgMapStrc* __wrap__Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc(
D2DrlgLevelStrc* pLevel, int nLvlPrestId, D2DrlgCoordStrc* pDrlgCoord, D2SeedStrc* pSeed)
{
if (pLevel->nDrlgType == DRLGTYPE_OUTDOOR && gLevelGridJson.find(pLevel->nLevelId) == gLevelGridJson.end())
{
gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel);
}
return __real__Z23DRLGPRESET_AllocDrlgMapP15D2DrlgLevelStrciP15D2DrlgCoordStrcP10D2SeedStrc(pLevel, nLvlPrestId, pDrlgCoord, pSeed);
}
extern "C" BOOL __wrap__Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii(
D2DrlgLevelStrc* pLevel, int nX, int nY, int nWidth, int nHeight, int dwRoomFlags, int dwOutdoorFlags, int dwOutdoorFlagsEx, int dwDT1Mask)
{
if (pLevel->nDrlgType == DRLGTYPE_OUTDOOR && gLevelGridJson.find(pLevel->nLevelId) == gLevelGridJson.end())
{
gLevelGridJson[pLevel->nLevelId] = LevelGrid(pLevel);
}
return __real__Z32DRLGOUTPLACE_CreateOutdoorRoomExP15D2DrlgLevelStrciiiiiiii(
pLevel, nX, nY, nWidth, nHeight, dwRoomFlags, dwOutdoorFlags, dwOutdoorFlagsEx, dwDT1Mask);
}
namespace
{
@ -614,10 +657,6 @@ std::string MapTiles(const RefIndex& index, const D2DrlgRoomStrc* pRoom, int nLe
{
D2ORACLE_Fail("level %d room %d: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles", nLevelId, nRoom);
}
if (pTileGrid->pAnimTiles)
{
D2ORACLE_Fail("level %d room %d: animated tiles (pAnimTiles) are not dumped", nLevelId, nRoom);
}
const D2DrlgRoomTilesStrc& t = pTileGrid->pTiles;
std::ostringstream o;
o << "{\"nWalls\":" << pTileGrid->nWalls << ",\"nFloors\":" << pTileGrid->nFloors << ",\"nShadows\":" << pTileGrid->nShadows
@ -645,19 +684,20 @@ std::string InitLevelStage(D2DrlgStrc* pDrlg, int nLevelId, D2DrlgLevelStrc** pp
D2ORACLE_SetTraceContext(szContext);
D2DrlgLevelStrc* pLevel = DRLG_GetLevel(pDrlg, nLevelId);
if (pLevel->nDrlgType != DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) != ACT_I)
if (pLevel->nDrlgType != DRLGTYPE_OUTDOOR)
{
D2ORACLE_Fail("level %d is not an Act I outdoor level", nLevelId);
D2ORACLE_Fail("level %d is not an outdoor level", nLevelId);
}
if (pLevel->pFirstRoomEx)
{
D2ORACLE_Fail("level %d was already generated before its own generation stage", nLevelId);
}
gLevelGridJson.erase(nLevelId);
DRLG_InitLevel(pLevel);
auto it = gLevelGridJson.find(nLevelId);
if (it == gLevelGridJson.end())
{
D2ORACLE_Fail("level %d: InitAct1OutdoorLevel hook did not fire", nLevelId);
D2ORACLE_Fail("level %d: outdoor levelGrid hook did not fire", nLevelId);
}
std::vector<D2DrlgMapStrc*> maps;
@ -679,14 +719,17 @@ std::string InitLevelStage(D2DrlgStrc* pDrlg, int nLevelId, D2DrlgLevelStrc** pp
return o.str();
}
D2DrlgStrc* AllocAct1(D2DrlgActStrc* pAct, uint32_t nSeed, uint8_t nDifficulty)
D2DrlgStrc* AllocAct(D2DrlgActStrc* pAct, uint8_t nAct, uint32_t nSeed, uint8_t nDifficulty)
{
static const int kTownLevelIds[5] = {
LEVEL_ROGUEENCAMPMENT, LEVEL_LUTGHOLEIN, LEVEL_KURASTDOCKTOWN, LEVEL_THEPANDEMONIUMFORTRESS, LEVEL_HARROGATH,
};
memset(pAct, 0, sizeof(*pAct));
pAct->nAct = ACT_I;
pAct->nAct = nAct;
D2ORACLE_SetTraceContext("drlg");
// D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act
// (DRLGOUTPLACE_CreateLevelConnections) and generates the town.
return DRLG_AllocDrlg(pAct, ACT_I, nullptr, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nullptr, nDifficulty, nullptr, nullptr);
return DRLG_AllocDrlg(pAct, nAct, nullptr, nSeed, kTownLevelIds[nAct], 0, nullptr, nDifficulty, nullptr, nullptr);
}
std::vector<int> ParseLevels(const char* sz)
@ -710,6 +753,7 @@ int main(int argc, char** argv)
bool bHaveSeed = false;
uint32_t nSeed = 0;
int nDifficulty = 0;
int nExplicitAct = -1;
bool bIsolated = false;
std::vector<int> levels;
for (int i = 1; i < argc; ++i)
@ -722,6 +766,7 @@ int main(int argc, char** argv)
if (arg == "--data") gsD2OracleDataDir = next();
else if (arg == "--seed") { nSeed = (uint32_t)strtoul(next(), nullptr, 0); bHaveSeed = true; }
else if (arg == "--levels") levels = ParseLevels(next());
else if (arg == "--act") nExplicitAct = atoi(next());
else if (arg == "--difficulty") nDifficulty = atoi(next());
else if (arg == "--isolated") bIsolated = true;
else if (arg == "--trace") szTrace = next();
@ -731,7 +776,7 @@ int main(int argc, char** argv)
// Without --levels only the act stage (layout, links, town) is generated and dumped.
if (gsD2OracleDataDir.empty() || !bHaveSeed || !szOut)
{
D2ORACLE_Fail("usage: d2moo-oracle --data <dir> --seed <n> [--levels 2,3] --out <file> [--difficulty n] [--isolated] [--trace file]");
D2ORACLE_Fail("usage: d2moo-oracle --data <dir> --seed <n> [--levels 2,3] [--act 0..4] --out <file> [--difficulty n] [--isolated] [--trace file]");
}
if (nDifficulty < 0 || nDifficulty > 2)
{
@ -744,12 +789,30 @@ int main(int argc, char** argv)
D2ORACLE_OpenTrace(szTrace);
}
uint8_t nAct = ACT_I;
if (nExplicitAct != -1)
{
if (nExplicitAct < 0 || nExplicitAct > 4) D2ORACLE_Fail("act must be 0..4");
nAct = (uint8_t)nExplicitAct;
}
else if (!levels.empty())
{
nAct = DRLG_GetActNoFromLevelId(levels[0]);
}
for (int id : levels)
{
if (DRLG_GetActNoFromLevelId(id) != nAct)
{
D2ORACLE_Fail("level %d belongs to act %d, not act %d", id, (int)DRLG_GetActNoFromLevelId(id), (int)nAct);
}
}
D2DrlgActStrc act = {};
std::ostringstream o;
o << "{\"schema\":3,\"oracle\":{\"d2moo\":\"" << D2ORACLE_D2MOO_COMMIT << "\",\"arch\":\"i386\"},\"gameSeed\":" << nSeed
<< ",\"difficulty\":" << nDifficulty << ",\"isolated\":" << (bIsolated ? "true" : "false");
D2DrlgStrc* pDrlg = AllocAct1(&act, nSeed, (uint8_t)nDifficulty);
D2DrlgStrc* pDrlg = AllocAct(&act, nAct, nSeed, (uint8_t)nDifficulty);
o << ",\"drlg\":{\"startSeed\":" << pDrlg->dwStartSeed << ",\"seed\":" << Seed(pDrlg->pSeed) << "},\"act\":" << Act(pDrlg) << ",\"levels\":[";
std::vector<std::string> heads(levels.size());
std::vector<D2DrlgLevelStrc*> generated(levels.size(), nullptr);
@ -761,7 +824,7 @@ int main(int argc, char** argv)
{
if (i > 0)
{
pDrlg = AllocAct1(&act, nSeed, (uint8_t)nDifficulty);
pDrlg = AllocAct(&act, nAct, nSeed, (uint8_t)nDifficulty);
}
heads[i] = InitLevelStage(pDrlg, levels[i], &generated[i]);
activations[i] = Activate(generated[i]);

View File

@ -336,9 +336,9 @@ BOOL __stdcall D2Common_11099(D2FieldStrc*, D2ActiveRoomStrc*, int, int, uint16_
{
D2ORACLE_Fail("unexpected D2Common_11099");
}
BOOL __fastcall MONSTERS_ValidateMonsterId(int)
BOOL __fastcall MONSTERS_ValidateMonsterId(int nMonsterId)
{
D2ORACLE_Fail("unexpected MONSTERS_ValidateMonsterId (tile preset units are not generated)");
return nMonsterId >= 0 && nMonsterId < sgptDataTables->nMonStatsTxtRecordCount ? nMonsterId : -1;
}
// ---- D2CMP: DT1 tile libraries ----
@ -468,16 +468,13 @@ void D2ORACLE_DescribeTile(const D2TileLibraryEntryStrc* pTileLibraryEntry, std:
*pnIndex = pTile->nIndex;
}
//D2Cmp.#10087: stores the library in the first free slot of the 32-slot array.
//D2Cmp.#10087 (1.13c D2CMP.dll 0x6fe25480): stores the library in the first free slot of the 32-slot
// array without deduplicating (e.g. LvlTypes.txt row 21 Act 3 Jungle lists Bridge.dt1 in slots 0 and 8).
void __stdcall D2CMP_10087_LoadTileLibrarySlot(D2TileLibraryHashStrc** ppTileLibraryHash, const char* szFileName)
{
OracleTileLibrary* pLibrary = LoadTileLibrary(szFileName);
for (int i = 0; i < kTileSlots; ++i)
{
if (ppTileLibraryHash[i] == &pLibrary->tHash)
{
D2ORACLE_Fail("tile library %s loaded twice into the same slot array (D2CMP behaviour unknown)", pLibrary->szPath.c_str());
}
if (!ppTileLibraryHash[i])
{
ppTileLibraryHash[i] = &pLibrary->tHash;