diablo2-web/tests/preset.test.ts

604 lines
20 KiB
TypeScript

import { describe, expect, test } from "vitest"
import {
generatePreset,
generateChaosSanctuary,
CHAOS_SANCTUARY_LEVEL_ID,
CHAOS_SANCTUARY_SIZE_X,
CHAOS_SANCTUARY_SIZE_Y,
CHAOS_SANCTUARY_PLACEMENTS,
PRESET_LEVEL_IDS,
PRESET_LEVEL_ID_SET,
ALL_PRESET_LEVEL_IDS,
isPresetLevel,
slicePresetRooms,
createD2Seed,
rollD2Random,
rollD2LimitedRandom,
resolvePickedFileIndex,
resolvePickedFile,
} from "../src/game/preset.ts"
import type { PresetPiece } from "../src/game/preset.ts"
import { generateWilderness } from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, style = 1): 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, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 4,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
describe("Preset Map Pipeline (DrlgType 2)", () => {
test("generatePreset generates Level 108 Chaos Sanctuary stitching 7 cross-wing presets on 120x120 canvas", () => {
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces: PresetPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(24, 24, 10 + idx)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, levels: [ds1] }
})
const result = generatePreset({
levelId: CHAOS_SANCTUARY_LEVEL_ID,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: CHAOS_SANCTUARY_SIZE_X,
sizeY: CHAOS_SANCTUARY_SIZE_Y,
seed: 108,
pieces,
})
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
expect(result.level.act).toBe(4)
expect(result.stats.substitutedClusters).toBe(7)
expect(result.stats.substitutions.length).toBe(7)
expect(result.level.objects.length).toBe(7)
expect(result.stats.groundCells).toBeGreaterThan(0)
// Verify object placement offsets (5 sub-tiles per cell)
// Arm N at (48, 0): object at (48*5 + 5, 0*5 + 5) = (245, 5)
const armNObj = result.level.objects.find(o => o.id === 200)
expect(armNObj).toBeDefined()
expect(armNObj?.x).toBe(48 * 5 + 5)
expect(armNObj?.y).toBe(0 * 5 + 5)
// Arm W at (24, 24): object at (24*5 + 5, 24*5 + 5) = (125, 125)
const armWObj = result.level.objects.find(o => o.id === 201)
expect(armWObj).toBeDefined()
expect(armWObj?.x).toBe(24 * 5 + 5)
expect(armWObj?.y).toBe(24 * 5 + 5)
// Heart at (48, 24): object at (48*5 + 5, 24*5 + 5) = (245, 125)
const heartObj = result.level.objects.find(o => o.id === 202)
expect(heartObj).toBeDefined()
expect(heartObj?.x).toBe(48 * 5 + 5)
expect(heartObj?.y).toBe(24 * 5 + 5)
// Bridge 2 at (48, 96): object at (48*5 + 5, 96*5 + 5) = (245, 485)
const bridgeObj = result.level.objects.find(o => o.id === 206)
expect(bridgeObj).toBeDefined()
expect(bridgeObj?.x).toBe(48 * 5 + 5)
expect(bridgeObj?.y).toBe(96 * 5 + 5)
// Verify tile cell content stamped at origins
expect(result.level.cells[0]?.[48]?.floors[0]?.style).toBe(10) // Arm N style
expect(result.level.cells[24]?.[24]?.floors[0]?.style).toBe(11) // Arm W style
expect(result.level.cells[24]?.[48]?.floors[0]?.style).toBe(12) // Heart style
expect(result.level.cells[96]?.[48]?.floors[0]?.style).toBe(16) // Bridge style
})
test("generatePreset dispatches by levelName when levelId is not 108", () => {
const pieces: PresetPiece[] = CHAOS_SANCTUARY_PLACEMENTS.map((item, idx) => ({
name: item.name,
levels: [makeMockDs1(24, 24, 5)],
}))
const result = generatePreset({
levelId: 0,
levelName: "Act 4 - Chaos Sanctuary",
pieces,
})
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
expect(result.stats.substitutedClusters).toBe(7)
})
test("generateChaosSanctuary matches piece names flexibly (tokens, stripped prefixes, filenames)", () => {
// Pieces with alternative naming styles (e.g. without 'Act 4 - ', or file paths)
const pieces: PresetPiece[] = [
{ name: "Diablo Arm N", levels: [makeMockDs1(24, 24, 1)] },
{ name: "data\\global\\tiles\\act4\\diablo\\ArmW.ds1", levels: [makeMockDs1(24, 24, 2)] },
{ name: "Act 4 - Diablo Heart", levels: [makeMockDs1(24, 24, 3)] },
{ name: "Diablo Arm E", levels: [makeMockDs1(24, 24, 4)] },
{ name: "Arm S", levels: [makeMockDs1(24, 24, 5)] },
{ name: "Diablo Entry", levels: [makeMockDs1(24, 24, 6)] },
{ name: "Bridge 2", levels: [makeMockDs1(24, 24, 7)] },
]
const result = generateChaosSanctuary({
levelId: 108,
pieces,
})
expect(result.stats.substitutedClusters).toBe(7)
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
})
test("generatePreset handles standalone single-DS1 preset levels", () => {
const baseDs1 = makeMockDs1(40, 40, 7)
const mockDs1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 99, x: 10, y: 10, flags: 0 }],
}
const pieces: PresetPiece[] = [
{
name: "Tristram",
levels: [mockDs1],
},
]
const result = generatePreset({
levelId: 38,
levelName: "Tristram",
sizeX: 40,
sizeY: 40,
pieces,
})
expect(result.level.width).toBe(40)
expect(result.level.height).toBe(40)
expect(result.level.objects.length).toBe(1)
expect(result.stats.substitutedClusters).toBe(1)
})
test("generatePreset supports custom piece placements", () => {
const pieces: PresetPiece[] = [
{ name: "RoomA", levels: [makeMockDs1(20, 20, 1)] },
{ name: "RoomB", levels: [makeMockDs1(20, 20, 2)] },
]
const result = generatePreset({
levelId: 999,
sizeX: 60,
sizeY: 60,
pieces,
placements: [
{ name: "RoomA", x: 0, y: 0 },
{ name: "RoomB", x: 30, y: 30 },
],
})
expect(result.level.width).toBe(60)
expect(result.level.height).toBe(60)
expect(result.stats.substitutedClusters).toBe(2)
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(1)
expect(result.level.cells[30]?.[30]?.floors[0]?.style).toBe(2)
})
test("wilderness generator no longer hardcodes Level 108 Chaos Sanctuary special case", () => {
// Wilderness generator does not hijack Level 108 anymore; without special preset routing,
// it will not produce the 7-cluster Chaos Sanctuary layout.
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces = names.map(name => ({
name,
border: false,
levels: [makeMockDs1(24, 24)],
}))
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
// Previously this returned substitutedClusters: 7 via stampAct4ChaosSanctuary.
// Now wilderness treats it as regular wilderness, not the 7-preset Chaos Sanctuary.
expect(result.stats.substitutedClusters).not.toBe(7)
})
})
describe("Canonical Preset Levels (Levels.txt DrlgType == 2)", () => {
test("PRESET_LEVEL_IDS contains all 35 canonical DrlgType == 2 levels across Acts 1-5", () => {
expect(PRESET_LEVEL_IDS.length).toBe(35)
// Act 1 (13)
for (const id of [1, 13, 14, 15, 16, 20, 25, 26, 27, 32, 33, 37, 38]) {
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
}
// Act 2 (3)
for (const id of [40, 50, 73]) {
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
}
// Act 3 (11)
for (const id of [75, 90, 91, 93, 94, 95, 96, 97, 98, 99, 102]) {
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
}
// Act 4 (1)
expect(PRESET_LEVEL_ID_SET.has(103)).toBe(true)
// Act 5 (7)
for (const id of [109, 120, 121, 124, 131, 132, 136]) {
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
}
})
test("ALL_PRESET_LEVEL_IDS includes the 35 canonical levels plus Level 108 Chaos Sanctuary", () => {
expect(ALL_PRESET_LEVEL_IDS.length).toBe(36)
expect(ALL_PRESET_LEVEL_IDS).toContain(108)
for (const id of PRESET_LEVEL_IDS) {
expect(ALL_PRESET_LEVEL_IDS).toContain(id)
}
})
test("isPresetLevel correctly classifies preset vs non-preset level IDs", () => {
// 35 canonical preset levels
for (const id of PRESET_LEVEL_IDS) {
expect(isPresetLevel(id)).toBe(true)
}
// Level 108 Chaos Sanctuary (stitched preset level)
expect(isPresetLevel(108)).toBe(true)
// Non-preset levels
expect(isPresetLevel(2)).toBe(false) // Blood Moor (outdoor)
expect(isPresetLevel(3)).toBe(false) // Cold Plains (outdoor)
expect(isPresetLevel(8)).toBe(false) // Catacombs Level 1 (maze)
expect(isPresetLevel(39)).toBe(false) // Moo Moo Farm (outdoor)
expect(isPresetLevel(0)).toBe(false)
expect(isPresetLevel(-1)).toBe(false)
expect(isPresetLevel(999)).toBe(false)
})
})
describe("8x8 Room Geometry Assembly and Slicing (DRLGPRESET_BuildArea)", () => {
test("slices 16x16 canvas into 4 exact 8x8 rooms", () => {
const rooms = slicePresetRooms(16, 16)
expect(rooms.length).toBe(4)
expect(rooms[0]).toMatchObject({
id: "preset_room_0_0",
x: 0,
y: 0,
width: 8,
height: 8,
gridX: 0,
gridY: 0,
cellX: 0,
cellY: 0,
cellsX: 8,
cellsY: 8,
objects: [],
})
expect(rooms[1]).toMatchObject({
id: "preset_room_1_0",
x: 8,
y: 0,
width: 8,
height: 8,
gridX: 1,
gridY: 0,
})
expect(rooms[2]).toMatchObject({
id: "preset_room_0_1",
x: 0,
y: 8,
width: 8,
height: 8,
gridX: 0,
gridY: 1,
})
expect(rooms[3]).toMatchObject({
id: "preset_room_1_1",
x: 8,
y: 8,
width: 8,
height: 8,
gridX: 1,
gridY: 1,
})
})
test("clamps non-multiple-of-8 canvas boundaries (20x20 canvas -> 9 rooms)", () => {
const rooms = slicePresetRooms(20, 20)
// 20 = 8 + 8 + 4 -> 3 x 3 = 9 rooms
expect(rooms.length).toBe(9)
// Check bottom-right clamped room
const cornerRoom = rooms.find(r => r.gridX === 2 && r.gridY === 2)
expect(cornerRoom).toBeDefined()
expect(cornerRoom?.x).toBe(16)
expect(cornerRoom?.y).toBe(16)
expect(cornerRoom?.width).toBe(4)
expect(cornerRoom?.height).toBe(4)
expect(cornerRoom?.cellsX).toBe(4)
expect(cornerRoom?.cellsY).toBe(4)
// Check edge rooms with 8x4 or 4x8
const topEdgeClamped = rooms.find(r => r.gridX === 2 && r.gridY === 0)
expect(topEdgeClamped?.width).toBe(4)
expect(topEdgeClamped?.height).toBe(8)
const leftEdgeClamped = rooms.find(r => r.gridX === 0 && r.gridY === 2)
expect(leftEdgeClamped?.width).toBe(8)
expect(leftEdgeClamped?.height).toBe(4)
})
test("handles asymmetric canvas (25x13 -> 8 rooms)", () => {
// Width 25: 8 + 8 + 8 + 1 -> 4 columns
// Height 13: 8 + 5 -> 2 rows
// Total: 4 * 2 = 8 rooms
const rooms = slicePresetRooms(25, 13)
expect(rooms.length).toBe(8)
const lastColTopRow = rooms.find(r => r.gridX === 3 && r.gridY === 0)
expect(lastColTopRow?.width).toBe(1)
expect(lastColTopRow?.height).toBe(8)
const lastColBottomRow = rooms.find(r => r.gridX === 3 && r.gridY === 1)
expect(lastColBottomRow?.width).toBe(1)
expect(lastColBottomRow?.height).toBe(5)
})
test("returns empty array for zero or negative dimensions", () => {
expect(slicePresetRooms(0, 0)).toEqual([])
expect(slicePresetRooms(-10, 20)).toEqual([])
expect(slicePresetRooms(20, -5)).toEqual([])
})
test("partitions objects by room boundaries using sub-tile coordinates (5 sub-tiles per cell)", () => {
const objects = [
// In room (0, 0): cell (2, 3) -> sub-tiles (10, 15)
{ type: 2, id: 1, x: 10, y: 15, flags: 0 },
// In room (1, 0): cell (9, 2) -> sub-tiles (45, 10)
{ type: 2, id: 2, x: 45, y: 10, flags: 0 },
// In room (0, 1): cell (1, 10) -> sub-tiles (5, 50)
{ type: 2, id: 3, x: 5, y: 50, flags: 0 },
// In room (1, 1): cell (15, 15) -> sub-tiles (75, 75)
{ type: 2, id: 4, x: 75, y: 75, flags: 0 },
// Outside canvas: cell (25, 25) -> sub-tiles (125, 125)
{ type: 2, id: 5, x: 125, y: 125, flags: 0 },
]
const rooms = slicePresetRooms(16, 16, objects)
expect(rooms.length).toBe(4)
const room00 = rooms.find(r => r.gridX === 0 && r.gridY === 0)
expect(room00?.objects.map(o => o.id)).toEqual([1])
const room10 = rooms.find(r => r.gridX === 1 && r.gridY === 0)
expect(room10?.objects.map(o => o.id)).toEqual([2])
const room01 = rooms.find(r => r.gridX === 0 && r.gridY === 1)
expect(room01?.objects.map(o => o.id)).toEqual([3])
const room11 = rooms.find(r => r.gridX === 1 && r.gridY === 1)
expect(room11?.objects.map(o => o.id)).toEqual([4])
})
test("supports direct cell coordinate objects when cellCoords option is enabled", () => {
const objects = [
{ type: 2, id: 10, x: 4, y: 4, flags: 0 },
{ type: 2, id: 20, x: 12, y: 4, flags: 0 },
]
const rooms = slicePresetRooms(16, 16, objects, { cellCoords: true })
const r00 = rooms.find(r => r.gridX === 0 && r.gridY === 0)
const r10 = rooms.find(r => r.gridX === 1 && r.gridY === 0)
expect(r00?.objects.map(o => o.id)).toEqual([10])
expect(r10?.objects.map(o => o.id)).toEqual([20])
})
})
describe("1.13c D2Seed PRNG and Multi-File Variant Selection (DRLGPRESET_AllocDrlgMap)", () => {
test("createD2Seed initializes low and default high = 666 matching 1.13c SEED_InitLowSeed", () => {
const seed = createD2Seed(12345)
expect(seed.low).toBe(12345)
expect(seed.high).toBe(666)
})
test("rollD2Random advances seed deterministically with 0x6AC690C5 multiplier", () => {
const seed = createD2Seed(100)
const roll1 = rollD2Random(seed)
// 666 + 0x6AC690C5 * 100 = 179139809166
// low 32 bits = 179139809166 % 2^32 = 3046150030
// high 32 bits = 179139809166 >> 32 = 41
expect(roll1).toBe(3046150030)
expect(seed.low).toBe(3046150030)
expect(seed.high).toBe(41)
// Second roll
const roll2 = rollD2Random(seed)
expect(typeof roll2).toBe("number")
expect(roll2).not.toBe(roll1)
})
test("rollD2LimitedRandom implements power-of-2 bitmask and non-power-of-2 modulo branches", () => {
// Power of 2 (max = 4): uses roll & 3
const p2Roll = rollD2LimitedRandom(100, 4)
expect(p2Roll).toBeGreaterThanOrEqual(0)
expect(p2Roll).toBeLessThan(4)
// Non power of 2 (max = 3): uses roll % 3
const np2Roll = rollD2LimitedRandom(100, 3)
expect(np2Roll).toBeGreaterThanOrEqual(0)
expect(np2Roll).toBeLessThan(3)
// max <= 0 returns 0
expect(rollD2LimitedRandom(100, 0)).toBe(0)
expect(rollD2LimitedRandom(100, -5)).toBe(0)
// max = 1 returns 0
expect(rollD2LimitedRandom(100, 1)).toBe(0)
})
test("resolvePickedFileIndex picks deterministic variant indices", () => {
const idxA1 = resolvePickedFileIndex(4, 42)
const idxA2 = resolvePickedFileIndex(4, 42)
expect(idxA1).toBe(idxA2)
const idxB = resolvePickedFileIndex(4, 99999)
expect(idxA1).toBeGreaterThanOrEqual(0)
expect(idxA1).toBeLessThan(4)
expect(idxB).toBeGreaterThanOrEqual(0)
expect(idxB).toBeLessThan(4)
})
test("resolvePickedFile returns matching element or undefined for empty array", () => {
const variants = ["fileA.ds1", "fileB.ds1", "fileC.ds1"]
const picked = resolvePickedFile(variants, 12345)
expect(variants).toContain(picked)
expect(resolvePickedFile([], 12345)).toBeUndefined()
})
})
describe("Preset Generation Room Integration and Variant Resolution", () => {
test("generateChaosSanctuary attaches 225 sliced 8x8 rooms and partitions wing objects", () => {
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Bridge 2",
]
const pieces: PresetPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(24, 24, 10 + idx)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, levels: [ds1] }
})
const result = generateChaosSanctuary({
levelId: CHAOS_SANCTUARY_LEVEL_ID,
pieces,
})
// 120 / 8 = 15 -> 15 x 15 = 225 rooms
expect(result.rooms?.length).toBe(225)
expect(result.stats.rooms).toBe(225)
// Arm N object at (48, 0) in cells -> cell (49, 1) -> room (gridX: 6, gridY: 0)
const armNRoom = result.rooms?.find(r => r.gridX === 6 && r.gridY === 0)
expect(armNRoom?.objects.some(o => o.id === 300)).toBe(true)
})
test("generatePreset resolves multi-level variants and records nPickedFile", () => {
const variant0 = makeMockDs1(20, 20, 1)
const variant1 = makeMockDs1(20, 20, 2)
const variant2 = makeMockDs1(20, 20, 3)
const pieces: PresetPiece[] = [
{
name: "MultiVariantLevel",
levels: [variant0, variant1, variant2],
},
]
const seed = 54321
const expectedIndex = resolvePickedFileIndex(3, seed)
const result = generatePreset({
levelId: 38,
levelName: "MultiVariantLevel",
seed,
pieces,
})
expect(result.stats.nPickedFile).toBe(expectedIndex)
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(expectedIndex + 1)
expect(result.rooms).toBeDefined()
expect(result.rooms?.length).toBe(9) // 20x20 -> 3x3 = 9 rooms
expect(result.stats.rooms).toBe(9)
})
test("generatePreset resolves multi-piece candidates and records nPickedFile", () => {
const pieces: PresetPiece[] = [
{ name: "VariantPiece_0", levels: [makeMockDs1(16, 16, 1)] },
{ name: "VariantPiece_1", levels: [makeMockDs1(16, 16, 2)] },
{ name: "VariantPiece_2", levels: [makeMockDs1(16, 16, 3)] },
{ name: "VariantPiece_3", levels: [makeMockDs1(16, 16, 4)] },
]
const seed = 777
const expectedIndex = resolvePickedFileIndex(4, seed)
const result = generatePreset({
levelId: 14,
levelName: "CountessTower",
seed,
pieces,
})
expect(result.stats.nPickedFile).toBe(expectedIndex)
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(expectedIndex + 1)
expect(result.rooms?.length).toBe(4) // 16x16 -> 2x2 = 4 rooms
})
test("generatePreset stamps onto canvas when requested size differs from DS1", () => {
const ds1 = makeMockDs1(16, 16, 1)
const pieces: PresetPiece[] = [{ name: "SmallPiece", levels: [ds1] }]
const result = generatePreset({
levelId: 33,
sizeX: 32,
sizeY: 24,
pieces,
})
expect(result.level.width).toBe(32)
expect(result.level.height).toBe(24)
// 32/8 = 4 columns, 24/8 = 3 rows -> 12 rooms
expect(result.rooms?.length).toBe(12)
expect(result.stats.rooms).toBe(12)
})
})