729 lines
26 KiB
TypeScript
729 lines
26 KiB
TypeScript
/**
|
|
* Wilderness D2MOO Ground Truth Parity Test Suite (Issue #455).
|
|
*
|
|
* Verifies that outdoor wilderness level generation adheres with 100% fidelity
|
|
* to Diablo II v1.13c ground truth as established by D2MOO C++ DRLG engine
|
|
* (`D2Common/src/Drlg/`) and native binary execution (`d2moo_mapgen`).
|
|
*
|
|
* Multi-Tier Parity Coverage:
|
|
* - Tier 1: Dynamic Level Dimensions (96x56 horizontal vs 56x96 vertical) & Topology (R1)
|
|
* - Tier 2: Outer River Placement, Dual Columns (x=80, 88), and Boundary Suppression (R2)
|
|
* - Tier 3: Town Transition (TownSTrans.ds1 56x8) Anchored to Eastern River Edge (R4)
|
|
* - Tier 4: Unconditional Landmark Presets & FarAway Anti-Overlap Metric (R4)
|
|
* - Tier 5: 4-Pass Secondary Border Evolution & Interior 2x3 Forest Closures (Bord9..12) (R3)
|
|
* - Tier 6: Orthogonal Polyline Dirt Paths & Width-2 Bresenham Rasterization (R5)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
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 } from '../src/formats/ds1.ts'
|
|
import {
|
|
generateWilderness,
|
|
type WildernessPiece,
|
|
type WildernessSubstitution,
|
|
type WildernessResult,
|
|
} from '../src/game/wilderness.ts'
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* D2MOO Data Contracts & Types
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
export interface OutdoorCellJson {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly fPacked: number
|
|
readonly fStyle: number
|
|
readonly fSeq: number
|
|
readonly wPacked: number
|
|
readonly wStyle: number
|
|
readonly wSeq: number
|
|
readonly wType: number
|
|
readonly dirt: number
|
|
}
|
|
|
|
export interface D2MooRoomJson {
|
|
readonly index: number
|
|
readonly type: number
|
|
readonly tileX: number
|
|
readonly tileY: number
|
|
readonly tileW: number
|
|
readonly tileH: number
|
|
readonly mapPtr: number
|
|
readonly mapX: number
|
|
readonly mapY: number
|
|
readonly mapW: number
|
|
readonly mapH: number
|
|
readonly lvlPrestId: number
|
|
readonly pickedFile: number
|
|
readonly ds1File: string
|
|
readonly ds1Width: number
|
|
readonly ds1Height: number
|
|
readonly doorMask: number
|
|
readonly orthCount: number
|
|
readonly outdoorCells: readonly OutdoorCellJson[]
|
|
}
|
|
|
|
export interface D2MooLevelJson {
|
|
readonly levelId: number
|
|
readonly levelName: string
|
|
readonly act: number
|
|
readonly drlgType: number
|
|
readonly levelType: number
|
|
readonly seed: number
|
|
readonly posX: number
|
|
readonly posY: number
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly roomCount: number
|
|
readonly rooms: readonly D2MooRoomJson[]
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Dual-Mode Execution & Fixture Loader
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
const D2MOO_BIN = process.env.D2MOO_BIN ?? '/usr/local/google/home/taodao/tmp/d2moo_runner/d2moo_mapgen'
|
|
const FIXTURES_DIR = 'tests/fixtures/d2moo'
|
|
const hasD2Archives = existsSync('samples/d2/d2data.mpq')
|
|
|
|
/**
|
|
* Loads a D2MOO level layout either by executing `d2moo_mapgen` dynamically
|
|
* or by reading the pre-recorded golden fixture from `tests/fixtures/d2moo/`.
|
|
*/
|
|
export function loadOrRunD2MooLevel(levelId: number, seed: number | string): D2MooLevelJson {
|
|
const seedNum = typeof seed === 'number' ? seed : (seed.startsWith('0x') ? parseInt(seed, 16) : parseInt(seed, 10))
|
|
const seedHex = `0x${seedNum.toString(16)}`
|
|
const seedDec = seedNum.toString(10)
|
|
|
|
// 1. If native binary is present and not explicitly bypassed, run dynamically
|
|
if (existsSync(D2MOO_BIN) && process.env.D2MOO_FORCE_FIXTURE !== '1') {
|
|
try {
|
|
const tmpOut = join(tmpdir(), `d2moo_parity_${levelId}_${seedNum.toString(16)}.json`)
|
|
execFileSync(D2MOO_BIN, [String(levelId), seedHex, '0', tmpOut], { stdio: 'ignore' })
|
|
if (existsSync(tmpOut)) {
|
|
return JSON.parse(readFileSync(tmpOut, 'utf8')) as D2MooLevelJson
|
|
}
|
|
} catch {
|
|
// Fallback to pre-recorded fixture
|
|
}
|
|
}
|
|
|
|
// 2. Fallback to pre-recorded golden JSON fixture
|
|
const candidateNames = [
|
|
`level${levelId}_seed_0x${seedNum.toString(16)}.json`,
|
|
`level${levelId}_seed_${seedDec}.json`,
|
|
`level${levelId}_seed_${seedNum.toString(16)}.json`,
|
|
`level_${levelId}_seed_${seedNum.toString(16)}.json`,
|
|
]
|
|
|
|
for (const name of candidateNames) {
|
|
const p = join(FIXTURES_DIR, name)
|
|
if (existsSync(p)) {
|
|
return JSON.parse(readFileSync(p, 'utf8')) as D2MooLevelJson
|
|
}
|
|
}
|
|
|
|
throw new Error(`Neither executable d2moo_mapgen nor golden fixture found for Level ${levelId} Seed ${seedHex}`)
|
|
}
|
|
|
|
/**
|
|
* Extracts distinct preset stamp instances (deduplicating multi-room presets sharing mapPtr).
|
|
*/
|
|
export function getDistinctPresetStamps(level: D2MooLevelJson): Array<{
|
|
file: string
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
}> {
|
|
const seenMapPtrs = new Set<number>()
|
|
const stamps: Array<{ file: string; x: number; y: number; w: number; h: number }> = []
|
|
|
|
for (const room of level.rooms) {
|
|
if (!room.ds1File) continue
|
|
if (seenMapPtrs.has(room.mapPtr)) continue
|
|
seenMapPtrs.add(room.mapPtr)
|
|
|
|
const file = room.ds1File.split('/').pop() ?? room.ds1File
|
|
stamps.push({
|
|
file,
|
|
x: room.mapX - level.posX,
|
|
y: room.mapY - level.posY,
|
|
w: room.mapW,
|
|
h: room.mapH,
|
|
})
|
|
}
|
|
|
|
return stamps
|
|
}
|
|
|
|
/**
|
|
* Extracts absolute world tile coordinates of all dirt road cells in the level.
|
|
*/
|
|
export function getDirtRoadCells(level: D2MooLevelJson): Array<{ x: number; y: number }> {
|
|
const cells: Array<{ x: number; y: number }> = []
|
|
for (const room of level.rooms) {
|
|
for (const cellRecord of room.outdoorCells) {
|
|
if (cellRecord.dirt !== 0) {
|
|
cells.push({
|
|
x: room.tileX - level.posX + cellRecord.x,
|
|
y: room.tileY - level.posY + cellRecord.y,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return cells
|
|
}
|
|
|
|
/**
|
|
* Analyzes connectivity of road cells via 8-way BFS flood-fill.
|
|
*/
|
|
export function evaluateRoadConnectivity(cells: Array<{ x: number; y: number }>): {
|
|
totalCells: number
|
|
componentCount: number
|
|
largestComponentSize: number
|
|
} {
|
|
const cellSet = new Set(cells.map(c => `${c.x},${c.y}`))
|
|
const visited = new Set<string>()
|
|
const components: string[][] = []
|
|
|
|
for (const key of cellSet) {
|
|
if (visited.has(key)) continue
|
|
const queue = [key]
|
|
visited.add(key)
|
|
const comp = [key]
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!
|
|
const [cx, cy] = current.split(',').map(Number)
|
|
for (const [dx, dy] of [
|
|
[1, 0], [-1, 0], [0, 1], [0, -1],
|
|
[1, 1], [1, -1], [-1, 1], [-1, -1],
|
|
]) {
|
|
const neighbor = `${cx + dx},${cy + dy}`
|
|
if (cellSet.has(neighbor) && !visited.has(neighbor)) {
|
|
visited.add(neighbor)
|
|
queue.push(neighbor)
|
|
comp.push(neighbor)
|
|
}
|
|
}
|
|
}
|
|
components.push(comp)
|
|
}
|
|
|
|
return {
|
|
totalCells: cells.length,
|
|
componentCount: components.length,
|
|
largestComponentSize: components[0]?.length ?? 0,
|
|
}
|
|
}
|
|
|
|
/* ========================================================================= *
|
|
* SUITE 1: D2MOO v1.13c Ground Truth Oracle & Fixture Invariants
|
|
* ========================================================================= */
|
|
|
|
describe('Part 1: D2MOO Ground Truth Fixture & Runner Verification (Issue #455)', () => {
|
|
// --- Tier 1: Dimensions & Orientations ---
|
|
describe('Tier 1: Dynamic Level Dimensions & Topology Invariants (R1)', () => {
|
|
it('verifies horizontal 96x56 Moor layout for Seed 0x12345678', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
|
expect(d2moo.width).toBe(96)
|
|
expect(d2moo.height).toBe(56)
|
|
expect(d2moo.width / 8).toBe(12)
|
|
expect(d2moo.height / 8).toBe(7)
|
|
expect(d2moo.roomCount).toBe(82)
|
|
})
|
|
|
|
it('verifies horizontal 96x56 Moor layout for Seed 12345', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 12345)
|
|
expect(d2moo.width).toBe(96)
|
|
expect(d2moo.height).toBe(56)
|
|
expect(d2moo.width / 8).toBe(12)
|
|
expect(d2moo.height / 8).toBe(7)
|
|
expect(d2moo.roomCount).toBe(84)
|
|
})
|
|
|
|
it('verifies vertical 56x96 Moor layout for Seed 1', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 1)
|
|
expect(d2moo.width).toBe(56)
|
|
expect(d2moo.height).toBe(96)
|
|
expect(d2moo.width / 8).toBe(7)
|
|
expect(d2moo.height / 8).toBe(12)
|
|
expect(d2moo.roomCount).toBe(79)
|
|
})
|
|
|
|
it('verifies vertical 56x96 Moor layout for Seed 42', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 42)
|
|
expect(d2moo.width).toBe(56)
|
|
expect(d2moo.height).toBe(96)
|
|
expect(d2moo.width / 8).toBe(7)
|
|
expect(d2moo.height / 8).toBe(12)
|
|
expect(d2moo.roomCount).toBe(82)
|
|
})
|
|
|
|
it('verifies Cold Plains (Level 3) fixed 80x80 layout for Seed 1', () => {
|
|
const d2moo = loadOrRunD2MooLevel(3, 1)
|
|
expect(d2moo.width).toBe(80)
|
|
expect(d2moo.height).toBe(80)
|
|
expect(d2moo.roomCount).toBe(97)
|
|
})
|
|
|
|
it('verifies Stony Field (Level 4) fixed 80x80 layout for Seed 1', () => {
|
|
const d2moo = loadOrRunD2MooLevel(4, 1)
|
|
expect(d2moo.width).toBe(80)
|
|
expect(d2moo.height).toBe(80)
|
|
expect(d2moo.roomCount).toBe(91)
|
|
})
|
|
})
|
|
|
|
// --- Tier 2: Outer River Placement ---
|
|
describe('Tier 2: Outer River Placement & Boundary Suppression (R2)', () => {
|
|
it('spawns dual-column river on eastern boundary (x=80, x=88) for Seed 0x12345678', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
|
const riverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('River'))
|
|
|
|
expect(riverRooms.length).toBeGreaterThanOrEqual(12)
|
|
|
|
const uRiverRooms = riverRooms.filter(r => r.ds1File.includes('URiverC'))
|
|
const lRiverRooms = riverRooms.filter(r => r.ds1File.includes('LRiverC'))
|
|
|
|
expect(uRiverRooms.length).toBeGreaterThanOrEqual(6)
|
|
expect(lRiverRooms.length).toBeGreaterThanOrEqual(6)
|
|
|
|
// Column x = 80 (block 10, nGridWidth - 2) for URiverC
|
|
for (const room of uRiverRooms) {
|
|
expect(room.mapX - d2moo.posX).toBe(80)
|
|
}
|
|
|
|
// Column x = 88 (block 11, nGridWidth - 1) for LRiverC
|
|
for (const room of lRiverRooms) {
|
|
expect(room.mapX - d2moo.posX).toBe(88)
|
|
}
|
|
|
|
// Outer boundary tree wall suppression: no Bord*.ds1 rooms exist at x = 88 where river flows
|
|
const eastBorders = d2moo.rooms.filter(r => {
|
|
const file = r.ds1File?.split('/').pop() ?? ''
|
|
const relX = r.mapX - d2moo.posX
|
|
return file.startsWith('Bord') && relX === 88
|
|
})
|
|
expect(eastBorders.length).toBe(0)
|
|
})
|
|
|
|
it('spawns dual-column river on eastern boundary (x=80, x=88) for Seed 12345', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 12345)
|
|
const uRiverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('URiverC'))
|
|
const lRiverRooms = d2moo.rooms.filter(r => r.ds1File && r.ds1File.includes('LRiverC'))
|
|
|
|
expect(uRiverRooms.length).toBeGreaterThanOrEqual(6)
|
|
expect(lRiverRooms.length).toBeGreaterThanOrEqual(6)
|
|
|
|
for (const room of uRiverRooms) expect(room.mapX - d2moo.posX).toBe(80)
|
|
for (const room of lRiverRooms) expect(room.mapX - d2moo.posX).toBe(88)
|
|
})
|
|
})
|
|
|
|
// --- Tier 3: Town Transition Alignment ---
|
|
describe('Tier 3: Town Transition Alignment & River Edge Anchoring (R4)', () => {
|
|
it('places TownSTrans.ds1 (56x8) anchored to East river at (40, 0) for Seed 0x12345678', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
|
const townRoom = d2moo.rooms.find(r => r.ds1File && r.ds1File.includes('TownSTrans'))
|
|
|
|
expect(townRoom).toBeDefined()
|
|
expect(townRoom!.mapX - d2moo.posX).toBe(40)
|
|
expect(townRoom!.mapY - d2moo.posY).toBe(0)
|
|
expect(townRoom!.mapW).toBe(56)
|
|
expect(townRoom!.mapH).toBe(8)
|
|
|
|
// 40 + 56 = 96: transition extends exactly to the eastern river edge (x = 80..96)
|
|
expect(townRoom!.mapX - d2moo.posX + townRoom!.mapW).toBe(d2moo.width)
|
|
})
|
|
})
|
|
|
|
// --- Tier 4: Landmark Presets & FarAway Anti-Overlap Metric ---
|
|
describe('Tier 4: Complete Landmark Presets & FarAway Distribution (R4)', () => {
|
|
it('unconditionally places all 5 canonical landmark categories in Blood Moor (Seed 0x12345678)', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
|
const uniquePresets = new Set(d2moo.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
|
|
|
|
expect(uniquePresets.has('DenEnt.ds1')).toBe(true)
|
|
expect(uniquePresets.has('Pond1.ds1')).toBe(true)
|
|
expect(uniquePresets.has('Cott1.ds1')).toBe(true)
|
|
expect(uniquePresets.has('Circle.ds1')).toBe(true)
|
|
expect(uniquePresets.has('Clover.ds1')).toBe(true)
|
|
})
|
|
|
|
it('verifies zero spatial overlap across all distinct preset stamps', () => {
|
|
for (const [lvl, s] of [
|
|
[2, 0x12345678],
|
|
[2, 12345],
|
|
[2, 1],
|
|
[2, 42],
|
|
[3, 1],
|
|
[4, 1],
|
|
] as const) {
|
|
const d2moo = loadOrRunD2MooLevel(lvl, s)
|
|
const stamps = getDistinctPresetStamps(d2moo)
|
|
|
|
for (let i = 0; i < stamps.length; i += 1) {
|
|
for (let j = i + 1; j < stamps.length; j += 1) {
|
|
const a = stamps[i]!
|
|
const b = stamps[j]!
|
|
|
|
const xOverlap = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x))
|
|
const yOverlap = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y))
|
|
|
|
expect(
|
|
xOverlap > 0 && yOverlap > 0,
|
|
`Overlap detected in Level ${lvl} Seed ${s} between ${a.file} and ${b.file}`,
|
|
).toBe(false)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('verifies FarAway metric (|dx| + 2*|dy|) places Den of Evil distant from Town Gate', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
|
const townRoom = d2moo.rooms.find(r => r.ds1File.includes('TownSTrans'))!
|
|
const denRoom = d2moo.rooms.find(r => r.ds1File.includes('DenEnt'))!
|
|
|
|
const townX = townRoom.mapX - d2moo.posX
|
|
const townY = townRoom.mapY - d2moo.posY
|
|
const denX = denRoom.mapX - d2moo.posX
|
|
const denY = denRoom.mapY - d2moo.posY
|
|
|
|
// FarAway Manhattan metric: |dx| + 2*|dy|
|
|
const distance = Math.abs(denX - townX) + 2 * Math.abs(denY - townY)
|
|
expect(distance).toBeGreaterThanOrEqual(48)
|
|
})
|
|
|
|
it('verifies canonical presets in Cold Plains (Level 3) and Stony Field (Level 4)', () => {
|
|
const l3 = loadOrRunD2MooLevel(3, 1)
|
|
const l3Presets = new Set(l3.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
|
|
expect(l3Presets.has('CaveDr1.ds1')).toBe(true)
|
|
expect(l3Presets.has('FallCmpBish.ds1')).toBe(true)
|
|
expect(l3Presets.has('Cott1A.ds1')).toBe(true)
|
|
expect(l3Presets.has('Circle.ds1')).toBe(true)
|
|
expect(l3Presets.has('Clover.ds1')).toBe(true)
|
|
|
|
const l4 = loadOrRunD2MooLevel(4, 1)
|
|
const l4Presets = new Set(l4.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean))
|
|
expect(l4Presets.has('cairn2.ds1')).toBe(true)
|
|
expect(l4Presets.has('clfcave.ds1')).toBe(true)
|
|
expect(l4Presets.has('tome.ds1')).toBe(true)
|
|
expect(l4Presets.has('Corral1.ds1')).toBe(true)
|
|
expect(l4Presets.has('FallCmp.ds1')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Tier 5: 4-Pass Secondary Borders & 2x3 Forest Closures ---
|
|
describe('Tier 5: 4-Pass Secondary Border Evolution & 2x3 Closures (R3)', () => {
|
|
it('spawns Bord5..8 outer peninsulas and Bord9..12 interior 2x3 forest closures', () => {
|
|
for (const [lvl, s] of [
|
|
[2, 0x12345678],
|
|
[2, 12345],
|
|
[2, 1],
|
|
[2, 42],
|
|
[3, 1],
|
|
[4, 1],
|
|
] as const) {
|
|
const d2moo = loadOrRunD2MooLevel(lvl, s)
|
|
const files = d2moo.rooms.map(r => r.ds1File.split('/').pop()).filter(Boolean) as string[]
|
|
|
|
const peninsulas = files.filter(f => /^Bord[5678]\.ds1$/i.test(f))
|
|
const closures = files.filter(f => /^Bord(9|10|11|12)\.ds1$/i.test(f))
|
|
|
|
expect(peninsulas.length, `Level ${lvl} Seed ${s} missing Bord5..8 peninsulas`).toBeGreaterThan(0)
|
|
expect(closures.length, `Level ${lvl} Seed ${s} missing Bord9..12 closures`).toBeGreaterThanOrEqual(4)
|
|
}
|
|
})
|
|
})
|
|
|
|
// --- Tier 6: Dirt Path Connectivity ---
|
|
describe('Tier 6: Orthogonal Polyline Dirt Paths & Connectivity (R5)', () => {
|
|
it('generates 100% contiguous dirt road network connecting gate, den, and exits', () => {
|
|
for (const s of [0x12345678, 12345, 1, 42] as const) {
|
|
const d2moo = loadOrRunD2MooLevel(2, s)
|
|
const dirtCells = getDirtRoadCells(d2moo)
|
|
|
|
expect(dirtCells.length, `Level 2 Seed ${s} must have dirt road cells`).toBeGreaterThanOrEqual(100)
|
|
|
|
const connectivity = evaluateRoadConnectivity(dirtCells)
|
|
expect(
|
|
connectivity.componentCount,
|
|
`Level 2 Seed ${s} road cells should form a single connected component`,
|
|
).toBe(1)
|
|
expect(connectivity.largestComponentSize).toBe(dirtCells.length)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
/* ========================================================================= *
|
|
* SUITE 2: Wilderness Generator Ground Truth Parity (Issue #455 Milestones)
|
|
* ========================================================================= */
|
|
|
|
describe.skipIf(!hasD2Archives)('Part 2: DRLG Wilderness Generator Parity vs D2MOO (Milestones M1..M6)', () => {
|
|
let act1Pieces: WildernessPiece[]
|
|
let act1Subs: WildernessSubstitution[]
|
|
|
|
const WILDERNESS_PIECE_FAMILIES = [
|
|
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
|
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
|
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
|
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
|
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
|
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
|
'Act 1 - Graveyard',
|
|
]
|
|
|
|
beforeAll(async () => {
|
|
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 {
|
|
// Skip optional archive if missing
|
|
}
|
|
}
|
|
|
|
const actTables = await loadActTables(archives)
|
|
const lvlsubTable = 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 val = cell(table, row, `File${String(slot)}`)
|
|
if (val === '' || val === '0') continue
|
|
levels.push(await loadDs1(val))
|
|
}
|
|
return levels
|
|
}
|
|
|
|
// Load Act 1 wilderness preset pieces
|
|
act1Pieces = []
|
|
for (const row of actTables.lvlprest.rows) {
|
|
const name = cell(actTables.lvlprest, row, 'Name')
|
|
if (!WILDERNESS_PIECE_FAMILIES.some(fam => name.startsWith(fam))) continue
|
|
const levels = await rowDs1s(actTables.lvlprest, row)
|
|
if (levels.length === 0) continue
|
|
act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) })
|
|
}
|
|
|
|
// Load Act 1 substitutions (SubTypes 0, 1, 6)
|
|
act1Subs = []
|
|
for (const row of lvlsubTable.rows) {
|
|
const subType = Number(cell(lvlsubTable, row, 'Type'))
|
|
if (subType !== 0 && subType !== 1 && subType !== 6) continue
|
|
const file = cell(lvlsubTable, row, 'File')
|
|
if (file === '' || file === '0') continue
|
|
const levels = [await loadDs1(file)]
|
|
act1Subs.push({
|
|
name: cell(lvlsubTable, row, 'Name'),
|
|
type: subType,
|
|
gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1,
|
|
bordType: Number(cell(lvlsubTable, row, 'BordType')),
|
|
dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0,
|
|
prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Prob${String(i)}`)) || 0),
|
|
trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Trials${String(i)}`)) || 0),
|
|
max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsubTable, row, `Max${String(i)}`)) || 0),
|
|
levels,
|
|
})
|
|
}
|
|
})
|
|
|
|
// --- Milestone M1: Dynamic Dimensions ---
|
|
describe('Milestone M1 Parity: Dynamic Level Dimensions (R1)', () => {
|
|
it('generates 96x56 horizontal Moor for Seed 0x12345678', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 0x12345678)
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
expect(result.level.width).toBe(d2moo.width)
|
|
expect(result.level.height).toBe(d2moo.height)
|
|
})
|
|
|
|
it('generates 56x96 vertical Moor for Seed 1', () => {
|
|
const d2moo = loadOrRunD2MooLevel(2, 1)
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 1,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
expect(result.level.width).toBe(d2moo.width)
|
|
expect(result.level.height).toBe(d2moo.height)
|
|
})
|
|
})
|
|
|
|
// --- Milestone M2: Outer River Placement ---
|
|
describe('Milestone M2 Parity: Outer River Placement & Boundary Replacement (R2)', () => {
|
|
it('spawns river along eastern boundary replacing outer tree border for Seed 0x12345678', () => {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
// River tiles on east edge: x in [80..95] contains water floors
|
|
let hasWaterTile = false
|
|
const maxX = result.level.width
|
|
const maxY = result.level.height
|
|
|
|
for (let y = 8; y < maxY - 8; y += 1) {
|
|
for (let x = Math.max(0, maxX - 16); x < maxX; x += 1) {
|
|
const row = result.level.cells[y]
|
|
if (row?.[x]?.floors.some(f => f.style === 2)) {
|
|
hasWaterTile = true
|
|
break
|
|
}
|
|
}
|
|
if (hasWaterTile) break
|
|
}
|
|
|
|
expect(hasWaterTile).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Milestone M4: Town Transition Alignment ---
|
|
describe('Milestone M4 Parity: Town Transition Alignment (R4)', () => {
|
|
it('aligns TownSTrans gate to river edge for Seed 0x12345678', () => {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
const notes = result.stats.notes ?? []
|
|
const hasTownTrans = notes.some(n => /Town.*Trans/i.test(n))
|
|
expect(hasTownTrans).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Milestone M4: Unconditional Landmarks ---
|
|
describe('Milestone M4 Parity: Unconditional Landmark Presets & FarAway (R4)', () => {
|
|
it('places all 5 canonical landmark categories in Blood Moor (Seed 0x12345678)', () => {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
const notes = (result.stats.notes ?? []).join('\n')
|
|
expect(/DOE|Den/i.test(notes)).toBe(true)
|
|
expect(/Pond/i.test(notes)).toBe(true)
|
|
expect(/Cottage|Bivouac/i.test(notes)).toBe(true)
|
|
expect(/Circle|Stone/i.test(notes)).toBe(true)
|
|
expect(/Clover/i.test(notes)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Milestone M3: Secondary Borders & 2x3 Forest Closures ---
|
|
describe('Milestone M3 Parity: Secondary Borders & 2x3 Forest Closures (R3)', () => {
|
|
it('spawns Bord5..8 peninsulas and Bord9..12 interior 2x3 closures for Seed 0x12345678', () => {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
const notes = (result.stats.notes ?? []).join('\n')
|
|
// Must record secondary border peninsulas and interior 2x3 closures
|
|
expect(/Bord[5678]|peninsula/i.test(notes)).toBe(true)
|
|
expect(/Bord(9|10|11|12)|2x3|closure/i.test(notes)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// --- Milestone M5: Orthogonal Dirt Road Rasterization ---
|
|
describe('Milestone M5 Parity: Orthogonal Dirt Paths & Width-2 Bresenham (R5)', () => {
|
|
it('generates orthogonal width-2 dirt road without spline loop deviations', () => {
|
|
const result: WildernessResult = generateWilderness({
|
|
levelId: 2,
|
|
levelName: 'Blood Moor',
|
|
levelTypeName: 'Act 1 - Wilderness',
|
|
sizeX: 80,
|
|
sizeY: 80,
|
|
subType: 0,
|
|
subTheme: 0,
|
|
seed: 0x12345678,
|
|
pieces: act1Pieces,
|
|
substitutions: act1Subs,
|
|
})
|
|
|
|
// Dirt road floor tiles in Act 1 use style 0 with sequence 1..46 (byte_6FDCF958 LUT)
|
|
let dirtTileCount = 0
|
|
for (const row of result.level.cells) {
|
|
for (const cellRecord of row) {
|
|
if (cellRecord.floors.some(f => (f.style === 0 && f.sequence >= 1 && f.sequence <= 46) || (f.style === 1 && f.prop1 === 194))) {
|
|
dirtTileCount += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parity expectation: road connects town gate to den and exit with width-2 footprint (>= 100 tiles)
|
|
expect(dirtTileCount).toBeGreaterThanOrEqual(100)
|
|
})
|
|
})
|
|
})
|