485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
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)
|
|
})
|
|
})
|