diablo2-web/tests/wilderness-roads-multi-act....

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