240 lines
9.3 KiB
TypeScript
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))
|
|
})
|
|
})
|