import { describe, expect, test } from 'vitest' import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' import { populateDungeonObjects, isOpenFloor, 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) }) }) /** * Regression cover for the `|| true` that used to close `isOpenFloor`'s content * test (issue #58 item 2). With it, every `Ds1Floor` entry counted as floor art, * so objects were seeded on cells the level never paints. */ describe('isOpenFloor content check', () => { /** One cell grid with the given floor entries and no walls. */ function grid(floors: Ds1Cell['floors']): Ds1Cell[][] { return [[{ walls: [], floors, shadows: [], substitutions: [] }]] } const painted = { prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } const empty = { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false } test('a painted floor cell is open floor', () => { expect(isOpenFloor(grid([painted]), 0, 0)).toBe(true) }) test('a cell whose only floor entry is all-zero is not open floor', () => { expect(isOpenFloor(grid([empty]), 0, 0)).toBe(false) }) test('style or sequence alone still counts as art', () => { expect(isOpenFloor(grid([{ ...empty, style: 3 }]), 0, 0)).toBe(true) expect(isOpenFloor(grid([{ ...empty, sequence: 7 }]), 0, 0)).toBe(true) }) test('a hidden painted floor does not rescue an otherwise empty cell', () => { expect(isOpenFloor(grid([{ ...painted, hidden: true }]), 0, 0)).toBe(false) }) test('a multi-layer cell is open floor when any layer carries art', () => { expect(isOpenFloor(grid([empty, painted]), 0, 0)).toBe(true) expect(isOpenFloor(grid([empty, empty]), 0, 0)).toBe(false) }) test('objects are not seeded into a level whose floors are all empty slots', () => { const { level, rooms } = createSyntheticDungeon() for (const row of level.cells) { for (const cell of row) { ;(cell.floors as Ds1Cell['floors'][number][]).splice( 0, cell.floors.length, { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }, ) } } const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, { density: 1.0 }, new Rng(7)) expect(result.placedObjects.length).toBe(0) }) })