668 lines
24 KiB
TypeScript
668 lines
24 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,
|
|
resolveObjectsTxtId,
|
|
validateDungeonObjectPools,
|
|
type MazeRoomDescriptor,
|
|
type PlacedMazeObject,
|
|
type WallAlignment,
|
|
} from '../src/game/maze-objects.ts'
|
|
import {
|
|
generateMaze,
|
|
discoverMazePieceFamilies,
|
|
classifyMazePieceName,
|
|
type MazePiece,
|
|
type MazeRequest,
|
|
} from '../src/game/maze.ts'
|
|
import {
|
|
ACT_POPULATION_CONFIG,
|
|
validateActPopulationConfig,
|
|
} from '../src/game/wilderness.ts'
|
|
import type { D2Table } from '../src/game/acts.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('strictly prefers corridor rooms (degree === 2) over dead-ends for rack candidates', () => {
|
|
const { level, rooms } = createSyntheticDungeon()
|
|
// Room 0 is dead-end ([0, 0]), Room 1 is corridor ([10, 0]), Room 2 is dead-end ([20, 0])
|
|
// Using non-barracks level type so isBarracks is false
|
|
const result = populateDungeonObjects(rooms, 0, 0, level, 'Act 1 - Cave', 2, {
|
|
shrineDensity: 0,
|
|
chestDensity: 0,
|
|
containerDensity: 0,
|
|
rackDensity: 2.0,
|
|
objectSeed: 123,
|
|
})
|
|
const racks = result.placedObjects.filter(o => o.category === 'rack')
|
|
expect(racks.length).toBeGreaterThan(0)
|
|
for (const rack of racks) {
|
|
// Must be placed in corridor room (Room 1, cellX in [10, 19])
|
|
const cellX = Math.floor(rack.x / 5)
|
|
expect(cellX).toBeGreaterThanOrEqual(10)
|
|
expect(cellX).toBeLessThan(20)
|
|
}
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('Issue #61 Step 4: Objects.txt mapping validation and LvlPrest piece families', () => {
|
|
describe('validateDungeonObjectPools', () => {
|
|
test('validates all canonical dungeon object pools against embedded data without errors', () => {
|
|
const result = validateDungeonObjectPools()
|
|
expect(result.valid).toBe(true)
|
|
expect(result.errors).toEqual([])
|
|
expect(result.totalChecked).toBe(223)
|
|
})
|
|
|
|
test('detects invalid or missing object IDs when an incomplete objectsTable is provided', () => {
|
|
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1'], ['2']] }
|
|
const result = validateDungeonObjectPools(mockObjectsTable)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors.length).toBeGreaterThan(0)
|
|
expect(result.errors[0]).toContain('missing in Objects.txt')
|
|
})
|
|
})
|
|
|
|
describe('resolveObjectsTxtId', () => {
|
|
test('resolves known DS1 Type 2 IDs to Objects.txt row IDs across Acts', () => {
|
|
expect(resolveObjectsTxtId(1, 2)).toBe(39) // Refilling Shrine -> Objects.txt ID 39
|
|
expect(resolveObjectsTxtId(1, 5)).toBe(5) // Chest C1 -> Objects.txt ID 5
|
|
expect(resolveObjectsTxtId(1, 99999)).toBeNull()
|
|
})
|
|
|
|
test('resolves with explicit objectsTable filter', () => {
|
|
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['39'], ['140']] }
|
|
expect(resolveObjectsTxtId(1, 2, mockObjectsTable)).toBe(39)
|
|
expect(resolveObjectsTxtId(1, 6, mockObjectsTable)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('validateActPopulationConfig', () => {
|
|
test('validates ACT_POPULATION_CONFIG for all 5 acts against embedded data', () => {
|
|
for (let act = 1; act <= 5; act += 1) {
|
|
const config = ACT_POPULATION_CONFIG[act]!
|
|
const result = validateActPopulationConfig(config, act)
|
|
expect(result.valid).toBe(true)
|
|
expect(result.errors).toEqual([])
|
|
expect(result.totalChecked).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
test('detects invalid IDs in population config with incomplete table', () => {
|
|
const mockObjectsTable: D2Table = { header: ['Id'], rows: [['1']] }
|
|
const config = ACT_POPULATION_CONFIG[1]!
|
|
const result = validateActPopulationConfig(config, 1, mockObjectsTable)
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('discoverMazePieceFamilies', () => {
|
|
test('returns static families when lvlPrestTable is omitted', () => {
|
|
expect(discoverMazePieceFamilies('Act 2 - Harem')).toEqual(['Act 2 - Corrupt Harem', 'Act 2 - Harem'])
|
|
expect(discoverMazePieceFamilies('Act 3 - Kurast')).toEqual(['Act 3 - Mephisto', 'Act 3 - Kurast'])
|
|
expect(discoverMazePieceFamilies('Act 3 - Dungeon')).toEqual(['Act 3 - Dungeon', 'Act 3 - Temple'])
|
|
expect(discoverMazePieceFamilies('Act 4 - Lava')).toEqual(['Act 4 - Lava', 'Act 4 - Bridge'])
|
|
expect(discoverMazePieceFamilies('Act 5 - Ice Caves')).toEqual(['Act 5 - Ice Caves', 'Act 5 - Ice'])
|
|
expect(discoverMazePieceFamilies('Act 5 - Baal')).toEqual(['Act 5 - Baal', 'Act 5 - ThroneRoom'])
|
|
expect(discoverMazePieceFamilies('Unknown Theme')).toEqual(['Unknown Theme'])
|
|
})
|
|
|
|
test('filters to families present in lvlPrestTable when provided', () => {
|
|
const mockLvlPrest: D2Table = {
|
|
header: ['Def', 'Name'],
|
|
rows: [
|
|
['100', 'Act 2 - Harem NSEW'],
|
|
['101', 'Act 2 - Harem N'],
|
|
],
|
|
}
|
|
expect(discoverMazePieceFamilies('Act 2 - Harem', mockLvlPrest)).toEqual(['Act 2 - Harem'])
|
|
})
|
|
|
|
test('falls back to static families when table has no matching rows', () => {
|
|
const emptyLvlPrest: D2Table = { header: ['Def', 'Name'], rows: [] }
|
|
expect(discoverMazePieceFamilies('Act 2 - Harem', emptyLvlPrest)).toEqual([
|
|
'Act 2 - Corrupt Harem',
|
|
'Act 2 - Harem',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('classifyMazePieceName with optional lvlPrestTable', () => {
|
|
test('classifies piece names with and without lvlPrestTable', () => {
|
|
const withoutTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem')
|
|
expect(withoutTable).toEqual({ kind: 'room', sides: 'NSEW' })
|
|
|
|
const mockLvlPrest: D2Table = {
|
|
header: ['Def', 'Name'],
|
|
rows: [['100', 'Act 2 - Harem NSEW']],
|
|
}
|
|
const withTable = classifyMazePieceName('Act 2 - Harem NSEW', 'Act 2 - Harem', mockLvlPrest)
|
|
expect(withTable).toEqual({ kind: 'room', sides: 'NSEW' })
|
|
})
|
|
})
|
|
})
|
|
|
|
|