diablo2-web/tests/wilderness-roads.test.ts

240 lines
9.3 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, resolveLevelLibraries } from '../src/game/acts.ts'
import type { ActTables, D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { buildIsoMapScene, levelSeed } from '../src/game/d2map.ts'
import {
generateWilderness,
DIRT_PATH_TILE_LUT,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe.skipIf(!hasD2)('Act 1 Wilderness Dirt Road & Preset Generation (Steps 1-4)', () => {
let archives: MountedArchives
let tables: ActTables
let lvlsubTable: D2Table
let act1Pieces: WildernessPiece[]
let act1Subs: WildernessSubstitution[]
const dt1Cache = new Map<string, Dt1>()
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],
})
}
// Pre-cache DT1 libraries for Act 1 wilderness levels
for (const id of [2, 3, 4, 5, 6, 7, 17]) {
const libraries = resolveLevelLibraries(tables, id)
for (const n of libraries.dt1Names) {
if (!dt1Cache.has(n)) {
dt1Cache.set(n, decodeDt1(await archives.read(n)))
}
}
}
}, 30000)
it('DIRT_PATH_TILE_LUT matches exact D2MOO 8-neighbor bitmask tile sequences', () => {
expect(DIRT_PATH_TILE_LUT.length).toBe(256)
// Straight horizontal road top edge (West + East + South neighbors = 0x6B -> 0x11 = 17)
expect(DIRT_PATH_TILE_LUT[0x6b]).toBe(0x11)
// Straight horizontal road bottom edge (West + East + North neighbors = 0xD6 -> 0x16 = 22)
expect(DIRT_PATH_TILE_LUT[0xd6]).toBe(0x16)
// Straight vertical road left edge (North + South + East neighbors = 0xF8 -> 0x18 = 24)
expect(DIRT_PATH_TILE_LUT[0xf8]).toBe(0x18)
// Straight vertical road right edge (North + South + West neighbors = 0x1F -> 0x14 = 20)
expect(DIRT_PATH_TILE_LUT[0x1f]).toBe(0x14)
})
it('places directional borders (Border 1..8) around the level perimeter', () => {
const res = generateWilderness({
levelId: 2,
levelName: 'Act 1 - Wilderness 1',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x123456,
pieces: act1Pieces,
substitutions: [],
})
const usage = res.stats.borderUsage as Record<string, number>
// Corners: Border 5 (BL), Border 6 (TL), Border 7 (TR), Border 8 (BR).
//
// Counted by slot number rather than by exact name. Act 1's north, west and
// three of its corners prefer rock cliff pieces over wooden fence ones
// (`Act 1 - Wild Cliff Border 6A`), and the cliff family also carries
// lettered variants — all of which are still "the NW corner piece". What
// must hold is that each corner block is stamped exactly once; stamping
// twice is what leaves a corner with two overlapping silhouettes.
const slotUsage = (slot: number): number => {
const pattern = new RegExp(`\\bBorder\\s*0?${String(slot)}[A-Z]?$`, 'i')
let total = 0
for (const [name, count] of Object.entries(usage)) {
if (pattern.test(name)) total += count
}
return total
}
expect(slotUsage(5)).toBe(1)
expect(slotUsage(6)).toBe(1)
expect(slotUsage(7)).toBe(1)
expect(slotUsage(8)).toBe(1)
// Edges: Border 1 (Bottom), Border 2 (Left), Border 3 (Top), Border 4 (Right)
expect(slotUsage(1)).toBeGreaterThan(0)
expect(slotUsage(2)).toBeGreaterThan(0)
expect(slotUsage(3)).toBeGreaterThan(0)
expect(slotUsage(4)).toBeGreaterThan(0)
})
it.each([
{ id: 2, name: 'Act 1 - Wilderness 1', expectedPreset: 'Act 1 - DOE Entrance' },
{ id: 3, name: 'Act 1 - Wilderness 2', expectedPreset: 'Act 1 - Cave Entrance' },
{ id: 4, name: 'Act 1 - Wilderness 3', expectedPreset: 'Act 1 - Cairn Stones' },
{ id: 5, name: 'Act 1 - Wilderness 4', expectedPreset: 'Act 1 - Inifus' },
{ id: 6, name: 'Act 1 - Wilderness 5', expectedPreset: 'Act 1 - Tower 1' },
{ id: 7, name: 'Act 1 - Wilderness 6', expectedPreset: 'Act 1 - Cave Entrance' },
{ id: 17, name: 'Act 1 - Graveyard', expectedPreset: 'Act 1 - Graveyard' },
])('generates connected dirt roads and special presets for $name (id=$id)', async ({ id, name, expectedPreset }) => {
const levelRow = tables.levels.rows.find(r => Number(cell(tables.levels, r, 'Id')) === id)!
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subTheme = Math.max(0, Number(cell(tables.levels, levelRow, 'SubTheme')))
const subs = subType === 0 ? act1Subs : []
const res = generateWilderness({
levelId: id,
levelName: name,
levelTypeName: 'Act 1 - Wilderness',
sizeX: Number(cell(tables.levels, levelRow, 'SizeX')),
sizeY: Number(cell(tables.levels, levelRow, 'SizeY')),
subType,
subTheme,
seed: 0x5eed_1000 + id,
pieces: act1Pieces,
substitutions: subs,
})
// Verify road stats
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
expect(Number(res.stats.roadSegments)).toBeGreaterThan(0)
expect(Number(res.stats.anchors)).toBeGreaterThanOrEqual(2)
// Verify special preset placement
const presets = res.stats.specialPresets as string[]
expect(presets).toContain(expectedPreset)
// Count actual dirt road tiles (style 0, sequence 1..46) on canvas floor[0]
let dirtTileCount = 0
for (let y = 0; y < res.level.height; y += 1) {
for (let x = 0; x < res.level.width; x += 1) {
const floor = res.level.cells[y]?.[x]?.floors[0]
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
dirtTileCount += 1
}
}
}
expect(dirtTileCount).toBeGreaterThan(30)
// Verify DT1 tile resolvability (0 missing tiles)
const libraries = resolveLevelLibraries(tables, id)
const dt1s: Dt1[] = libraries.dt1Names.map(n => dt1Cache.get(n)!)
const scene = buildIsoMapScene(res.level, dt1s, levelSeed('test'))
expect(scene.missingTiles).toBe(0)
}, 15000)
it('safely handles out-of-bounds road neighbors without corrupting border bitmasks', () => {
const res = generateWilderness({
levelId: 2,
levelName: 'Act 1 - Wilderness 1',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 0x5eed_2001,
pieces: act1Pieces,
substitutions: [],
})
expect(Number(res.stats.roadCells)).toBeGreaterThan(0)
let dirtRoadTiles = 0
for (let y = 0; y < res.level.height; y += 1) {
for (let x = 0; x < res.level.width; x += 1) {
const floor = res.level.cells[y]?.[x]?.floors[0]
if (floor && !floor.hidden && floor.style === 0 && floor.sequence >= 1 && floor.sequence <= 46) {
dirtRoadTiles += 1
}
}
}
expect(dirtRoadTiles).toBeGreaterThanOrEqual(Number(res.stats.roadCells))
})
})