diablo2-web/tests/maze-objects.test.ts

539 lines
18 KiB
TypeScript

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