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