Merge branch 'fix/issue-50'
# Conflicts: # src/game/maze.ts
This commit is contained in:
commit
4f8e077bbb
|
|
@ -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<number, number>
|
||||
}
|
||||
|
||||
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<number, { super: readonly number[]; normal: readonly number[] }> = {
|
||||
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<number>()
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +82,23 @@ import { Rng } from './rng.ts'
|
|||
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||
export { SUB_TILES_PER_TILE }
|
||||
import { type SpecialPassName, runMazeSpecialPasses } from './maze-special-passes.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
|
||||
|
|
@ -316,12 +333,18 @@ export interface MazeRequest {
|
|||
* If provided and `maxCellsY` is not specified, `maxCellsY` is computed as `maxSectionsY * (sectionHeight ?? sectionSize)`.
|
||||
*/
|
||||
readonly maxSectionsY?: number | undefined
|
||||
/** 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<string, unknown>
|
||||
}
|
||||
|
|
@ -2222,8 +2245,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,
|
||||
|
|
@ -2240,7 +2304,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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue