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