This commit is contained in:
commit
9077b5889d
|
|
@ -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` 这类简单线性同余公式,并通过空间方差统计单测确保视觉离散度。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ function buildTokenJoin(
|
|||
for (const row of monstats.rows) {
|
||||
const token = cell(monstats, row, 'Code').trim().toUpperCase()
|
||||
const ex = cell(monstats, row, 'MonStatsEx').trim()
|
||||
if (token.length === 0 || token === 'XX') continue
|
||||
if (token.length === 0) continue
|
||||
if (ex.length === 0 || !rows.has(ex)) continue
|
||||
const ids = join.get(token) ?? []
|
||||
if (!ids.includes(ex)) ids.push(ex)
|
||||
|
|
@ -301,7 +301,7 @@ function bakedMonsterTokens(): Set<string> {
|
|||
const tokens = new Set<string>()
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
for (const spec of ACT_MONSTER_SPECS[act] ?? []) {
|
||||
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
||||
if (!spec.token) continue
|
||||
tokens.add(spec.token.toUpperCase())
|
||||
}
|
||||
}
|
||||
|
|
@ -314,7 +314,7 @@ function bakedMonsterTokens(): Set<string> {
|
|||
}
|
||||
for (const id of monsterIds) {
|
||||
const spec = resolveMonsterArtSpec(id)
|
||||
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') tokens.add(spec.token.toUpperCase())
|
||||
if (spec && spec.token) tokens.add(spec.token.toUpperCase())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
|
|
|||
|
|
@ -735,7 +735,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
|||
for (let act = 1; act <= 5; act += 1) {
|
||||
const specs = ACT_MONSTER_SPECS[act] ?? []
|
||||
for (const spec of specs) {
|
||||
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
||||
if (!spec.token) continue
|
||||
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||
if (!uniqueSpecs.has(key)) {
|
||||
uniqueSpecs.set(key, spec)
|
||||
|
|
@ -762,7 +762,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
|||
|
||||
for (const monsterId of allSuperUniqueMonsterIds) {
|
||||
const spec = resolveMonsterArtSpec(monsterId)
|
||||
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
|
||||
if (spec && spec.token) {
|
||||
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||
if (!uniqueSpecs.has(key)) {
|
||||
uniqueSpecs.set(key, spec)
|
||||
|
|
@ -773,7 +773,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
|||
// Include special summon and minion entities (Valkyrie, Decoy/Dopplezon)
|
||||
for (const summonId of ['valkyrie', 'dopplezon']) {
|
||||
const spec = resolveMonsterArtSpec(summonId)
|
||||
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
|
||||
if (spec && spec.token) {
|
||||
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||
if (!uniqueSpecs.has(key)) {
|
||||
uniqueSpecs.set(key, spec)
|
||||
|
|
@ -804,7 +804,7 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
|||
for (let act = 1; act <= 5; act += 1) {
|
||||
const specs = ACT_MONSTER_SPECS[act] ?? []
|
||||
for (const spec of specs) {
|
||||
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
||||
if (!spec.token) continue
|
||||
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||
const list = monsterActsMap.get(key) ?? []
|
||||
if (!list.includes(act)) list.push(act)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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!')
|
||||
}
|
||||
|
|
@ -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('================================================================================')
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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):`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]!
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -530,6 +530,37 @@ describe('Diablo II DRLG Maze Special Passes', () => {
|
|||
expect(observedDirs).toContain('SW')
|
||||
expect(observedDirs).toContain('SE')
|
||||
})
|
||||
|
||||
test('Halls of Pain (level 123): always places waypoint in bounded 60x60 2x2 ring across var1..var3 and 100 seeds (Issue #501)', () => {
|
||||
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
|
||||
const seeds = [
|
||||
0x5eed0000 + 123 + 0 * 555,
|
||||
0x5eed0000 + 123 + 1 * 555,
|
||||
0x5eed0000 + 123 + 2 * 555,
|
||||
...Array.from({ length: 100 }, (_, i) => i + 1),
|
||||
]
|
||||
|
||||
for (const seed of seeds) {
|
||||
const res = generateMaze({
|
||||
levelId: 123,
|
||||
levelName: 'Act 5 - Temple 2',
|
||||
sectionSize: 30,
|
||||
minRooms: 4,
|
||||
merge: 1000,
|
||||
seed,
|
||||
pieces,
|
||||
maxCellsX: 60,
|
||||
maxCellsY: 60,
|
||||
})
|
||||
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
|
||||
const wp = specials.find(s => s.kind === 'waypoint')
|
||||
const down = specials.find(s => s.kind === 'down')
|
||||
expect(wp, `missing waypoint for seed 0x${seed.toString(16)}`).toBeDefined()
|
||||
expect(down, `missing down stairs for seed 0x${seed.toString(16)}`).toBeDefined()
|
||||
expect(wp!.sides).not.toBe(down!.sides)
|
||||
expect(wp!.room).not.toBe(down!.room)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------------- *
|
||||
|
|
|
|||
|
|
@ -23,17 +23,24 @@ describe('monster-mapping', () => {
|
|||
expect(MONSTER_ART_MAP.hellbovine).toEqual({ token: 'EC', weapon: 'hth' })
|
||||
})
|
||||
|
||||
it('does not leak XX tokens or crash on cut monsters', () => {
|
||||
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'minion1', 'spiritmummy']
|
||||
it('does not leak cut monsters while resolving Act 5 Minion of Destruction (minion1..11) to token XX', () => {
|
||||
const cutMonsters = ['bloodmage1', 'darkguard1', 'lightningbeast', 'spiritmummy']
|
||||
for (const id of cutMonsters) {
|
||||
expect(resolveMonsterArtSpec(id)).toBeNull()
|
||||
}
|
||||
for (let i = 1; i <= 11; i += 1) {
|
||||
expect(resolveMonsterArtSpec(`minion${i}`)).toEqual({ token: 'XX', weapon: 'hth' })
|
||||
}
|
||||
})
|
||||
|
||||
it('ACT_MONSTER_SPECS does not contain any XX tokens in any act', () => {
|
||||
it('ACT_MONSTER_SPECS includes token XX only in Act 5 (Minion of Destruction)', () => {
|
||||
for (const [act, specs] of Object.entries(ACT_MONSTER_SPECS)) {
|
||||
for (const spec of specs) {
|
||||
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
|
||||
if (Number(act) === 5) {
|
||||
expect(specs).toContainEqual({ token: 'XX', weapon: 'hth' })
|
||||
} else {
|
||||
for (const spec of specs) {
|
||||
expect(spec.token, `Act ${act} contains invalid token ${spec.token}`).not.toBe('XX')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue