import { describe, it, expect, beforeAll } from 'vitest' import * as fs from '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 { ActTables } from '../src/game/acts.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import { Rng } from '../src/game/rng.ts' import { catmullRomPoint, sampleCatmullRomSpline, createDrlgVertices, DRLGVER_CreateVertices, buildOutdoorRoadPolylines, buildRoadNetworkPolylines, rasterizeDirtPathSegment, generateDirtPaths, createCanvas, generateWilderness, DIRT_PATH_TILE_LUT, TILES_PER_BLOCK, } from '../src/game/wilderness.ts' import type { Act1OutdoorPlan, DrlgVertex, WildernessPiece, WildernessSubstitution, } from '../src/game/wilderness.ts' const hasD2 = fs.existsSync('samples/d2/d2data.mpq') 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() 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) }) }) describe('Dirt Path Rasterization & Obstacle Clearance', () => { it('rasterizes 2-3 cell wide continuous dirt road onto canvas', () => { const canvas = createCanvas(80, 80, 1, 1, 1) const polylines = [ [ { x: 10, y: 20 }, { x: 20, y: 22 }, { x: 30, y: 25 }, { x: 40, y: 30 }, { x: 50, y: 38 }, ], ] const roadRes = generateDirtPaths(canvas, polylines) expect(roadRes.roadCells).toBeGreaterThan(20) expect(roadRes.roadSegments).toBe(polylines[0]!.length - 1) // Check canvas floors contain road styles (style 0, sequences 1..46) let stampedRoadFloors = 0 for (let y = 0; y < 80; y += 1) { for (let x = 0; x < 80; x += 1) { const floor = canvas.cells[y]?.[x]?.floors[0] if (floor && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) { stampedRoadFloors += 1 } } } expect(stampedRoadFloors).toBe(roadRes.roadCells) }) it('strictly avoids painting dirt road cells inside roadObstacleBlocks', () => { const canvas = createCanvas(80, 80, 1, 1, 1) const roadObstacleBlocks = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => false), ) // Block block (3, 3) -> cells [24..31, 24..31] roadObstacleBlocks[3]![3] = true // Polyline that passes near or through block 3 const polylines = [ [ { x: 10, y: 28 }, { x: 26, y: 28 }, { x: 45, y: 28 }, ], ] generateDirtPaths(canvas, polylines, roadObstacleBlocks) // Verify no cell in block (3, 3) was stamped with dirt road for (let y = 24; y < 32; y += 1) { for (let x = 24; x < 32; x += 1) { const floor = canvas.cells[y]?.[x]?.floors[0] expect(floor?.sequence).not.toBeGreaterThanOrEqual(1) } } }) it('correctly maps 8-neighbor bitmask to DIRT_PATH_TILE_LUT sequence', () => { const grid = new Uint8Array(20 * 20) // Place a cross of dirt road cells: // (10, 10), (10, 9), (10, 11), (9, 10), (11, 10) grid[10 * 20 + 10] = 1 grid[9 * 20 + 10] = 1 grid[11 * 20 + 10] = 1 grid[10 * 20 + 9] = 1 grid[10 * 20 + 11] = 1 // Compute bitmask for center (10, 10): // top (10, 9) = bit 4 (0x10) // bottom (10, 11) = bit 3 (0x08) // left (9, 10) = bit 1 (0x02) // right (11, 10) = bit 6 (0x40) const expectedMask = 0x10 | 0x08 | 0x02 | 0x40 const expectedSeq = DIRT_PATH_TILE_LUT[expectedMask] expect(expectedSeq).toBeGreaterThan(0) }) }) describe.skipIf(!hasD2)('End-to-End Spline Roads in Act 1 Wilderness Generation', () => { let archives: MountedArchives let tables: ActTables let lvlsubTable: any let act1Pieces: WildernessPiece[] let act1Subs: WildernessSubstitution[] beforeAll(async () => { archives = new MountedArchives() for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) } tables = await loadActTables(archives) lvlsubTable = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) const families = [ 'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance', 'Act 1 - DOE Entrance', 'Act 1 - Cairn Stones', 'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Graveyard', 'Act 1 - Bivouac', 'Act 1 - Fallen Camp', 'Act 1 - Cottages', 'Act 1 - Pond', 'Act 1 - Camp', 'Act 1 - Ruin', 'Act 1 - Stone Fill', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill', 'Act 1 - Swamp Fill', 'Act 1 - Tree Fill', ] act1Pieces = [] for (const row of tables.lvlprest.rows) { const name = cell(tables.lvlprest, row, 'Name') if (!families.some(f => name.startsWith(f))) continue const levels = [] for (let i = 1; i <= 6; i += 1) { const f = cell(tables.lvlprest, row, `File${String(i)}`) if (f && f !== '0') { levels.push(decodeDs1(await archives.read(tileMemberPath(f)))) } } if (levels.length > 0) { act1Pieces.push({ name, levels, border: /\bBorder\b/i.test(name) }) } } act1Subs = [] for (const row of lvlsubTable.rows) { if (Number(cell(lvlsubTable, row, 'Type')) !== 0) continue const file = cell(lvlsubTable, row, 'File') if (!file || file === '0') continue const ds1 = decodeDs1(await archives.read(tileMemberPath(file))) const vals = (prefix: string) => [0, 1, 2, 3, 4].map(idx => Number(cell(lvlsubTable, row, `${prefix}${String(idx)}`)) || 0) act1Subs.push({ name: cell(lvlsubTable, row, 'Name'), type: 0, gridSize: Number(cell(lvlsubTable, row, 'GridSize')) || 1, bordType: Number(cell(lvlsubTable, row, 'BordType')), dt1Mask: Number(cell(lvlsubTable, row, 'Dt1Mask')) || 0, prob: vals('Prob'), trials: vals('Trials'), max: vals('Max'), levels: [ds1], }) } }, 30000) it('populates drlgVertices with gates, hub, and special preset anchors', () => { const res = generateWilderness({ levelId: 2, // Blood Moor (Den of Evil) levelName: 'Blood Moor', levelTypeName: 'Act 1 - Wilderness', sizeX: 80, sizeY: 80, subType: 0, subTheme: 0, seed: 0x5eed_0002, pieces: act1Pieces, substitutions: act1Subs, }) expect(res.stats.drlgVertices).toBeDefined() const vertices = res.stats.drlgVertices as DrlgVertex[] expect(vertices.length).toBeGreaterThanOrEqual(3) const vertexTypes = vertices.map(v => v.type) expect(vertexTypes).toContain('hub') expect(vertexTypes).toContain('gate') // Blood Moor has Den of Evil preset expect(vertexTypes).toContain('preset') expect(res.stats.roadPolylines).toBeDefined() const polylines = res.stats.roadPolylines as { x: number; y: number }[][] expect(polylines.length).toBeGreaterThanOrEqual(2) // Verify roadCells and dirt road floor stamping expect(Number(res.stats.roadCells)).toBeGreaterThan(0) }) it('generates smooth spline road in Stony Field connecting Cairn Stones to Gates', () => { const res = generateWilderness({ levelId: 4, // Stony Field (Cairn Stones) levelName: 'Stony Field', levelTypeName: 'Act 1 - Wilderness', sizeX: 80, sizeY: 80, subType: 0, subTheme: 0, seed: 0x5eed_0004, pieces: act1Pieces, substitutions: act1Subs, }) const vertices = res.stats.drlgVertices as DrlgVertex[] expect(vertices).toBeDefined() const cairnVertex = vertices.find(v => v.label?.includes('Cairn Stones') || v.type === 'preset') expect(cairnVertex).toBeDefined() const hubVertex = vertices.find(v => v.type === 'hub') expect(hubVertex).toBeDefined() // Spline polylines connect to hub const polylines = res.stats.roadPolylines as { x: number; y: number }[][] expect(polylines.length).toBeGreaterThanOrEqual(2) // Verify all polyline points are within map bounds for (const line of polylines) { for (const pt of line) { expect(pt.x).toBeGreaterThanOrEqual(0) expect(pt.x).toBeLessThan(res.level.width) expect(pt.y).toBeGreaterThanOrEqual(0) expect(pt.y).toBeLessThan(res.level.height) } } }) }) })