diablo2-web/tests/wilderness-spline-roads.tes...

492 lines
17 KiB
TypeScript

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<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)
})
})
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)
}
}
})
})
})