diff --git a/src/game/preset.ts b/src/game/preset.ts index 0ad0dbe..23d8d09 100644 --- a/src/game/preset.ts +++ b/src/game/preset.ts @@ -23,6 +23,56 @@ export const CHAOS_SANCTUARY_SIZE_X = 120 /** Standard canvas height for Act 4 Chaos Sanctuary in cells. */ export const CHAOS_SANCTUARY_SIZE_Y = 120 +/** + * Complete canonical set of 35 preset level IDs defined in `Levels.txt` with `DrlgType == 2`: + * + * - Act 1 (13): 1 (Rogue Encampment), 13 (Cave 2), 14 (Underground Passage 2), + * 15 (Hole 2), 16 (Pit 2), 20 (Forgotten Tower), 25 (Tower Cellar 5), + * 26 (Monastery Gate), 27 (Outer Cloister), 32 (Inner Cloister), + * 33 (Cathedral), 37 (Catacombs 4), 38 (Tristram). + * - Act 2 (3): 40 (Lut Gholein), 50 (Harem 1), 73 (Duriel's Lair). + * - Act 3 (11): 75 (Kurast Docktown), 90 (Swampy Pit 3), 91 (Flayer Dungeon 3), + * 93 (Sewers 2), 94..99 (Ruined Temple, Disused Fane, Forgotten Reliquary, + * Forgotten Temple, Ruined Fane, Disused Reliquary), 102 (Durance of Hate 3). + * - Act 4 (1): 103 (The Pandemonium Fortress). + * (Note: Level 108 Chaos Sanctuary is assembled via cross-wing presets, while having DrlgType == 3 in Levels.txt). + * - Act 5 (7): 109 (Harrogath), 120 (Rocky Summit / Arreat Summit), + * 121 (Nihlathaks Temple), 124 (Halls of Vaught), 131 (Throne of Destruction), + * 132 (The Worldstone Chamber), 136 (Tristram / Pandemonium Finale). + */ +export const PRESET_LEVEL_IDS: readonly number[] = [ + // Act 1 (13) + 1, 13, 14, 15, 16, 20, 25, 26, 27, 32, 33, 37, 38, + // Act 2 (3) + 40, 50, 73, + // Act 3 (11) + 75, 90, 91, 93, 94, 95, 96, 97, 98, 99, 102, + // Act 4 (1) + 103, + // Act 5 (7) + 109, 120, 121, 124, 131, 132, 136, +] as const + +/** Set of canonical DrlgType == 2 preset level IDs for O(1) membership check. */ +export const PRESET_LEVEL_ID_SET: ReadonlySet = new Set(PRESET_LEVEL_IDS) + +/** All preset level IDs handled by the preset pipeline, including Level 108 Chaos Sanctuary. */ +export const ALL_PRESET_LEVEL_IDS: readonly number[] = [ + ...PRESET_LEVEL_IDS.filter(id => id < 108), + CHAOS_SANCTUARY_LEVEL_ID, + ...PRESET_LEVEL_IDS.filter(id => id > 108), +] as const + +/** + * Checks whether a level is handled by the preset level generator. + * Returns true for all 35 canonical DrlgType == 2 levels and Level 108 Chaos Sanctuary. + * + * @param levelId - `Levels.txt` level Id. + */ +export function isPresetLevel(levelId: number): boolean { + return levelId === CHAOS_SANCTUARY_LEVEL_ID || PRESET_LEVEL_ID_SET.has(levelId) +} + /** One `LvlPrest.txt` piece or DS1 preset available for stamping. */ export interface PresetPiece { /** Name of the preset piece (e.g. from LvlPrest.txt Name or DS1 file name). */ @@ -75,6 +125,191 @@ export interface PresetRequest { readonly substitutions?: readonly unknown[] | undefined } +/** + * A discrete 8x8 cell room or sector in a preset level. + * Corresponds to 1.13c D2Common `DRLGPRESET_BuildArea` room allocations. + */ +export interface PresetRoom { + /** Identifier for this room (e.g. "preset_room_0_0"). */ + readonly id: string | number + /** Top-left cell X on preset canvas. */ + readonly x: number + /** Top-left cell Y on preset canvas. */ + readonly y: number + /** Width in cells (normally 8, clipped at canvas boundary). */ + readonly width: number + /** Height in cells (normally 8, clipped at canvas boundary). */ + readonly height: number + /** Grid column index (x / 8). */ + readonly gridX: number + /** Grid row index (y / 8). */ + readonly gridY: number + /** Grid cell coordinate X (alias for x for MapRoom compatibility). */ + readonly cellX: number + /** Grid cell coordinate Y (alias for y for MapRoom compatibility). */ + readonly cellY: number + /** Width in cells (alias for width for MapRoom compatibility). */ + readonly cellsX: number + /** Height in cells (alias for height for MapRoom compatibility). */ + readonly cellsY: number + /** Objects placed within this room. */ + readonly objects: readonly Ds1Object[] +} + +/** Configuration options for {@link slicePresetRooms}. */ +export interface SlicePresetRoomsOptions { + /** + * If true, treats object coordinates directly as cell coordinates rather than + * sub-tiles (5 sub-tiles per cell). Default is false (standard DS1 sub-tiles). + */ + readonly cellCoords?: boolean | undefined +} + +/** + * Slices any preset canvas into discrete 8x8 cell PresetRooms, assigning local + * bounds and partitioning objects by their room. + * + * Matches 1.13c DrlgPreset.cpp DRLGPRESET_BuildArea: + * ```cpp + * const int nXEnd = pDrlgMap->pDrlgCoord.nPosX + pDrlgMap->pDrlgCoord.nWidth; + * const int nYEnd = pDrlgMap->pDrlgCoord.nPosY + pDrlgMap->pDrlgCoord.nHeight; + * for (int nY = pDrlgMap->pDrlgCoord.nPosY; nY < nYEnd; nY += 8) { + * for (int nX = pDrlgMap->pDrlgCoord.nPosX; nX < nXEnd; nX += 8) { + * // 8x8 tile room allocation (clipped at border) + * } + * } + * ``` + * + * @param width - Canvas width in cells. + * @param height - Canvas height in cells. + * @param objects - Objects placed on canvas (coordinates in sub-tiles by default). + * @param options - Optional configuration flags. + * @returns Array of 8x8 PresetRoom descriptors covering the canvas. + */ +export function slicePresetRooms( + width: number, + height: number, + objects: readonly Ds1Object[] = [], + options?: SlicePresetRoomsOptions, +): readonly PresetRoom[] { + if (width <= 0 || height <= 0) return [] + + const rooms: PresetRoom[] = [] + let gridY = 0 + for (let y = 0; y < height; y += 8) { + const roomHeight = Math.min(8, height - y) + let gridX = 0 + for (let x = 0; x < width; x += 8) { + const roomWidth = Math.min(8, width - x) + + const roomObjects: Ds1Object[] = [] + for (const obj of objects) { + const ox = options?.cellCoords ? Math.floor(obj.x) : Math.floor(obj.x / SUB_TILES_PER_TILE) + const oy = options?.cellCoords ? Math.floor(obj.y) : Math.floor(obj.y / SUB_TILES_PER_TILE) + if (ox >= x && ox < x + roomWidth && oy >= y && oy < y + roomHeight) { + roomObjects.push(obj) + } + } + + rooms.push({ + id: `preset_room_${gridX}_${gridY}`, + x, + y, + width: roomWidth, + height: roomHeight, + gridX, + gridY, + cellX: x, + cellY: y, + cellsX: roomWidth, + cellsY: roomHeight, + objects: roomObjects, + }) + + gridX += 1 + } + gridY += 1 + } + + return rooms +} + +/** + * Seed structure matching 1.13c D2SeedStrc. + */ +export interface D2Seed { + low: number + high: number +} + +/** + * Initializes a 1.13c D2SeedStrc state matching SEED_InitLowSeed. + * + * @param seed - Initial seed value (low 32-bits). + * @param high - Initial high seed value (defaults to 666 matching 1.13c SEED_InitLowSeed). + */ +export function createD2Seed(seed: number, high = 666): D2Seed { + return { + low: seed >>> 0, + high: high >>> 0, + } +} + +/** + * Advances seed and returns 32-bit pseudo-random roll matching 1.13c SEED_RollRandomNumber: + * `uint64_t lSeed = pSeed->nHighSeed + 0x6AC690C5i64 * pSeed->nLowSeed;` + */ +export function rollD2Random(seedState: D2Seed): number { + const lSeed = BigInt(seedState.high) + 0x6ac690c5n * BigInt(seedState.low) + seedState.low = Number(lSeed & 0xffffffffn) + seedState.high = Number((lSeed >> 32n) & 0xffffffffn) + return seedState.low +} + +/** + * Rolls a limited random number in `[0, max - 1]` matching 1.13c SEED_RollLimitedRandomNumber: + * ```cpp + * if (nMax > 0) { + * if ((nMax - 1) & nMax) return (unsigned int)SEED_RollRandomNumber(pSeed) % nMax; + * else return SEED_RollRandomNumber(pSeed) & (nMax - 1); + * } + * return 0; + * ``` + */ +export function rollD2LimitedRandom(seedState: D2Seed | number, max: number): number { + if (max <= 0) return 0 + const state = typeof seedState === 'number' ? createD2Seed(seedState) : seedState + const roll = rollD2Random(state) + if (((max - 1) & max) === 0) { + return roll & (max - 1) + } + return roll % max +} + +/** + * Resolves 0-based picked file index (nPickedFile) for a preset piece from + * `LvlPrest.txt` `File1..File6` variants and level seed, matching 1.13c DRLGPRESET_AllocDrlgMap. + * + * In 1.13c DrlgPreset.cpp: + * `pDrlgMap->nPickedFile = SEED_RollLimitedRandomNumber(pSeed, pDrlgMap->pLvlPrestTxtRecord->dwFiles);` + * + * @param filesCount - Number of available file variants (`LvlPrest.txt.Files` or variants length). + * @param seed - Level seed or D2Seed state. + * @returns 0-based index of picked file variant. + */ +export function resolvePickedFileIndex(filesCount: number, seed: number | D2Seed): number { + return rollD2LimitedRandom(seed, filesCount) +} + +/** + * Resolves the picked file variant from an array of file candidates or DS1s. + */ +export function resolvePickedFile(files: readonly T[], seed: number | D2Seed): T | undefined { + if (files.length === 0) return undefined + const index = resolvePickedFileIndex(files.length, seed) + return files[index] +} + /** Statistics and metadata returned from preset map generation. */ export interface PresetStats { readonly levelId: number @@ -91,6 +326,8 @@ export interface PresetStats { readonly borderUsage?: Readonly> | undefined readonly unresolved?: readonly unknown[] | undefined readonly notes?: readonly string[] | undefined + readonly nPickedFile?: number | undefined + readonly rooms?: number | undefined readonly [key: string]: unknown } @@ -100,6 +337,8 @@ export interface PresetResult { readonly level: Ds1 /** Execution statistics and placement records. */ readonly stats: PresetStats + /** Discrete 8x8 rooms sliced from the preset canvas. */ + readonly rooms?: readonly PresetRoom[] | undefined } /** @@ -341,6 +580,8 @@ export function generateChaosSanctuary(request: PresetRequest): PresetResult { } } + const rooms = slicePresetRooms(canvas.width, canvas.height, canvas.objects) + return { level: { version: version === 0 ? 18 : version, @@ -367,7 +608,9 @@ export function generateChaosSanctuary(request: PresetRequest): PresetResult { objects: canvas.objects.length, cells: canvas.width * canvas.height, groundCells, + rooms: rooms.length, }, + rooms, } } @@ -392,42 +635,90 @@ export function generatePreset(request: PresetRequest): PresetResult { return generateChaosSanctuary(request) } - // Handle single preset map - if (request.pieces.length > 0 && request.pieces[0]?.levels[0]) { - const ds1 = request.pieces[0].levels[0] - const width = request.sizeX && request.sizeX > 0 ? request.sizeX : ds1.width - const height = request.sizeY && request.sizeY > 0 ? request.sizeY : ds1.height + // Handle single preset map (resolving multi-piece or multi-level variants if present) + if (request.pieces.length > 0) { + let pickedPieceIndex = 0 + let pickedPiece = request.pieces[0] + let nPickedFile: number | undefined = undefined - if (width === ds1.width && height === ds1.height) { - let groundCells = 0 - for (let y = 0; y < ds1.height; y += 1) { - const row = ds1.cells[y] - if (row === undefined) continue - for (let x = 0; x < ds1.width; x += 1) { - const c = row[x] - if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) { - groundCells += 1 + if (request.pieces.length > 1) { + pickedPieceIndex = resolvePickedFileIndex(request.pieces.length, request.seed ?? 0) + pickedPiece = request.pieces[pickedPieceIndex] + nPickedFile = pickedPieceIndex + } + + if (pickedPiece && pickedPiece.levels.length > 0) { + let ds1 = pickedPiece.levels[0] + if (pickedPiece.levels.length > 1) { + const variantIndex = resolvePickedFileIndex(pickedPiece.levels.length, request.seed ?? 0) + ds1 = pickedPiece.levels[variantIndex] ?? ds1 + nPickedFile = variantIndex + } + + if (ds1) { + const width = request.sizeX && request.sizeX > 0 ? request.sizeX : ds1.width + const height = request.sizeY && request.sizeY > 0 ? request.sizeY : ds1.height + + let resultLevel: Ds1 = ds1 + if (width !== ds1.width || height !== ds1.height) { + const canvas = makeCanvas( + width, + height, + ds1.wallLayers, + ds1.floorLayers, + ds1.cells[0]?.[0]?.substitutions?.length ?? 1, + ) + stampDs1(canvas, ds1, 0, 0) + resultLevel = { + version: ds1.version === 0 ? 18 : ds1.version, + width: canvas.width, + height: canvas.height, + act: request.act ?? ds1.act ?? 1, + substitutionType: ds1.substitutionType, + wallLayers: canvas.wallLayers, + floorLayers: canvas.floorLayers, + cells: canvas.cells, + objects: canvas.objects, + npcPathOffset: ds1.npcPathOffset, } } - } - return { - level: ds1, - stats: { - levelId: request.levelId, - levelName: request.levelName ?? request.pieces[0].name, - sizeX: width, - sizeY: height, - seed: request.seed ?? 0, - borderBlocks: 0, - borderUsage: {}, - substitutions: [ - { name: request.pieces[0].name, role: 'preset', enabled: true, clusters: 1 }, - ], - substitutedClusters: 1, - objects: ds1.objects.length, - cells: width * height, - groundCells, - }, + + let groundCells = 0 + for (let y = 0; y < resultLevel.height; y += 1) { + const row = resultLevel.cells[y] + if (row === undefined) continue + for (let x = 0; x < resultLevel.width; x += 1) { + const c = row[x] + if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) { + groundCells += 1 + } + } + } + + const rooms = slicePresetRooms(resultLevel.width, resultLevel.height, resultLevel.objects) + + return { + level: resultLevel, + stats: { + levelId: request.levelId, + levelName: request.levelName ?? pickedPiece.name, + sizeX: width, + sizeY: height, + seed: request.seed ?? 0, + borderBlocks: 0, + borderUsage: {}, + substitutions: [ + { name: pickedPiece.name, role: 'preset', enabled: true, clusters: 1 }, + ], + substitutedClusters: 1, + objects: resultLevel.objects.length, + cells: width * height, + groundCells, + nPickedFile: nPickedFile ?? 0, + rooms: rooms.length, + }, + rooms, + } } } } diff --git a/tests/preset.test.ts b/tests/preset.test.ts index 032c4db..294d50a 100644 --- a/tests/preset.test.ts +++ b/tests/preset.test.ts @@ -6,6 +6,16 @@ import { 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" @@ -235,3 +245,359 @@ describe("Preset Map Pipeline (DrlgType 2)", () => { 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) + }) +})