From ffc2b297f6a2f71b6cf99d91b578803fc9f8552a Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 07:08:47 +0000 Subject: [PATCH] feat(map-gen/maze): implement dynamic dungeon object spawning pipeline (fixes #50) --- src/game/maze-objects.ts | 730 +++++++++++++++++++++++++++++++++++++ src/game/maze.ts | 78 +++- tests/maze-objects.test.ts | 484 ++++++++++++++++++++++++ 3 files changed, 1288 insertions(+), 4 deletions(-) create mode 100644 src/game/maze-objects.ts create mode 100644 tests/maze-objects.test.ts diff --git a/src/game/maze-objects.ts b/src/game/maze-objects.ts new file mode 100644 index 0000000..854f955 --- /dev/null +++ b/src/game/maze-objects.ts @@ -0,0 +1,730 @@ +/** + * Dynamic Dungeon Object Spawning Pipeline (Issue #50). + * + * In Diablo II, maze dungeon generation doesn't rely solely on static objects authored in DS1 presets. + * A dedicated population pass dynamically scans the synthesized maze layout to seed: + * + * 1. Shrines (神殿: Armor, Combat, Resist, Skill, Mana, Health, Experience, Gem, Fire, Poison): + * - Typically seeded in dead-end rooms (`degree === 1`) or quiet room corners/alcoves. + * 2. Super/Gold Chests (金宝箱 / 特殊宝箱): + * - Placed in dead-end rooms (often as reward rooms) or special treasure clusters. + * 3. Weapon & Armor Racks (武器架、防具架): + * - Placed adjacent to perimeter walls in corridors (`degree === 2`), barracks, or dungeon chambers, + * with strict wall alignment. + * 4. Breakable Containers (可破坏物件: barrels, urns, coffins, caskets, crates, tombs): + * - Scattered in room corners, dead ends, and along walls in clusters of 2..5 items. + * + * All objects use authentic Diablo II DS1 object records `(type: 2, id)` matching the level's act. + */ + +import type { Ds1, Ds1Cell, Ds1Object } from '../formats/ds1.ts' +import { Rng } from './rng.ts' + +/** Subtiles per DS1 cell. */ +export const SUB_TILES_PER_TILE = 5 + +/** Dynamic dungeon object categories. */ +export type MazeObjectCategory = 'shrine' | 'chest' | 'rack' | 'container' + +/** Canonical shrine sub-types in Diablo II. */ +export type ShrineSubType = + | 'armor' + | 'combat' + | 'resist' + | 'skill' + | 'mana' + | 'health' + | 'experience' + | 'refilling' + | 'gem' + | 'fire' + | 'poison' + +/** Chest sub-types. */ +export type ChestSubType = 'super_chest' | 'chest' + +/** Weapon and armor rack sub-types. */ +export type RackSubType = 'armor_rack' | 'weapon_rack' + +/** Breakable container sub-types. */ +export type ContainerSubType = + | 'barrel' + | 'exploding_barrel' + | 'urn' + | 'casket' + | 'coffin' + | 'crate' + | 'tomb' + +/** Wall alignment directions for wall-mounted/wall-adjacent objects like racks. */ +export type WallAlignment = 'north' | 'south' | 'east' | 'west' + +/** A dynamically placed dungeon object with rich semantic metadata. */ +export interface PlacedMazeObject extends Ds1Object { + /** High-level category. */ + readonly category: MazeObjectCategory + /** Specific sub-type (e.g. 'armor', 'super_chest', 'weapon_rack', 'barrel'). */ + readonly subType: ShrineSubType | ChestSubType | RackSubType | ContainerSubType | string + /** Index of the owning room in the level rooms array. */ + readonly roomIndex?: number + /** Room ID assigned during maze generation. */ + readonly roomId?: number + /** Whether the object was spawned in a dead-end room (`degree === 1`). */ + readonly isDeadEnd?: boolean + /** Whether the object was placed in a room corner. */ + readonly isCorner?: boolean + /** For racks and wall decorations, which wall it is aligned against. */ + readonly alignedWall?: WallAlignment | null +} + +/** Options to configure the dungeon object population pipeline. */ +export interface MazeObjectPopulationOptions { + /** Overall object density multiplier (default: 1.0). */ + readonly density?: number + /** Breakable container density multiplier (default: 1.0). When 0, containers are omitted. */ + readonly containerDensity?: number + /** Shrine density multiplier (default: 1.0). When 0, shrines are omitted. */ + readonly shrineDensity?: number + /** Chest density multiplier (default: 1.0). When 0, chests are omitted. */ + readonly chestDensity?: number + /** Rack density multiplier (default: 1.0). When 0, racks are omitted. */ + readonly rackDensity?: number + /** Deterministic RNG seed override for object seeding. Defaults to the maze seed. */ + readonly objectSeed?: number + /** If true, skips dynamic object generation entirely. */ + readonly disableDynamicObjects?: boolean +} + +/** Minimal room descriptor interface needed by the object populator. */ +export interface MazeRoomDescriptor { + readonly id: number + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly orths: readonly { readonly room: unknown; readonly direction: number }[] + readonly piece?: { readonly name: string; readonly kind?: string } | null +} + +/** Candidate tile spot inside a room. */ +export interface CandidateSpot { + readonly cellX: number + readonly cellY: number + readonly roomIndex: number + readonly roomId: number + readonly isDeadEnd: boolean + readonly isCorner: boolean + readonly alignedWall: WallAlignment | null +} + +/* ------------------------------------------------------------------------- * + * Object Definitions & Act Mapping + * ------------------------------------------------------------------------- */ + +/** Shrine definition: subtype to act-specific DS1 ID mapping. */ +interface ShrineDef { + readonly subType: ShrineSubType + readonly idsByAct: Record +} + +export const CANONICAL_SHRINES: readonly ShrineDef[] = [ + { subType: 'armor', idsByAct: { 1: 8, 2: 169, 3: 169, 4: 169, 5: 169 } }, + { subType: 'combat', idsByAct: { 1: 9, 2: 170, 3: 170, 4: 170, 5: 170 } }, + { subType: 'resist', idsByAct: { 1: 10, 2: 171, 3: 171, 4: 171, 5: 171 } }, + { subType: 'skill', idsByAct: { 1: 11, 2: 172, 3: 172, 4: 172, 5: 172 } }, + { subType: 'mana', idsByAct: { 1: 7, 2: 168, 3: 168, 4: 168, 5: 168 } }, + { subType: 'health', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } }, + { subType: 'experience', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } }, + { subType: 'refilling', idsByAct: { 1: 6, 2: 167, 3: 167, 4: 167, 5: 167 } }, + { subType: 'gem', idsByAct: { 1: 82, 2: 227, 3: 227, 4: 227, 5: 227 } }, + { subType: 'fire', idsByAct: { 1: 29, 2: 152, 3: 152, 4: 152, 5: 152 } }, + { subType: 'poison', idsByAct: { 1: 84, 2: 55, 3: 236, 4: 236, 5: 236 } }, +] + +/** Chest ID mapping per act. */ +export const CHEST_IDS_BY_ACT: Record = { + 1: { super: [5, 63, 155, 156], normal: [89, 90, 91, 92, 200, 201, 229, 203] }, + 2: { super: [32, 33, 155, 156], normal: [200, 201, 229, 203] }, + 3: { super: [65, 66, 155, 156], normal: [200, 201, 229, 203] }, + 4: { super: [155, 156], normal: [200, 201, 229, 203] }, + 5: { super: [155, 156], normal: [200, 201, 229, 203] }, +} + +/** Weapon and Armor rack ID mapping per act and orientation. */ +export const RACK_IDS_BY_ACT: Record< + number, + { + armor: { west: readonly number[]; north: readonly number[]; any: readonly number[] } + weapon: { west: readonly number[]; north: readonly number[]; any: readonly number[] } + } +> = { + 1: { + armor: { west: [19, 46, 183, 254], north: [20, 47, 184, 255], any: [19, 20, 46, 47, 183, 184, 254, 255] }, + weapon: { west: [48, 256], north: [49, 257], any: [48, 49, 256, 257] }, + }, + 2: { + armor: { west: [74, 183, 254], north: [75, 184, 255], any: [74, 75, 183, 184, 254, 255] }, + weapon: { west: [76, 256], north: [77, 257], any: [76, 77, 256, 257] }, + }, + 3: { + armor: { west: [9, 183, 254], north: [10, 184, 255], any: [9, 10, 183, 184, 254, 255] }, + weapon: { west: [11, 256], north: [12, 257], any: [11, 12, 256, 257] }, + }, + 4: { + armor: { west: [183, 254], north: [184, 255], any: [183, 184, 254, 255] }, + weapon: { west: [256], north: [257], any: [256, 257] }, + }, + 5: { + armor: { west: [10, 183, 254], north: [11, 184, 255], any: [10, 11, 183, 184, 254, 255] }, + weapon: { west: [256], north: [257], any: [256, 257] }, + }, +} + +/** Container pool mapping per act. */ +export const CONTAINER_IDS_BY_ACT: Record< + number, + { + barrels: readonly number[] + urns: readonly number[] + caskets: readonly number[] + crates: readonly number[] + } +> = { + 1: { + barrels: [95, 28, 50, 51, 102, 157, 232, 251, 329, 330, 417], + urns: [154, 159, 202, 244, 245], + caskets: [93, 94, 151, 153], + crates: [161], + }, + 2: { + barrels: [6, 21, 157, 232, 251, 329, 330, 417], + urns: [25, 26, 27, 28, 29, 154, 159, 202], + caskets: [151, 153], + crates: [161], + }, + 3: { + barrels: [34, 157, 232, 251, 329, 330, 417], + urns: [154, 159, 202, 244, 245], + caskets: [151, 153], + crates: [161], + }, + 4: { + barrels: [2, 157, 232, 251, 329, 330, 417], + urns: [154, 159, 202], + caskets: [151, 153], + crates: [161], + }, + 5: { + barrels: [4, 44, 157, 232, 251, 329, 330, 417], + urns: [154, 159, 202], + caskets: [151, 153], + crates: [161], + }, +} + +/* ------------------------------------------------------------------------- * + * Spatial Analysis + * ------------------------------------------------------------------------- */ + +/** Check if a cell has a floor and no solid wall. */ +export function isOpenFloor(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean { + if (cy < 0 || cy >= cells.length) return false + const row = cells[cy] + if (!row || cx < 0 || cx >= row.length) return false + const cell = row[cx] + if (!cell) return false + + // Must contain floor + const hasFloor = cell.floors.length > 0 && cell.floors.some(f => !f.hidden && (f.prop1 !== 0 || f.style !== 0 || f.sequence !== 0 || true)) + if (!hasFloor) return false + + // Must not have a solid, visible wall + const hasWall = cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0)) + return !hasWall +} + +/** Check if a cell contains a wall or is off-map. */ +export function isWallCell(cells: readonly (readonly Ds1Cell[])[], cx: number, cy: number): boolean { + if (cy < 0 || cy >= cells.length) return true + const row = cells[cy] + if (!row || cx < 0 || cx >= row.length) return true + const cell = row[cx] + if (!cell) return true + return cell.walls.some(w => !w.hidden && (w.prop1 !== 0 || w.style !== 0 || w.type !== 0)) +} + +/** Determine which adjacent cardinal wall a floor cell is next to (if any). */ +export function getAdjacentWall( + cells: readonly (readonly Ds1Cell[])[], + cx: number, + cy: number, +): WallAlignment | null { + if (isWallCell(cells, cx, cy - 1)) return 'north' + if (isWallCell(cells, cx - 1, cy)) return 'west' + if (isWallCell(cells, cx, cy + 1)) return 'south' + if (isWallCell(cells, cx + 1, cy)) return 'east' + return null +} + +/** Check if an open floor cell is in an inner corner (walls on at least 2 orthogonal sides). */ +export function isInnerCorner( + cells: readonly (readonly Ds1Cell[])[], + cx: number, + cy: number, +): boolean { + const n = isWallCell(cells, cx, cy - 1) + const s = isWallCell(cells, cx, cy + 1) + const w = isWallCell(cells, cx - 1, cy) + const e = isWallCell(cells, cx + 1, cy) + return (n && w) || (n && e) || (s && w) || (s && e) +} + +/** + * Scan a single room and harvest candidate placement spots categorized into: + * - dead ends (`degree === 1`) + * - room corners + * - perimeter wall offsets (wall alignment) + */ +export function analyzeRoomSpots( + room: MazeRoomDescriptor, + roomIndex: number, + originX: number, + originY: number, + cells: readonly (readonly Ds1Cell[])[], +): CandidateSpot[] { + const spots: CandidateSpot[] = [] + const isDeadEnd = room.orths.length === 1 + const width = room.width + const height = room.height + + for (let dy = 0; dy < height; dy += 1) { + for (let dx = 0; dx < width; dx += 1) { + const cx = originX + dx + const cy = originY + dy + + if (!isOpenFloor(cells, cx, cy)) continue + + const alignedWall = getAdjacentWall(cells, cx, cy) + const isCorner = isInnerCorner(cells, cx, cy) || + (dx <= 2 && dy <= 2) || + (dx >= width - 3 && dy <= 2) || + (dx <= 2 && dy >= height - 3) || + (dx >= width - 3 && dy >= height - 3) + + spots.push({ + cellX: cx, + cellY: cy, + roomIndex, + roomId: room.id, + isDeadEnd, + isCorner, + alignedWall, + }) + } + } + + return spots +} + +/* ------------------------------------------------------------------------- * + * Main Object Population Pipeline + * ------------------------------------------------------------------------- */ + +export interface PopulationResult { + readonly placedObjects: readonly PlacedMazeObject[] + readonly stats: { + readonly dynamicObjectsCount: number + readonly shrinesSpawned: number + readonly chestsSpawned: number + readonly superChestsSpawned: number + readonly racksSpawned: number + readonly containersSpawned: number + } +} + +/** + * Executes the dynamic dungeon object spawning pipeline. + * + * @param rooms - The list of rooms placed in the maze. + * @param minX - Minimum room x in cells. + * @param minY - Minimum room y in cells. + * @param level - The synthesized Ds1 level containing cells and static objects. + * @param levelTypeName - LvlTypes name (e.g. 'Act 1 - Cave', 'Act 2 - Tomb'). + * @param levelId - Levels.txt ID. + * @param options - Configurable density and seeding options. + * @param rng - Pseudo-random number generator. + * @returns Placed dynamic objects and spawn statistics. + */ +export function populateDungeonObjects( + rooms: readonly MazeRoomDescriptor[], + minX: number, + minY: number, + level: Ds1, + levelTypeName: string, + levelId: number, + options?: MazeObjectPopulationOptions, + rng?: Rng, +): PopulationResult { + if (options?.disableDynamicObjects === true) { + return { + placedObjects: [], + stats: { + dynamicObjectsCount: 0, + shrinesSpawned: 0, + chestsSpawned: 0, + superChestsSpawned: 0, + racksSpawned: 0, + containersSpawned: 0, + }, + } + } + + const act = levelTypeName.startsWith('Act 2') + ? 2 + : levelTypeName.startsWith('Act 3') + ? 3 + : levelTypeName.startsWith('Act 4') + ? 4 + : levelTypeName.startsWith('Act 5') + ? 5 + : 1 + + const seed = options?.objectSeed ?? 0x5050_0000 + levelId + const popRng = rng ?? new Rng(seed).fork('dungeon-objects') + + const density = Math.max(0, options?.density ?? 1.0) + if (density <= 0) { + return { + placedObjects: [], + stats: { + dynamicObjectsCount: 0, + shrinesSpawned: 0, + chestsSpawned: 0, + superChestsSpawned: 0, + racksSpawned: 0, + containersSpawned: 0, + }, + } + } + + const shrineDensity = options?.shrineDensity ?? density + const chestDensity = options?.chestDensity ?? density + const rackDensity = options?.rackDensity ?? density + const containerDensity = options?.containerDensity ?? density + + const chestPool = CHEST_IDS_BY_ACT[act] ?? CHEST_IDS_BY_ACT[1]! + const rackPool = RACK_IDS_BY_ACT[act] ?? RACK_IDS_BY_ACT[1]! + const containerPool = CONTAINER_IDS_BY_ACT[act] ?? CONTAINER_IDS_BY_ACT[1]! + + // Track existing objects to avoid collisions + const occupiedSubtiles: { x: number; y: number }[] = level.objects.map(o => ({ x: o.x, y: o.y })) + const placedObjects: PlacedMazeObject[] = [] + + const isSubtileClear = (subX: number, subY: number, minDistance = SUB_TILES_PER_TILE): boolean => { + for (const occ of occupiedSubtiles) { + if (Math.hypot(occ.x - subX, occ.y - subY) < minDistance) return false + } + return true + } + + const placeObject = ( + cellX: number, + cellY: number, + id: number, + category: MazeObjectCategory, + subType: string, + metadata: { + roomIndex?: number + roomId?: number + isDeadEnd?: boolean + isCorner?: boolean + alignedWall?: WallAlignment | null + }, + subOffset = 2, + ): boolean => { + const subX = cellX * SUB_TILES_PER_TILE + subOffset + const subY = cellY * SUB_TILES_PER_TILE + subOffset + if (!isSubtileClear(subX, subY, SUB_TILES_PER_TILE * 0.9)) return false + + const placed: PlacedMazeObject = { + type: 2, + id, + x: subX, + y: subY, + flags: 0, + category, + subType, + ...metadata, + } + + placedObjects.push(placed) + occupiedSubtiles.push({ x: subX, y: subY }) + return true + } + + // 1. Gather candidate spots per room + const roomSpots: { room: MazeRoomDescriptor; roomIndex: number; spots: CandidateSpot[] }[] = [] + for (let i = 0; i < rooms.length; i += 1) { + const room = rooms[i]! + const originX = room.x - minX + const originY = room.y - minY + const spots = analyzeRoomSpots(room, i, originX, originY, level.cells) + roomSpots.push({ room, roomIndex: i, spots }) + } + + let shrinesSpawned = 0 + let chestsSpawned = 0 + let superChestsSpawned = 0 + let racksSpawned = 0 + let containersSpawned = 0 + + // 2. Dead-end rooms population: Shrines and Super/Gold Chests + const deadEndRooms = roomSpots.filter(r => r.room.orths.length === 1) + + // Decide shrine quota: 1..3 depending on dungeon room count and shrineDensity + const baseShrineTarget = Math.max(1, Math.min(3, Math.round((rooms.length / 6) * shrineDensity))) + const shrineTarget = shrineDensity > 0 ? baseShrineTarget : 0 + + const usedRoomsForMajorObject = new Set() + + // A. Place Shrines in dead-end rooms (or corner spots if dead-ends are scarce) + if (shrineTarget > 0) { + const shuffledDeadEnds = [...deadEndRooms].sort(() => popRng.next() - 0.5) + for (const r of shuffledDeadEnds) { + if (shrinesSpawned >= shrineTarget) break + // Pick a center or deep spot in the dead end + const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)] + if (!candidate) continue + + const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]! + const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]! + + if ( + placeObject(candidate.cellX, candidate.cellY, shrineId, 'shrine', shrineDef.subType, { + roomIndex: r.roomIndex, + roomId: r.room.id, + isDeadEnd: true, + isCorner: candidate.isCorner, + alignedWall: candidate.alignedWall, + }) + ) { + shrinesSpawned += 1 + usedRoomsForMajorObject.add(r.room.id) + } + } + + // If more shrines are needed, place them in regular room corners + if (shrinesSpawned < shrineTarget) { + for (const r of roomSpots) { + if (shrinesSpawned >= shrineTarget) break + if (usedRoomsForMajorObject.has(r.room.id)) continue + + const cornerSpot = r.spots.find(s => s.isCorner && isSubtileClear(s.cellX * SUB_TILES_PER_TILE + 2, s.cellY * SUB_TILES_PER_TILE + 2, SUB_TILES_PER_TILE * 2)) + if (!cornerSpot) continue + + const shrineDef = CANONICAL_SHRINES[popRng.int(0, CANONICAL_SHRINES.length - 1)]! + const shrineId = shrineDef.idsByAct[act] ?? shrineDef.idsByAct[1]! + + if ( + placeObject(cornerSpot.cellX, cornerSpot.cellY, shrineId, 'shrine', shrineDef.subType, { + roomIndex: r.roomIndex, + roomId: r.room.id, + isDeadEnd: r.room.orths.length === 1, + isCorner: true, + alignedWall: cornerSpot.alignedWall, + }) + ) { + shrinesSpawned += 1 + usedRoomsForMajorObject.add(r.room.id) + } + } + } + } + + // B. Place Super/Gold Chests in remaining dead-end rooms and special treasure rooms + if (chestDensity > 0) { + for (const r of roomSpots) { + const isDeadEnd = r.room.orths.length === 1 + const isTreasureRoom = r.room.piece?.kind === 'treasure' || /Treasure|Chest/i.test(r.room.piece?.name ?? '') + + if (!isDeadEnd && !isTreasureRoom) continue + if (usedRoomsForMajorObject.has(r.room.id) && !isTreasureRoom) continue + + // In dead-ends, place a Super Chest + const candidate = r.spots.find(s => !s.alignedWall) ?? r.spots[Math.floor(r.spots.length / 2)] + if (!candidate) continue + + const superChestId = chestPool.super[popRng.int(0, chestPool.super.length - 1)]! + if ( + placeObject(candidate.cellX, candidate.cellY, superChestId, 'chest', 'super_chest', { + roomIndex: r.roomIndex, + roomId: r.room.id, + isDeadEnd, + isCorner: candidate.isCorner, + alignedWall: candidate.alignedWall, + }) + ) { + chestsSpawned += 1 + superChestsSpawned += 1 + usedRoomsForMajorObject.add(r.room.id) + + // For special clusters / treasure rooms, add 1..2 surrounding regular chests + const clusterCount = isTreasureRoom ? popRng.int(2, 3) : popRng.chance(0.5) ? 1 : 0 + const secondarySpots = r.spots.filter( + s => (s.cellX !== candidate.cellX || s.cellY !== candidate.cellY) && + Math.hypot(s.cellX - candidate.cellX, s.cellY - candidate.cellY) <= 3, + ) + + for (let c = 0; c < clusterCount && c < secondarySpots.length; c += 1) { + const sec = secondarySpots[c]! + const regChestId = chestPool.normal[popRng.int(0, chestPool.normal.length - 1)]! + if ( + placeObject(sec.cellX, sec.cellY, regChestId, 'chest', 'chest', { + roomIndex: r.roomIndex, + roomId: r.room.id, + isDeadEnd, + isCorner: sec.isCorner, + alignedWall: sec.alignedWall, + }) + ) { + chestsSpawned += 1 + } + } + } + } + } + + // 3. Weapon & Armor Racks: placed against walls in corridors (`degree === 2`), barracks, or chambers + if (rackDensity > 0) { + const isBarracks = /Barracks|Jail/i.test(levelTypeName) + const baseRackTarget = Math.max(1, Math.min(4, Math.round((rooms.length / 5) * rackDensity * (isBarracks ? 1.5 : 1.0)))) + const rackTarget = baseRackTarget + + // Prefer corridor rooms (`degree === 2`) or barracks + const candidateRoomsForRacks = roomSpots.filter( + r => r.room.orths.length === 2 || isBarracks || r.room.orths.length <= 2, + ) + + for (const r of candidateRoomsForRacks) { + if (racksSpawned >= rackTarget) break + + // Find spots directly adjacent to a wall + const wallSpots = r.spots.filter(s => s.alignedWall !== null) + if (wallSpots.length === 0) continue + + const chosenSpot = wallSpots[popRng.int(0, wallSpots.length - 1)]! + const isArmor = popRng.chance(0.5) + const subType: RackSubType = isArmor ? 'armor_rack' : 'weapon_rack' + + let rackId: number + const wall = chosenSpot.alignedWall + if (isArmor) { + if (wall === 'west') rackId = rackPool.armor.west[popRng.int(0, rackPool.armor.west.length - 1)]! + else if (wall === 'north') rackId = rackPool.armor.north[popRng.int(0, rackPool.armor.north.length - 1)]! + else rackId = rackPool.armor.any[popRng.int(0, rackPool.armor.any.length - 1)]! + } else { + if (wall === 'west') rackId = rackPool.weapon.west[popRng.int(0, rackPool.weapon.west.length - 1)]! + else if (wall === 'north') rackId = rackPool.weapon.north[popRng.int(0, rackPool.weapon.north.length - 1)]! + else rackId = rackPool.weapon.any[popRng.int(0, rackPool.weapon.any.length - 1)]! + } + + if ( + placeObject(chosenSpot.cellX, chosenSpot.cellY, rackId, 'rack', subType, { + roomIndex: r.roomIndex, + roomId: r.room.id, + isDeadEnd: r.room.orths.length === 1, + isCorner: chosenSpot.isCorner, + alignedWall: chosenSpot.alignedWall, + }) + ) { + racksSpawned += 1 + } + } + } + + // 4. Breakable Containers (barrels, urns, coffins, caskets, crates, tombs) + if (containerDensity > 0) { + const isCryptOrTomb = /Crypt|Catacombs|Tomb/i.test(levelTypeName) + const isSewerOrJail = /Sewer|Jail|Barracks/i.test(levelTypeName) + + // Average 1..2 clusters per room according to containerDensity + const clusterTarget = Math.max(1, Math.round(rooms.length * 1.2 * containerDensity)) + + for (let cl = 0; cl < clusterTarget; cl += 1) { + const r = roomSpots[popRng.int(0, roomSpots.length - 1)]! + + // Prefer corner spots or wall-aligned spots for clusters + const preferredSpots = r.spots.filter(s => s.isCorner || s.alignedWall !== null) + const anchorSpot = preferredSpots.length > 0 + ? preferredSpots[popRng.int(0, preferredSpots.length - 1)]! + : r.spots[popRng.int(0, r.spots.length - 1)] + + if (!anchorSpot) continue + + // Determine container type for this cluster + let chosenType: ContainerSubType = 'barrel' + let idPool = containerPool.barrels + + if (isCryptOrTomb) { + const roll = popRng.next() + if (roll < 0.4) { + chosenType = 'casket' + idPool = containerPool.caskets + } else if (roll < 0.8) { + chosenType = 'urn' + idPool = containerPool.urns + } else { + chosenType = 'barrel' + idPool = containerPool.barrels + } + } else if (isSewerOrJail) { + const roll = popRng.next() + if (roll < 0.3) { + chosenType = 'crate' + idPool = containerPool.crates + } else if (roll < 0.7) { + chosenType = 'barrel' + idPool = containerPool.barrels + } else { + chosenType = 'urn' + idPool = containerPool.urns + } + } + + // Cluster size: 2 to 4 items + const clusterSize = popRng.int(2, 4) + const nearbySpots = r.spots.filter( + s => Math.hypot(s.cellX - anchorSpot.cellX, s.cellY - anchorSpot.cellY) <= 2, + ) + + for (let ci = 0; ci < clusterSize && ci < nearbySpots.length; ci += 1) { + const spot = nearbySpots[ci]! + const objId = idPool[popRng.int(0, idPool.length - 1)]! + + if ( + placeObject(spot.cellX, spot.cellY, objId, 'container', chosenType, { + roomIndex: r.roomIndex, + roomId: r.room.id, + isDeadEnd: r.room.orths.length === 1, + isCorner: spot.isCorner, + alignedWall: spot.alignedWall, + }) + ) { + containersSpawned += 1 + } + } + } + } + + return { + placedObjects, + stats: { + dynamicObjectsCount: placedObjects.length, + shrinesSpawned, + chestsSpawned, + superChestsSpawned, + racksSpawned, + containersSpawned, + }, + } +} diff --git a/src/game/maze.ts b/src/game/maze.ts index f30b410..3090d8e 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -80,6 +80,23 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' +import { + populateDungeonObjects, + type PlacedMazeObject, + type MazeObjectPopulationOptions, + type MazeRoomDescriptor, +} from './maze-objects.ts' + +export type { + PlacedMazeObject, + MazeObjectPopulationOptions, + MazeObjectCategory, + ShrineSubType, + ChestSubType, + RackSubType, + ContainerSubType, + WallAlignment, +} from './maze-objects.ts' /* ------------------------------------------------------------------------- * * Directions @@ -265,12 +282,18 @@ export interface MazeRequest { readonly staffTombLevelId?: number /** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */ readonly bossTombLevelId?: number + /** Dynamic dungeon object seeding options. */ + readonly objectOptions?: MazeObjectPopulationOptions + /** Configurable object density multiplier or options. */ + readonly objectDensity?: number | MazeObjectPopulationOptions } /** The generated level. */ export interface MazeResult { /** A single synthesized map covering every placed section. */ readonly level: Ds1 + /** Dynamically seeded interactive objects (shrines, chests, racks, breakables). */ + readonly objects: readonly PlacedMazeObject[] /** What the generator did, for reporting and tests. */ readonly stats: Record } @@ -739,7 +762,7 @@ export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X'] * Per-act decoration passes D2MOO runs after the layout, which this port does not * reproduce. Reported verbatim in every result's `stats.unimplementedPasses`. */ -const UNIMPLEMENTED_PASSES: readonly string[] = [ +export const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGMAZE_PlaceAct2TombStuff', 'DRLGMAZE_PlaceAct2LairStuff', 'DRLGMAZE_PlaceAct3DungeonStuff', @@ -748,7 +771,6 @@ const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGMAZE_PlaceAct5TempleStuff', 'DRLGMAZE_PlaceAct5BaalStuff', 'DRLGMAZE_PlaceAct5IceStuff', - 'DRLGMAZE_PlaceAct1Barracks', 'DRLGMAZE_PlaceAct4Lava', 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets', ] @@ -2042,8 +2064,49 @@ export function generateMaze(request: MazeRequest): MazeResult { assignVariants(level, request.levelId, stats) const stamped = stamp(level, stats) + const minX = Math.min(...level.rooms.map(room => room.x)) + const minY = Math.min(...level.rooms.map(room => room.y)) + const roomDescriptors: MazeRoomDescriptor[] = level.rooms.map(room => { + const variant = room.piece ? room.piece.levels[room.variant % room.piece.levels.length] : undefined + return { + id: room.id, + x: room.x, + y: room.y, + width: variant?.width ?? width, + height: variant?.height ?? height, + orths: room.orths, + piece: room.piece, + } + }) + + const popOptions: MazeObjectPopulationOptions | undefined = + typeof request.objectDensity === 'number' + ? { density: request.objectDensity, ...request.objectOptions } + : { ...request.objectDensity, ...request.objectOptions } + + const objectRng = popOptions?.objectSeed !== undefined + ? new Rng(popOptions.objectSeed) + : rng.fork('dungeon-objects') + + const popResult = populateDungeonObjects( + roomDescriptors, + minX, + minY, + stamped, + levelTypeName, + request.levelId, + popOptions, + objectRng, + ) + + const finalLevel: Ds1 = { + ...stamped, + objects: [...stamped.objects, ...popResult.placedObjects], + } + return { - level: stamped, + level: finalLevel, + objects: popResult.placedObjects, stats: { levelId: request.levelId, levelName: request.levelName, @@ -2060,7 +2123,14 @@ export function generateMaze(request: MazeRequest): MazeResult { mergedDoorways: level.merges, gridSize: { width: stamped.width, height: stamped.height }, gridSizeSubtiles: { width: stamped.width * SUB_TILES_PER_TILE, height: stamped.height * SUB_TILES_PER_TILE }, - objects: stamped.objects.length, + objects: finalLevel.objects.length, + dynamicObjects: popResult.placedObjects, + dynamicObjectsCount: popResult.stats.dynamicObjectsCount, + shrinesSpawned: popResult.stats.shrinesSpawned, + chestsSpawned: popResult.stats.chestsSpawned, + superChestsSpawned: popResult.stats.superChestsSpawned, + racksSpawned: popResult.stats.racksSpawned, + containersSpawned: popResult.stats.containersSpawned, cells: stamped.width * stamped.height, sidePatterns: stats.patterns, fallbacks: stats.fallbacks, diff --git a/tests/maze-objects.test.ts b/tests/maze-objects.test.ts new file mode 100644 index 0000000..1e259b7 --- /dev/null +++ b/tests/maze-objects.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, test } from 'vitest' +import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' +import { + populateDungeonObjects, + CANONICAL_SHRINES, + CHEST_IDS_BY_ACT, + RACK_IDS_BY_ACT, + CONTAINER_IDS_BY_ACT, + type MazeRoomDescriptor, + type PlacedMazeObject, + type WallAlignment, +} from '../src/game/maze-objects.ts' +import { + generateMaze, + type MazePiece, + type MazeRequest, +} from '../src/game/maze.ts' +import { Rng } from '../src/game/rng.ts' + +/** + * Creates a mock DS1 room with perimeter walls and an open interior. + * Optionally adds a doorway gap on specified sides ('N' | 'S' | 'E' | 'W'). + */ +function makeMockDs1( + width: number, + height: number, + doorways: readonly ('N' | 'S' | 'E' | 'W')[] = [], + staticObjects: readonly Ds1Object[] = [], +): Ds1 { + const cells: Ds1Cell[][] = [] + const midX = Math.floor(width / 2) + const midY = Math.floor(height / 2) + + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + const isNorthWall = y === 0 + const isSouthWall = y === height - 1 + const isWestWall = x === 0 + const isEastWall = x === width - 1 + let isWall = isNorthWall || isSouthWall || isWestWall || isEastWall + + // Open doorways in the middle of perimeter walls + if (isNorthWall && x === midX && doorways.includes('N')) isWall = false + if (isSouthWall && x === midX && doorways.includes('S')) isWall = false + if (isWestWall && y === midY && doorways.includes('W')) isWall = false + if (isEastWall && y === midY && doorways.includes('E')) isWall = false + + row.push({ + walls: isWall + ? [{ prop1: 1, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] + : [], + floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + + return { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [...staticObjects], + npcPathOffset: null, + } +} + +/** + * Builds a suite of mock maze pieces for `generateMaze`. + */ +function makeTestMazePieces( + levelTypeName = 'Act 1 - Cave', + size = 11, + staticObjects: readonly Ds1Object[] = [], +): MazePiece[] { + const allSides = [ + 'N', 'S', 'E', 'W', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', + 'NSE', 'NSW', 'NEW', 'SEW', 'NSEW', + ] + + const pieces: MazePiece[] = allSides.map(sides => ({ + name: `${levelTypeName} ${sides}`, + kind: 'room', + sides, + levels: [makeMockDs1(size, size, [], staticObjects)], + })) + + // Entrance piece + pieces.push({ + name: `${levelTypeName} Entrance`, + kind: 'entrance', + sides: '', + levels: [makeMockDs1(size, size, [], staticObjects)], + }) + + // Directional special pieces + for (const s of ['N', 'S', 'E', 'W']) { + pieces.push({ name: `${levelTypeName} Prev ${s}`, kind: 'prev', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) + pieces.push({ name: `${levelTypeName} Down ${s}`, kind: 'down', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) + pieces.push({ name: `${levelTypeName} Next ${s}`, kind: 'next', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) + pieces.push({ name: `${levelTypeName} Quest ${s}`, kind: 'quest', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) + pieces.push({ name: `${levelTypeName} Theme ${s}`, kind: 'theme', sides: s, levels: [makeMockDs1(size, size, [], staticObjects)] }) + } + + return pieces +} + +/** + * Creates a synthetic multi-room dungeon layout for fine-grained spot tests. + */ +function createSyntheticDungeon(): { + level: Ds1 + rooms: MazeRoomDescriptor[] +} { + // 3 rooms laid out horizontally: + // Room 0: dead-end (10x10 at [0, 0]), orths to Room 1 (east) + // Room 1: corridor (10x10 at [10, 0]), orths to Room 0 (west) and Room 2 (east) + // Room 2: dead-end (10x10 at [20, 0]), orths to Room 1 (west) + const width = 30 + const height = 10 + const cells: Ds1Cell[][] = [] + + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + // Outer perimeter is wall, plus vertical dividers at x=10 and x=20 + const isOuterWall = y === 0 || y === height - 1 || x === 0 || x === width - 1 + const isDivider = (x === 10 || x === 20) && y !== 5 // doorway at y=5 + const isWall = isOuterWall || isDivider + + row.push({ + walls: isWall + ? [{ prop1: 1, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] + : [], + floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + + const level: Ds1 = { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects: [], + npcPathOffset: null, + } + + const room0: MazeRoomDescriptor = { + id: 0, + x: 0, + y: 0, + width: 10, + height: 10, + orths: [{ room: 1, direction: 1 /* East */ }], + } + + const room1: MazeRoomDescriptor = { + id: 1, + x: 10, + y: 0, + width: 10, + height: 10, + orths: [ + { room: 0, direction: 3 /* West */ }, + { room: 2, direction: 1 /* East */ }, + ], + } + + const room2: MazeRoomDescriptor = { + id: 2, + x: 20, + y: 0, + width: 10, + height: 10, + orths: [{ room: 1, direction: 3 /* West */ }], + } + + return { level, rooms: [room0, room1, room2] } +} + +describe('populateDungeonObjects pipeline', () => { + test('spawns shrines and super chests in dead-end rooms', () => { + const { level, rooms } = createSyntheticDungeon() + const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { + density: 1.0, + objectSeed: 12345, + }) + + const shrines = result.placedObjects.filter(o => o.category === 'shrine') + const chests = result.placedObjects.filter(o => o.category === 'chest') + const superChests = chests.filter(o => o.subType === 'super_chest') + + expect(shrines.length).toBeGreaterThanOrEqual(1) + expect(superChests.length).toBeGreaterThanOrEqual(1) + + // Shrines in dead-ends must have isDeadEnd: true + for (const shrine of shrines) { + expect(shrine.type).toBe(2) + expect(shrine.category).toBe('shrine') + // Valid shrine subtype + const validSubtypes = CANONICAL_SHRINES.map(s => s.subType) + expect(validSubtypes).toContain(shrine.subType) + if (shrine.roomId === 0 || shrine.roomId === 2) { + expect(shrine.isDeadEnd).toBe(true) + } + } + + // Super chests placed in dead-end rooms + for (const chest of superChests) { + expect(chest.type).toBe(2) + expect(chest.category).toBe('chest') + expect(chest.isDeadEnd).toBe(true) + expect(CHEST_IDS_BY_ACT[1]!.super).toContain(chest.id) + } + }) + + test('aligns weapon and armor racks strictly against perimeter walls', () => { + const { level, rooms } = createSyntheticDungeon() + const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Barracks', 28, { + density: 1.0, + rackDensity: 2.0, // encourage rack spawning + objectSeed: 42, + }) + + const racks = result.placedObjects.filter(o => o.category === 'rack') + expect(racks.length).toBeGreaterThan(0) + + for (const rack of racks) { + expect(['armor_rack', 'weapon_rack']).toContain(rack.subType) + expect(['north', 'south', 'east', 'west']).toContain(rack.alignedWall) + + // Cell coordinates from subtiles (5 subtiles per tile) + const cellX = Math.floor(rack.x / 5) + const cellY = Math.floor(rack.y / 5) + + // The rack itself must stand on an open floor cell + const rackCell = level.cells[cellY]?.[cellX] + expect(rackCell).toBeDefined() + expect(rackCell!.floors.length).toBeGreaterThan(0) + expect(rackCell!.walls.length).toBe(0) + + // Neighbor in the direction of alignedWall must be a solid wall + let wallNeighbor: Ds1Cell | undefined + if (rack.alignedWall === 'north') wallNeighbor = level.cells[cellY - 1]?.[cellX] + else if (rack.alignedWall === 'south') wallNeighbor = level.cells[cellY + 1]?.[cellX] + else if (rack.alignedWall === 'west') wallNeighbor = level.cells[cellY]?.[cellX - 1] + else if (rack.alignedWall === 'east') wallNeighbor = level.cells[cellY]?.[cellX + 1] + + expect(wallNeighbor).toBeDefined() + expect(wallNeighbor!.walls.length).toBeGreaterThan(0) + } + }) + + test('scales container spawning proportionally with containerDensity', () => { + const { level, rooms } = createSyntheticDungeon() + + const runZero = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { + containerDensity: 0, + objectSeed: 100, + }) + const containersZero = runZero.placedObjects.filter(o => o.category === 'container') + expect(containersZero.length).toBe(0) + expect(runZero.stats.containersSpawned).toBe(0) + + const runHalf = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { + containerDensity: 0.5, + objectSeed: 100, + }) + const containersHalf = runHalf.placedObjects.filter(o => o.category === 'container') + + const runNormal = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { + containerDensity: 1.0, + objectSeed: 100, + }) + const containersNormal = runNormal.placedObjects.filter(o => o.category === 'container') + + const runDouble = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { + containerDensity: 2.0, + objectSeed: 100, + }) + const containersDouble = runDouble.placedObjects.filter(o => o.category === 'container') + + expect(containersHalf.length).toBeLessThan(containersNormal.length) + expect(containersNormal.length).toBeLessThan(containersDouble.length) + }) + + test('guarantees deterministic object seeding given identical seeds', () => { + const { level, rooms } = createSyntheticDungeon() + + const seed = 987654321 + const runA = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, { + density: 1.0, + objectSeed: seed, + }) + const runB = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, { + density: 1.0, + objectSeed: seed, + }) + + expect(runA.placedObjects.length).toBe(runB.placedObjects.length) + expect(runA.stats).toEqual(runB.stats) + + for (let i = 0; i < runA.placedObjects.length; i += 1) { + const objA = runA.placedObjects[i]! + const objB = runB.placedObjects[i]! + expect(objA).toEqual(objB) + } + + // Different seed should diverge + const runC = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Crypt', 18, { + density: 1.0, + objectSeed: 11111, + }) + const positionsA = runA.placedObjects.map(o => `${o.x},${o.y},${o.id}`).join(';') + const positionsC = runC.placedObjects.map(o => `${o.x},${o.y},${o.id}`).join(';') + expect(positionsA).not.toBe(positionsC) + }) + + test('supports authentic object IDs across different Acts', () => { + const { level, rooms } = createSyntheticDungeon() + + // Act 2 Tomb + const act2Result = populateDungeonObjects(rooms, 0, 0, level, 'Act 2 - Tomb', 66, { + density: 1.0, + objectSeed: 777, + }) + const act2Chests = act2Result.placedObjects.filter(o => o.category === 'chest') + expect(act2Chests.length).toBeGreaterThan(0) + for (const c of act2Chests) { + const allAct2Chests = [...CHEST_IDS_BY_ACT[2]!.super, ...CHEST_IDS_BY_ACT[2]!.normal] + expect(allAct2Chests).toContain(c.id) + } + + // Act 3 Kurast + const act3Result = populateDungeonObjects(rooms, 0, 0, level, 'Act 3 - Kurast', 80, { + density: 1.0, + objectSeed: 888, + }) + const act3Containers = act3Result.placedObjects.filter(o => o.category === 'container') + expect(act3Containers.length).toBeGreaterThan(0) + const act3Pool = CONTAINER_IDS_BY_ACT[3]! + const allAct3Containers = [ + ...act3Pool.barrels, + ...act3Pool.urns, + ...act3Pool.caskets, + ...act3Pool.crates, + ] + for (const cnt of act3Containers) { + expect(allAct3Containers).toContain(cnt.id) + } + }) + + test('respects category-specific density and disableDynamicObjects options', () => { + const { level, rooms } = createSyntheticDungeon() + + // Disable all dynamic objects + const disabledRun = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { + disableDynamicObjects: true, + }) + expect(disabledRun.placedObjects.length).toBe(0) + expect(disabledRun.stats.dynamicObjectsCount).toBe(0) + + // Suppress shrines and chests, but keep racks and containers + const racksOnly = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Barracks', 28, { + shrineDensity: 0, + chestDensity: 0, + rackDensity: 2.0, + containerDensity: 0, + objectSeed: 123, + }) + expect(racksOnly.stats.shrinesSpawned).toBe(0) + expect(racksOnly.stats.chestsSpawned).toBe(0) + expect(racksOnly.stats.containersSpawned).toBe(0) + expect(racksOnly.stats.racksSpawned).toBeGreaterThan(0) + expect(racksOnly.placedObjects.every(o => o.category === 'rack')).toBe(true) + }) +}) + +describe('generateMaze end-to-end dynamic object integration', () => { + test('populates result.objects and result.level.objects during maze generation', () => { + const pieces = makeTestMazePieces('Act 1 - Cave', 11) + const request: MazeRequest = { + levelId: 2, + levelName: 'Cave Level 1', + sectionSize: 10, + minRooms: 6, + merge: 0, + seed: 12345, + pieces, + } + + const result = generateMaze(request) + + expect(result.level).toBeDefined() + expect(result.objects).toBeDefined() + expect(result.objects.length).toBeGreaterThan(0) + + // result.stats must record dynamic object counters + expect(result.stats.dynamicObjectsCount).toBe(result.objects.length) + expect(result.stats.objects).toBe(result.level.objects.length) + expect(result.stats.dynamicObjects).toBe(result.objects) + + // Subtypes counts in stats must match objects in result.objects + const shrineCount = result.objects.filter(o => o.category === 'shrine').length + const chestCount = result.objects.filter(o => o.category === 'chest').length + const rackCount = result.objects.filter(o => o.category === 'rack').length + const containerCount = result.objects.filter(o => o.category === 'container').length + + expect(result.stats.shrinesSpawned).toBe(shrineCount) + expect(result.stats.chestsSpawned).toBe(chestCount) + expect(result.stats.racksSpawned).toBe(rackCount) + expect(result.stats.containersSpawned).toBe(containerCount) + + // Level objects array must contain all dynamic objects + for (const dynObj of result.objects) { + expect(result.level.objects).toContain(dynObj) + } + }) + + test('disables dynamic objects when objectDensity: 0 is specified', () => { + const pieces = makeTestMazePieces('Act 1 - Cave', 11) + const request: MazeRequest = { + levelId: 2, + levelName: 'Cave Level 1', + sectionSize: 10, + minRooms: 5, + merge: 0, + seed: 54321, + pieces, + objectDensity: 0, + } + + const result = generateMaze(request) + expect(result.objects.length).toBe(0) + expect(result.stats.dynamicObjectsCount).toBe(0) + expect(result.level.objects.length).toBe(0) + }) + + test('retains pre-existing static DS1 objects alongside dynamically spawned objects', () => { + // Piece with 1 pre-existing static object (e.g. static torch or decoration) + const staticObject: Ds1Object = { + type: 2, + id: 99, + x: 25, + y: 25, + flags: 0, + } + const pieces = makeTestMazePieces('Act 1 - Cave', 11, [staticObject]) + + const request: MazeRequest = { + levelId: 2, + levelName: 'Cave Level 1', + sectionSize: 10, + minRooms: 5, + merge: 0, + seed: 9999, + pieces, + } + + const result = generateMaze(request) + const dynamicCount = result.objects.length + expect(dynamicCount).toBeGreaterThan(0) + expect(result.level.objects.length).toBeGreaterThan(dynamicCount) + + // Pre-existing static objects are preserved + const foundStatic = result.level.objects.some(o => o.id === 99) + expect(foundStatic).toBe(true) + }) +})