492 lines
17 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|