417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|