Compare commits

...

42 Commits

Author SHA1 Message Date
troytt 274ffae71e Merge branch 'feat/monster-room-placement' 2026-09-17 09:11:24 +00:00
troytt 5aba7487f3 Merge branch 'fix/issue-16' into main 2026-09-17 09:10:59 +00:00
troytt 3951e70ef4 Merge branch 'fix/issue-20' 2026-09-17 09:09:01 +00:00
troytt 6f01c8de89 Merge branch 'fix/issue-19' into main 2026-09-17 09:08:46 +00:00
troytt db3d6ffa84 Merge branch 'fix/issue-57' into main 2026-09-17 09:07:38 +00:00
troytt 12718ebcd6 Merge branch 'fix/issue-56' into main 2026-09-17 09:03:15 +00:00
troytt 74d2d1e36f test(wilderness): reconcile caravan and avenue floor style check with act topography passes 2026-09-17 09:02:05 +00:00
troytt dfce2f6cef Merge branch 'fix/issue-55' 2026-09-17 08:58:45 +00:00
troytt a6ab21881d Merge branch 'fix/issue-54'
# Conflicts:
#	src/game/wilderness.ts
2026-09-17 08:58:22 +00:00
troytt 2de9835b34 Merge branch 'fix/issue-53'
# Conflicts:
#	src/game/wilderness.ts
2026-09-17 08:56:41 +00:00
troytt 3ab024d12f Merge branch 'fix/issue-52' 2026-09-17 08:51:58 +00:00
troytt 334b957ab6 Merge branch 'fix/issue-51'
# Conflicts:
#	src/game/wilderness.ts
2026-09-17 08:51:55 +00:00
troytt 4f8e077bbb Merge branch 'fix/issue-50'
# Conflicts:
#	src/game/maze.ts
2026-09-17 08:50:47 +00:00
troytt 4c584db273 Merge branch 'fix/issue-49'
# Conflicts:
#	src/game/maze.ts
2026-09-17 08:49:32 +00:00
troytt 893e9b8550 Merge branch 'fix/issue-48'
# Conflicts:
#	src/game/maze.ts
2026-09-17 08:41:40 +00:00
troytt e1163b1253 Merge branch 'fix/issue-47'
# Conflicts:
#	src/game/maze.ts
2026-09-17 08:40:55 +00:00
troytt c310119ed2 Merge branch 'fix/issue-46' 2026-09-17 08:40:20 +00:00
troytt 80b2fd0f71 Merge branch 'fix/issue-45' 2026-09-17 08:40:15 +00:00
troytt c83499c591 Merge branch 'fix/issue-44'
# Conflicts:
#	src/scene/act-scene.ts
2026-09-17 08:40:10 +00:00
troytt 9af2c07c38 Merge branch 'fix/issue-43' 2026-09-17 08:39:26 +00:00
troytt c2bda29211 Merge remote-tracking branch 'origin/feat/monster-streaming' into feat/monster-room-placement 2026-09-17 08:36:02 +00:00
troytt 8e46b64156 feat(monsters): room-local pack allocation and clustering placement 2026-09-17 08:35:11 +00:00
troytt 71fdaaefe3 feat(sim/monsters): lazy room population with bPopulated and active/sleeping streaming 2026-09-17 08:33:19 +00:00
troytt 11e2e42731 perf(render): optimize GL state with immutable texStorage2D, uniform caching, and configurable discard (fixes #20) 2026-09-17 08:16:05 +00:00
troytt 33585afe8d test(render/perf): comprehensive lifecycle verification and performance regression suite (fixes #16) 2026-09-17 08:15:13 +00:00
troytt 167928a31f feat(sim/loop): decouple simulation from rAF with independent timer and separate TPS/FPS metrics (fixes #19) 2026-09-17 08:12:57 +00:00
troytt 92d6731dae feat(map-gen/wilderness): data-driven wilderness SuperUniques and landmark entities from SuperUniques.txt (fixes #57) 2026-09-17 07:49:31 +00:00
troytt 46b7166f22 feat(map-gen/wilderness): thematic clustering and dispersed distribution for waypoints and shrines (fixes #56) 2026-09-17 07:36:39 +00:00
troytt 2fe5f4d892 feat(map-gen/wilderness): dynamic siege trench and fortification generation for Bloody Foothills (fixes #54) 2026-09-17 07:30:54 +00:00
troytt 3f463ba777 feat(map-gen/wilderness): extend road and stone divider networks to Acts 2, 3, and 5 (fixes #55) 2026-09-17 07:28:07 +00:00
troytt 81d2172cca feat(map-gen/maze): implement 11 official dungeon special passes (fixes #49) 2026-09-17 07:27:47 +00:00
troytt 723f5dce27 feat(map-gen/wilderness): implement Act 3 jungle river network and branch topology (fixes #53) 2026-09-17 07:25:07 +00:00
troytt 8baf91c51f feat(map-gen/wilderness): implement outdoor level initializers for acts 2-5 (fixes #51) 2026-09-17 07:11:19 +00:00
troytt ffc2b297f6 feat(map-gen/maze): implement dynamic dungeon object spawning pipeline (fixes #50) 2026-09-17 07:08:47 +00:00
troytt 12588ea948 feat(map-gen/wilderness): support multi-DT1 weighted ground tile blending (fixes #52) 2026-09-17 07:08:24 +00:00
troytt 801afd7ddb fix(map-gen/maze): enforce Levels.txt global bounds SizeX/SizeY on room growth (fixes #48) 2026-09-17 06:59:31 +00:00
troytt c56b808d7a feat(renderer/map): support animated tiles loop playback (fixes #43) 2026-09-17 06:34:02 +00:00
troytt 776e2d3c20 feat(map-gen/maze): integrate waypoint room into maze preset replacement pass (fixes #47) 2026-09-17 06:33:16 +00:00
troytt 8bb99ded7e refactor(map-gen/maze): align special room placement with D2 scanning rules (fixes #46) 2026-09-17 06:20:59 +00:00
troytt 72aa835260 refactor(map-gen/preset): move Chaos Sanctuary (Level 108) into preset pipeline (fixes #45) 2026-09-17 06:20:35 +00:00
troytt 57c13bdc26 feat(renderer/roofs): implement dynamic roof fade when player enters shelter (fixes #44) 2026-09-17 06:19:01 +00:00
troytt a01cffa0b5 refactor(map-gen/preset): fix variant selection and seam orientation alignment (fixes #42) 2026-09-17 06:18:42 +00:00
47 changed files with 17972 additions and 893 deletions

View File

@ -46,9 +46,11 @@ import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts'
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind, MazeWaypoint } from '../src/game/maze.ts'
import { generateWilderness } from '../src/game/wilderness.ts'
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { generatePreset } from '../src/game/preset.ts'
import type { PresetPiece } from '../src/game/preset.ts'
export interface MazeWarp {
readonly room: number
readonly direction: 'up' | 'down'
@ -537,6 +539,7 @@ function buildSceneLinks(
entrances: readonly WildernessEntrance[],
mazeWarps: readonly MazeWarp[],
waypointCells: readonly { x: number; y: number }[],
mazeWaypoints?: readonly MazeWaypoint[],
): SceneLinks {
const outEntrances: SceneEntrance[] = []
const outWarps: SceneWarp[] = []
@ -781,7 +784,7 @@ function buildSceneLinks(
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
const waypoints: SceneWaypoint[] = []
if (waypointId !== 255) {
const fromArt = waypointCells[0]
const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined)
const spot = fromArt ?? findWaypointSpot(grid, region)
if (spot !== null && spot !== undefined) {
const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot
@ -1154,6 +1157,7 @@ async function bakeDs1Variant(
generatorLinks: {
readonly entrances: readonly WildernessEntrance[]
readonly mazeWarps: readonly MazeWarp[]
readonly mazeWaypoints?: readonly MazeWaypoint[]
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
} = { entrances: [], mazeWarps: [] },
): Promise<void> {
@ -1383,6 +1387,7 @@ async function bakeDs1Variant(
generatorLinks.entrances,
generatorLinks.mazeWarps,
waypointCells,
generatorLinks.mazeWaypoints,
)
const sceneJson = {
@ -1522,10 +1527,28 @@ for (const entry of LEVELS) {
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const label = `${entry.slug}-${slugOf(ds1Name)}`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
if (entry.levelId === 108) {
const pieces: PresetPiece[] = []
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
pieces.push({ name: pieceName, levels: [level] })
}
const result = generatePreset({
levelId: entry.levelId,
levelName: info.levelName,
sizeX: info.sizeX > 0 ? info.sizeX : 120,
sizeY: info.sizeY > 0 ? info.sizeY : 120,
pieces,
})
const label = `${entry.slug}-chaos`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, result.level, 'preset:chaos-sanctuary', label, levelSeed(info.levelName))
} else {
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const label = `${entry.slug}-${slugOf(ds1Name)}`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
}
}
} else if (entry.kind === 'maze') {
const type = levelType(levelRow)
@ -1538,6 +1561,8 @@ for (const entry of LEVELS) {
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
const merge = Number(cell(lvlmaze, row, 'Merge'))
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`)
@ -1561,11 +1586,17 @@ for (const entry of LEVELS) {
merge,
seed,
pieces,
maxCellsX: sizeX > sectionX ? sizeX : undefined,
maxCellsY: sizeY > sectionY ? sizeY : undefined,
})
await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
result.level, `generated:${label}`, label, seed,
{ entrances: [], mazeWarps: (result.stats.warps ?? []) as MazeWarp[] },
{
entrances: [],
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
},
)
} catch (err) {
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)

View File

@ -523,7 +523,7 @@ async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: n
* ------------------------------------------------------------------------- */
/** The generators that must stay browser-safe. */
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts']
const BROWSER_SAFE_FILES = ['src/game/maze.ts', 'src/game/wilderness.ts', 'src/game/preset.ts']
/**
* Assert the generators import nothing Node-only.

View File

@ -4,6 +4,7 @@
* batching, indexed geometry and draw-call instrumentation added for Issue #16.
*/
import { SpriteRenderer, RendererError } from '../src/render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../src/render/cull.ts'
interface MockResource {
id: number
@ -26,6 +27,12 @@ function createMockCanvas(): {
eventListeners: Map<string, Set<EventListener>>
glDrawCalls: number
shaderSources: string[]
texStorage2DCalls: number
texSubImage2DCalls: number
uniformUploadCalls: number
uniformCameraCalls: number
uniformViewportCalls: number
uniformZoomCalls: number
}
setContextLost: (lost: boolean) => void
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
@ -47,6 +54,12 @@ function createMockCanvas(): {
eventListeners: new Map<string, Set<EventListener>>(),
glDrawCalls: 0,
shaderSources: [] as string[],
texStorage2DCalls: 0,
texSubImage2DCalls: 0,
uniformUploadCalls: 0,
uniformCameraCalls: 0,
uniformViewportCalls: 0,
uniformZoomCalls: 0,
}
const gl: any = {
@ -58,6 +71,9 @@ function createMockCanvas(): {
MAX_TEXTURE_IMAGE_UNITS: 34930,
TEXTURE_2D: 3553,
RGBA: 6408,
RGBA8: 32856,
R8: 33321,
RED: 6403,
UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125,
FLOAT: 5126,
@ -142,12 +158,21 @@ function createMockCanvas(): {
bindTexture: () => {},
texParameteri: () => {},
texImage2D: () => {},
texStorage2D: () => { stats.texStorage2DCalls += 1 },
texSubImage2D: () => { stats.texSubImage2DCalls += 1 },
pixelStorei: () => {},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: () => {},
uniform1f: () => {},
uniform2f: (loc: any) => {
stats.uniformUploadCalls += 1
if (loc?.name === 'u_camera') stats.uniformCameraCalls += 1
if (loc?.name === 'u_viewport') stats.uniformViewportCalls += 1
},
uniform1f: (loc: any) => {
stats.uniformUploadCalls += 1
if (loc?.name === 'u_zoom') stats.uniformZoomCalls += 1
},
uniform1i: () => {},
uniform1iv: () => {},
@ -234,7 +259,7 @@ function runTests(): void {
assert(mock.stats.createdPrograms.size === 1, 'Program created')
assert(mock.stats.createdVAOs.size === 1, 'VAO created')
assert(mock.stats.createdBuffers.size === 2, 'Vertex and index buffers created')
assert(mock.stats.createdTextures.size === 2, 'Initial textures created (atlas + white)')
assert(mock.stats.createdTextures.size === 3, 'Initial textures created (atlas + white + palette)')
assert(renderer.isDisposed === false, 'Renderer is active and not disposed')
renderer.dispose()
@ -246,7 +271,7 @@ function runTests(): void {
const renderer = new SpriteRenderer(mock.canvas)
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(64 * 64 * 4), width: 64, height: 64 })
assert(mock.stats.createdTextures.size === 3, 'addAtlas creates a new GPU texture (3 total)')
assert(mock.stats.createdTextures.size === 4, 'addAtlas creates a new GPU texture (4 total)')
// Draw with this extra atlas
renderer.begin({ x: 0, y: 0, zoom: 1 })
@ -273,11 +298,11 @@ function runTests(): void {
void extra1
void extra2
assert(mock.stats.createdTextures.size === 4, '4 total textures created before dispose')
assert(mock.stats.createdTextures.size === 5, '5 total textures created before dispose')
renderer.dispose()
assert(renderer.isDisposed === true, 'isDisposed is true after dispose()')
assert(mock.stats.deletedTextures.size === 4, 'All 4 textures were deleted on dispose()')
assert(mock.stats.deletedTextures.size === 5, 'All 5 textures were deleted on dispose()')
assert(mock.stats.deletedBuffers.size === 2, 'Vertex and index buffers were deleted on dispose()')
assert(mock.stats.deletedVAOs.size === 1, 'VAO was deleted on dispose()')
assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()')
@ -437,6 +462,312 @@ function runTests(): void {
renderer.dispose()
}
// Test 11 (Issue #20): Immutable texture storage (texStorage2D + texSubImage2D)
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
// In constructor: whiteTexture and setPalette(defaultPal) should have used texStorage2D + texSubImage2D
assert(mock.stats.texStorage2DCalls >= 2, 'texStorage2D called for initial whiteTexture and paletteTexture')
assert(mock.stats.texSubImage2DCalls >= 2, 'texSubImage2D called for initial whiteTexture and paletteTexture')
const beforeStorage = mock.stats.texStorage2DCalls
const beforeSub = mock.stats.texSubImage2DCalls
// setAtlas uses texStorage2D on first upload
const atlas = {
pixels: new Uint8ClampedArray(64 * 64 * 4),
width: 64,
height: 64,
groups: [],
}
renderer.setAtlas(atlas)
assert(mock.stats.texStorage2DCalls === beforeStorage + 1, 'setAtlas allocates immutable storage via texStorage2D')
assert(mock.stats.texSubImage2DCalls === beforeSub + 1, 'setAtlas uploads data via texSubImage2D')
// Calling setAtlas again with SAME dimensions reuses storage (no extra texStorage2D, only texSubImage2D)
renderer.setAtlas(atlas)
assert(mock.stats.texStorage2DCalls === beforeStorage + 1, 'setAtlas with same dimensions reuses immutable storage')
assert(mock.stats.texSubImage2DCalls === beforeSub + 2, 'setAtlas with same dimensions uploads data via texSubImage2D')
// addAtlas uses texStorage2D and texSubImage2D
renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 })
assert(mock.stats.texStorage2DCalls === beforeStorage + 2, 'addAtlas allocates immutable storage via texStorage2D')
assert(mock.stats.texSubImage2DCalls === beforeSub + 3, 'addAtlas uploads data via texSubImage2D')
// createAtlas alias also uses texStorage2D and texSubImage2D
renderer.createAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
assert(mock.stats.texStorage2DCalls === beforeStorage + 3, 'createAtlas allocates immutable storage via texStorage2D')
assert(mock.stats.texSubImage2DCalls === beforeSub + 4, 'createAtlas uploads data via texSubImage2D')
// addIndexedAtlas uses texStorage2D (R8) and texSubImage2D (RED)
renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
assert(mock.stats.texStorage2DCalls === beforeStorage + 4, 'addIndexedAtlas allocates immutable storage via texStorage2D')
assert(mock.stats.texSubImage2DCalls === beforeSub + 5, 'addIndexedAtlas uploads data via texSubImage2D')
renderer.dispose()
}
// Test 12 (Issue #20): Uniform dirty checking and caching in flush()
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const frame = { x: 0, y: 0, width: 8, height: 8 }
renderer.begin({ x: 100, y: 200, zoom: 1.5 })
renderer.draw(frame, 0, 0)
renderer.flush()
assert(mock.stats.uniformCameraCalls === 1, 'First flush uploads camera uniform')
assert(mock.stats.uniformViewportCalls === 1, 'First flush uploads viewport uniform')
assert(mock.stats.uniformZoomCalls === 1, 'First flush uploads zoom uniform')
// Second flush with identical camera/viewport/zoom should NOT re-upload any uniforms
renderer.draw(frame, 8, 8)
renderer.flush()
assert(mock.stats.uniformCameraCalls === 1, 'Consecutive flush with same camera skips camera uniform upload')
assert(mock.stats.uniformViewportCalls === 1, 'Consecutive flush with same viewport skips viewport uniform upload')
assert(mock.stats.uniformZoomCalls === 1, 'Consecutive flush with same zoom skips zoom uniform upload')
// Change only camera
renderer.begin({ x: 150, y: 200, zoom: 1.5 })
renderer.draw(frame, 0, 0)
renderer.flush()
assert(mock.stats.uniformCameraCalls === 2, 'Only camera uniform is uploaded when camera changes')
assert(mock.stats.uniformViewportCalls === 1, 'Viewport uniform is not uploaded when viewport did not change')
assert(mock.stats.uniformZoomCalls === 1, 'Zoom uniform is not uploaded when zoom did not change')
// Change only zoom
renderer.begin({ x: 150, y: 200, zoom: 2.0 })
renderer.draw(frame, 0, 0)
renderer.flush()
assert(mock.stats.uniformCameraCalls === 2, 'Camera uniform is not uploaded when camera did not change')
assert(mock.stats.uniformZoomCalls === 2, 'Only zoom uniform is uploaded when zoom changes')
renderer.dispose()
}
// Test 13 (Issue #20): Configurable shader alphaDiscard option
{
const mockDefault = createMockCanvas()
const rendererDefault = new SpriteRenderer(mockDefault.canvas)
assert(rendererDefault.alphaDiscard === true, 'alphaDiscard defaults to true')
const fsDefault = mockDefault.stats.shaderSources.find(s => s.includes('outColor')) ?? ''
assert(fsDefault.includes('discard;'), 'Default shader includes discard for transparent fragments')
rendererDefault.dispose()
const mockNoDiscard = createMockCanvas()
const rendererNoDiscard = new SpriteRenderer(mockNoDiscard.canvas, { alphaDiscard: false })
assert(rendererNoDiscard.alphaDiscard === false, 'alphaDiscard can be disabled via RendererOptions')
const fsNoDiscard = mockNoDiscard.stats.shaderSources.find(s => s.includes('outColor')) ?? ''
assert(!fsNoDiscard.includes('discard;'), 'Shader with alphaDiscard=false omits discard instructions for mobile GPU optimization')
assert(fsNoDiscard.includes('outColor = texel * v_tint;'), 'Pure alpha blending used when alphaDiscard=false')
rendererNoDiscard.dispose()
}
// Test 14 (Issue #16): Multi-page level maintains draw calls <= 1 (within batch limits)
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
// A complex multi-page level with 8 pages:
// Floors (2 pages), Walls (2 pages), Roofs (1 page), Objects (1 page), Character (1 page), Monsters (1 page)
const pages = Array.from({ length: 8 }, () => page(renderer))
const frame = { x: 0, y: 0, width: 16, height: 16 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const beforeDrawCalls = mock.stats.glDrawCalls
// Simulate drawing a full frame in painter's order across these 8 pages (1200 quads total)
// 1. Floors (pages 0 & 1)
for (let i = 0; i < 400; i += 1) {
renderer.draw(frame, i * 4, 0, { atlas: pages[i % 2] })
}
// 2. Walls and objects interleaved (pages 2, 3, 5)
for (let i = 0; i < 500; i += 1) {
const pageIndex = i % 3 === 0 ? 5 : i % 3 === 1 ? 2 : 3
renderer.draw(frame, i * 4, 100, { atlas: pages[pageIndex] })
}
// 3. Characters and monsters (pages 6, 7)
for (let i = 0; i < 100; i += 1) {
renderer.draw(frame, i * 10, 200, { atlas: pages[6 + (i % 2)] })
}
// 4. Roofs (page 4)
for (let i = 0; i < 200; i += 1) {
renderer.draw(frame, i * 4, 300, { atlas: pages[4] })
}
renderer.flush()
assert(renderer.quadsSubmitted === 1200, 'Multi-page level submitted all 1200 quads')
assert(mock.stats.glDrawCalls - beforeDrawCalls === 1, 'Multi-page level (8 pages) rendered in exactly 1 draw call')
assert(renderer.drawCalls === 1, 'renderer.drawCalls reports 1 draw call')
// Now verify when distinct pages exceed the hardware texture unit budget
const overBudgetPages = Array.from({ length: renderer.textureUnits + 4 }, () => page(renderer))
renderer.begin({ x: 0, y: 0, zoom: 1 })
const beforeOverCalls = mock.stats.glDrawCalls
for (let i = 0; i < overBudgetPages.length; i += 1) {
renderer.draw(frame, i * 16, 0, { atlas: overBudgetPages[i] })
}
renderer.flush()
const extraCalls = mock.stats.glDrawCalls - beforeOverCalls
assert(
extraCalls <= Math.ceil(overBudgetPages.length / renderer.textureUnits),
'Exceeding texture unit budget flushes minimally within batch unit limits',
)
renderer.dispose()
}
// Test 15 (Issue #16): Viewport culling achieves > 80% quad culling ratio on large levels
{
// Generate an 80x80 level (6400 quads) with standard tile size 80x40
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
const gridCols = 80
const gridRows = 80
for (let y = 0; y < gridRows; y += 1) {
for (let x = 0; x < gridCols; x += 1) {
quads.push({
x: x * 80,
y: y * 40,
frame: { width: 80, height: 40 },
})
}
}
const totalQuads = quads.length // 6400
const bounds = buildBounds(quads)
// Center camera at (3200, 1600) with standard 1280x720 viewport and zoom = 1
const view1x = viewportRect(3200, 1600, 1280, 720, 1.0)
let visible1x = 0
let culled1x = 0
const visibleIndices: number[] = []
for (let i = 0; i < totalQuads; i += 1) {
if (intersects(bounds, i, view1x)) {
visible1x += 1
visibleIndices.push(i)
} else {
culled1x += 1
}
}
const cullingRatio1x = culled1x / totalQuads
assert(totalQuads === 6400, 'Large level has 6400 quads')
assert(visible1x + culled1x === totalQuads, 'All quads accounted for in culling pass')
assert(
cullingRatio1x > 0.80,
`Viewport culling achieves > 80% culling ratio on 80x80 level (measured: ${(cullingRatio1x * 100).toFixed(1)}%)`,
)
// Verify painter's order preservation: visible indices must be strictly monotonic
let monotonic = true
for (let i = 1; i < visibleIndices.length; i += 1) {
if (visibleIndices[i]! <= visibleIndices[i - 1]!) {
monotonic = false
break
}
}
assert(monotonic, "Culled stream maintains strictly monotonic painter's order")
// Test with zoom = 2.0 (tighter viewport -> even higher culling ratio)
const view2x = viewportRect(3200, 1600, 1280, 720, 2.0)
let culled2x = 0
for (let i = 0; i < totalQuads; i += 1) {
if (!intersects(bounds, i, view2x)) culled2x += 1
}
const cullingRatio2x = culled2x / totalQuads
assert(
cullingRatio2x > cullingRatio1x && cullingRatio2x > 0.80,
`Zooming in increases culling ratio (measured: ${(cullingRatio2x * 100).toFixed(1)}%)`,
)
}
// Test 16 (Issue #16): HUD displays accurate measured metrics rather than hardcoded dummies
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const frame = { x: 0, y: 0, width: 16, height: 16 }
// Frame 1: Render 120 quads
const t0 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 120; i += 1) {
renderer.draw(frame, i, i, { atlas: a })
}
renderer.flush()
const renderMs1 = performance.now() - t0
const state1 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 880,
renderMs: renderMs1,
}
const hudText1 = `${String(state1.quadsDrawn)} 图元 / ${String(state1.drawCalls)} 批次(剔除 ${String(state1.culledDraws)})· ${state1.renderMs.toFixed(1)} ms`
assert(state1.drawCalls === 1, 'HUD state drawCalls accurately records 1 draw call')
assert(state1.quadsDrawn === 120, 'HUD state quadsDrawn accurately records 120 quads')
assert(state1.culledDraws === 880, 'HUD state culledDraws accurately records 880 culled quads')
assert(state1.renderMs >= 0, 'HUD state renderMs is a valid measured frame duration')
assert(hudText1.includes('120 图元 / 1 批次(剔除 880)'), 'HUD string accurately formats measured metrics')
// Frame 2: Render with over-budget pages to force 2 draw calls and 250 quads
const overPages = Array.from({ length: renderer.textureUnits + 1 }, () => page(renderer))
const t1 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 200; i += 1) {
renderer.draw(frame, i, i, { atlas: overPages[i % renderer.textureUnits] })
}
for (let i = 0; i < 50; i += 1) {
renderer.draw(frame, i, i, { atlas: overPages[renderer.textureUnits] })
}
renderer.flush()
const renderMs2 = performance.now() - t1
const state2 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 1750,
renderMs: renderMs2,
}
const hudText2 = `${String(state2.quadsDrawn)} 图元 / ${String(state2.drawCalls)} 批次(剔除 ${String(state2.culledDraws)})· ${state2.renderMs.toFixed(1)} ms`
assert(state2.drawCalls === 2, 'HUD state drawCalls dynamically updates to 2 (not a hardcoded dummy)')
assert(state2.quadsDrawn === 250, 'HUD state quadsDrawn dynamically updates to 250 (not a hardcoded dummy)')
assert(state2.culledDraws === 1750, 'HUD state culledDraws dynamically updates to 1750')
assert(hudText2.includes('250 图元 / 2 批次(剔除 1750)'), 'HUD string reflects dynamic update without hardcoded values')
renderer.dispose()
}
// Test 17 (Issue #16): Memory allocation and performance regression audit
{
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const a = page(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
// 1. Buffer growth audit: submitting > INITIAL_CAPACITY (8192) quads
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 9000; i += 1) {
renderer.draw(frame, i % 100, i % 100, { atlas: a })
}
assert(renderer.quadsSubmitted === 9000, 'Batch buffer dynamically grows past initial capacity (9000 quads)')
renderer.flush()
assert(renderer.drawCalls === 1, 'Large batch (>8192 quads) flushed in single draw call without memory error')
// 2. Texture cleanup audit on deleteAtlas
const tempAtlas = page(renderer)
const texturesBefore = mock.stats.deletedTextures.size
renderer.deleteAtlas(tempAtlas)
assert(mock.stats.deletedTextures.size === texturesBefore + 1, 'deleteAtlas promptly frees GPU texture memory')
renderer.dispose()
}
console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`)
if (failed > 0) process.exit(1)
}

View File

@ -123,6 +123,14 @@ export interface Dt1Tile {
* with the engine's own offset when it is wired up.
*/
readonly roofHeight: number
/**
* Animated flag, at file offset +7.
*
* Non-zero (specifically bit 0 set) when this tile is part of an animated sequence.
* For animated tiles, `rarityFrameIndex` specifies the 0-based animation frame index
* rather than a variant selection weight.
*/
readonly animated: number
/**
* Variant weight inside one `style:sequence` group, at file offset +32.
*
@ -240,6 +248,20 @@ export function subTileFlagsOf(raw: number): SubTileFlags {
}
}
/**
* Whether a DT1 tile is part of an animated sequence.
*
* In Diablo II DT1 files, byte +7 is the animated flag (bit 0 set for animated floor/wall tiles).
* When animated, `rarityFrameIndex` represents the 0-based animation frame index
* rather than a random variation weight.
*
* @param tile - the DT1 tile to check.
* @returns true if the tile is an animated frame.
*/
export function isAnimatedTile(tile: { readonly animated?: number }): boolean {
return typeof tile.animated === 'number' && ((tile.animated & 1) === 1 || tile.animated > 0)
}
/**
* Decode a DT1 file.
*
@ -315,6 +337,7 @@ export function decodeDt1(data: Uint8Array): Dt1 {
sequence: i32(data, at + 28),
materialFlags: u16(data, at + 6),
roofHeight: i16(data, at + 4),
animated: new DataView(data.buffer, data.byteOffset, data.byteLength).getUint8(at + 7),
rarityFrameIndex: i32(data, at + 32),
subTileFlags,
},

View File

@ -8,7 +8,7 @@
*
* `Levels.txt` `Act N - Town` → level id, act, palette index, level type
* `LvlTypes.txt` level type id → `File 1..File 32` (the DT1 libraries)
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 quadrants), Dt1Mask
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 variants / versions), Dt1Mask
* `data\\global\\palette\\actN\\pal.pl2` → the act's palette
*
* Two details cost real time to find and are encoded here:
@ -318,13 +318,13 @@ export function resolveActTown(tables: ActTables, act: number): LevelInfo {
export interface LoadedActTown {
/** The resolution result. */
readonly town: LevelInfo
/** The decoded DS1 quadrants. */
/** The decoded DS1 variants / versions. */
readonly levels: readonly Ds1[]
/** The decoded DT1 libraries, in file-list order. */
readonly libraries: readonly Dt1[]
/** The act palette. */
readonly palette: Pl2
/** Total cells across the quadrants. */
/** Total cells across the variants. */
readonly cells: number
}

180
src/game/animated-tiles.ts Normal file
View File

@ -0,0 +1,180 @@
/**
* DT1 Animated Tiles controller and runtime helpers.
*
* In Diablo II, certain environmental tiles (water ripples, lava, torches,
* fountains, cauldrons) are stored in DT1 format as animated sequences.
*
* Each animated tile group shares the same (style, sequence, type), with
* byte +7 (animated flag) set, and `rarityFrameIndex` indicating the 0-based
* animation frame index.
*
* Default playback speed is 10 frames per second (100ms per frame).
*/
import type { AtlasFrame } from '../render/atlas.ts'
/** Default playback duration per animated tile frame in milliseconds (10 FPS in Diablo II). */
export const DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS = 100
/** Game ticks per animation frame at standard 25Hz simulation rate (2.5 ticks/frame = 100ms/frame). */
export const DEFAULT_TICKS_PER_ANIMATED_FRAME = 2.5
/** One placed frame of an animated tile. */
export interface AnimatedFrame {
readonly frame: AtlasFrame
readonly page: number
}
/** An animated tile or drawable that can advance frames. */
export interface AnimatableTile {
frame: AtlasFrame
page: number
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
currentFrameIndex?: number | undefined
animTimeMs?: number | undefined
}
/**
* Compute the frame index given elapsed time and total frames.
* Automatically wraps around (modulo) to create a seamless loop.
*
* @param elapsedMs - elapsed milliseconds (game clock or timestamp).
* @param totalFrames - total number of frames in the animation sequence.
* @param frameDurationMs - duration of each frame in milliseconds (default 100ms / 10 FPS).
* @returns the 0-based frame index within [0, totalFrames - 1].
*/
export function computeFrameIndexFromTime(
elapsedMs: number,
totalFrames: number,
frameDurationMs = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
): number {
if (totalFrames <= 1) return 0
const duration = frameDurationMs > 0 ? frameDurationMs : DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS
return Math.floor(Math.max(0, elapsedMs) / duration) % totalFrames
}
/**
* Compute the frame index given game simulation ticks and total frames.
* Automatically wraps around (modulo) to create a seamless loop.
*
* @param tick - simulation tick count (at 25Hz).
* @param totalFrames - total number of frames in the animation sequence.
* @param ticksPerFrame - ticks elapsed per frame (default 2.5 ticks / frame).
* @returns the 0-based frame index within [0, totalFrames - 1].
*/
export function computeFrameIndexFromTick(
tick: number,
totalFrames: number,
ticksPerFrame = DEFAULT_TICKS_PER_ANIMATED_FRAME,
): number {
if (totalFrames <= 1) return 0
const rate = ticksPerFrame > 0 ? ticksPerFrame : DEFAULT_TICKS_PER_ANIMATED_FRAME
return Math.floor(Math.max(0, tick) / rate) % totalFrames
}
/**
* Advance a frame index by a given step with cycle wrapping.
*
* @param currentIndex - current frame index.
* @param frameCount - total number of frames.
* @param step - number of frames to advance (default 1, can be negative).
* @returns the new frame index within [0, frameCount - 1].
*/
export function advanceFrameIndex(currentIndex: number, frameCount: number, step = 1): number {
if (frameCount <= 1) return 0
const next = (currentIndex + step) % frameCount
return next >= 0 ? next : next + frameCount
}
/**
* Update an animatable tile's current frame according to clock (timestamp or tick).
*
* @param tile - the animatable tile to update.
* @param clock - current timestamp in ms or tick count.
* @param mode - 'time' for millisecond timestamps or 'tick' for simulation ticks.
* @param rate - optional override for frame duration (ms) or ticks per frame.
* @returns true if the frame index changed, false otherwise.
*/
export function updateAnimatableTile(
tile: AnimatableTile,
clock: number,
mode: 'time' | 'tick' = 'time',
rate?: number,
): boolean {
const frames = tile.animatedFrames
if (!frames || frames.length <= 1) return false
const targetIndex = mode === 'time'
? computeFrameIndexFromTime(clock, frames.length, rate)
: computeFrameIndexFromTick(clock, frames.length, rate)
if (targetIndex === tile.currentFrameIndex) return false
tile.currentFrameIndex = targetIndex
const targetFrame = frames[targetIndex]
if (targetFrame !== undefined) {
tile.frame = targetFrame.frame
tile.page = targetFrame.page
return true
}
return false
}
/**
* Advance an animatable tile by a discrete step.
*
* @param tile - the animatable tile.
* @param step - number of frames to advance (default 1).
* @returns true if the frame index changed.
*/
export function advanceAnimatableTile(tile: AnimatableTile, step = 1): boolean {
const frames = tile.animatedFrames
if (!frames || frames.length <= 1) return false
const currentIndex = tile.currentFrameIndex ?? 0
const nextIndex = advanceFrameIndex(currentIndex, frames.length, step)
if (nextIndex === tile.currentFrameIndex) return false
tile.currentFrameIndex = nextIndex
const targetFrame = frames[nextIndex]
if (targetFrame !== undefined) {
tile.frame = targetFrame.frame
tile.page = targetFrame.page
return true
}
return false
}
/**
* Update a list of animatable tiles according to clock (timestamp or tick).
*
* @param tiles - array of animatable tiles.
* @param clock - timestamp in ms or simulation tick.
* @param mode - 'time' or 'tick'.
* @param rate - optional rate override.
* @returns the number of tiles whose active frame changed.
*/
export function updateAnimatableTiles(
tiles: readonly AnimatableTile[],
clock: number,
mode: 'time' | 'tick' = 'time',
rate?: number,
): number {
let updated = 0
for (let i = 0; i < tiles.length; i += 1) {
if (updateAnimatableTile(tiles[i]!, clock, mode, rate)) {
updated += 1
}
}
return updated
}
/**
* Advance all animatable tiles by a discrete step.
*
* @param tiles - array of animatable tiles.
* @param step - step count.
* @returns the number of tiles updated.
*/
export function advanceAnimatableTiles(tiles: readonly AnimatableTile[], step = 1): number {
let updated = 0
for (let i = 0; i < tiles.length; i += 1) {
if (advanceAnimatableTile(tiles[i]!, step)) {
updated += 1
}
}
return updated
}

View File

@ -22,6 +22,22 @@
*/
import { numberCell, textCell } from './tables.ts'
import type { DataTable } from './tables.ts'
import {
allocatePacksToRooms,
partitionMapIntoRooms,
spawnMonsterPacksByRooms,
spawnPacksInRoom,
} from './monster-rooms.ts'
import type { MapRoom } from './monster-rooms.ts'
export {
allocatePacksToRooms,
partitionMapIntoRooms,
spawnMonsterPacksByRooms,
spawnPacksInRoom,
type MapRoom,
}
/** Movement request and intent for one tick. */
export interface CombatInput {
@ -120,6 +136,8 @@ export interface Monster {
hitFlash: number
/** Ticks left of the death animation. */
corpseTicks: number
/** Whether the monster is sleeping (inactive off-screen room). */
sleeping?: boolean | undefined
}
/** The player's combat-relevant state. */
@ -481,7 +499,11 @@ export function spawnMonsterPacks(
spread: number,
terrain: CombatTerrain,
safeZones?: readonly SafeZone[],
rooms?: readonly MapRoom[],
): number {
if (rooms !== undefined && rooms.length > 0) {
return spawnMonsterPacksByRooms(world, packs, rooms, terrain, safeZones !== undefined ? { safeZones } : undefined)
}
if (packs.length === 0) return 0
let placed = 0
let campIndex = 0
@ -787,6 +809,7 @@ function tickPlayer(
*/
function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void {
for (const monster of world.monsters) {
if (monster.sleeping) continue
if (monster.state === 'dead') {
if (monster.corpseTicks > 0) monster.corpseTicks -= 1
continue

View File

@ -25,6 +25,7 @@
*/
import type { Ds1 } from '../formats/ds1.ts'
import type { Dt1, Dt1Tile } from '../formats/dt1.ts'
import { isAnimatedTile } from '../formats/dt1.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
@ -83,6 +84,13 @@ const TOP_CORNER_LEFT_TYPE = 4
export interface IsoDraw {
/** Index into {@link IsoMapScene.frames}. */
readonly frameIndex: number
/**
* Sequence of frame indices for animated tiles (e.g. water ripples, torches, fountains, lava).
*
* When present and containing more than 1 frame, the tile is animated and
* the renderer can cycle through these frame indices over time or game ticks.
*/
readonly animatedFrames?: readonly number[] | undefined
/** Screen x of the bitmap's left edge, origin applied. */
readonly x: number
/** Screen y of the bitmap's top edge, origin applied. */
@ -177,6 +185,8 @@ export interface IsoMapScene {
readonly cornerPairs: number
/** Top-corner-right walls whose left half could not be resolved (should be 0). */
readonly missingCornerPairs: number
/** Number of animated tiles in this scene. */
readonly animatedTileCount?: number | undefined
}
/**
@ -210,6 +220,10 @@ interface PoolTile {
readonly tile: number
/** Variant weight (`Dt1Tile.rarityFrameIndex`) used when picking per cell. */
readonly weight: number
/** Whether this tile is part of an animated sequence. */
readonly animated: boolean
/** 0-based animation frame index when animated is true. */
readonly frameIndex: number
}
/** The merged pool a DS1's references are resolved against. */
@ -246,7 +260,14 @@ function mergeLibraries(libraries: readonly Dt1[]): TilePool {
}
libraries.forEach((library, libraryIndex) => {
library.tiles.forEach((tile, tileIndex) => {
const entry = { library: libraryIndex, tile: tileIndex, weight: tile.rarityFrameIndex }
const animated = isAnimatedTile(tile)
const entry: PoolTile = {
library: libraryIndex,
tile: tileIndex,
weight: tile.rarityFrameIndex,
animated,
frameIndex: animated ? Math.max(0, tile.rarityFrameIndex) : 0,
}
// The third component is the DS1 wall `type` and it matches the DT1 tile's
// **`Type` field at offset +20**, not its `Direction` field at +0. `Direction`
// is the orientation/variant index (1..5 in practice) and never equals the
@ -309,6 +330,42 @@ function pickVariant(
return candidates[candidates.length - 1]
}
/**
* Result of resolving a candidate group.
*/
interface ResolvedCandidates {
readonly tile: PoolTile | undefined
readonly animatedTiles?: readonly PoolTile[] | undefined
}
/**
* Resolve a candidate group from the pool:
* If the candidates are animated tiles, sort them by frameIndex (0..N-1) and return
* the base frame as `tile` and the sequence as `animatedTiles`.
* Otherwise, pick one variant per cell using weighted random draw.
*/
function resolveCandidates(
candidates: readonly PoolTile[] | undefined,
cellX: number,
cellY: number,
seed: number,
): ResolvedCandidates {
if (candidates === undefined || candidates.length === 0) {
return { tile: undefined }
}
const isAnim = candidates.some(c => c.animated)
if (isAnim) {
const sorted = [...candidates].sort((a, b) => a.frameIndex - b.frameIndex)
return {
tile: sorted[0],
animatedTiles: sorted.length > 1 ? sorted : undefined,
}
}
return {
tile: pickVariant(candidates, cellX, cellY, seed),
}
}
/**
* Resolve a DS1 reference against the pool.
*
@ -316,7 +373,7 @@ function pickVariant(
* @param style - DS1 style field.
* @param sequence - DS1 sequence field.
* @param type - DS1 wall type (the third field of the reference).
* @returns the pool tile, or undefined when nothing matches.
* @returns the resolved pool tile and animated sequence.
*/
function resolveExactType(
pool: TilePool,
@ -326,8 +383,8 @@ function resolveExactType(
cellX: number,
cellY: number,
seed: number,
): PoolTile | undefined {
return pickVariant(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
): ResolvedCandidates {
return resolveCandidates(pool.exact.get(`${String(style)}:${String(sequence)}:${String(type)}`), cellX, cellY, seed)
}
/**
@ -337,7 +394,7 @@ function resolveExactType(
* @param style - DS1 style field.
* @param sequence - DS1 sequence field.
* @param type - DS1 wall type (the third field of the reference), or null for floors.
* @returns the pool tile, or undefined when nothing matches.
* @returns the pool tile, animated tiles if any, and whether it matched via loose fallback.
*/
function resolveWithSource(
pool: TilePool,
@ -347,11 +404,12 @@ function resolveWithSource(
cellX: number,
cellY: number,
seed: number,
): { tile: PoolTile | undefined; viaLoose: boolean } {
): { tile: PoolTile | undefined; animatedTiles?: readonly PoolTile[] | undefined; viaLoose: boolean } {
const key = `${String(style)}:${String(sequence)}`
const exact = pickVariant(pool.exact.get(`${key}:${String(type)}`), cellX, cellY, seed)
if (exact !== undefined) return { tile: exact, viaLoose: false }
return { tile: pickVariant(pool.loose.get(key), cellX, cellY, seed), viaLoose: true }
const exact = resolveExactType(pool, style, sequence, type, cellX, cellY, seed)
if (exact.tile !== undefined) return { tile: exact.tile, animatedTiles: exact.animatedTiles, viaLoose: false }
const loose = resolveCandidates(pool.loose.get(key), cellX, cellY, seed)
return { tile: loose.tile, animatedTiles: loose.animatedTiles, viaLoose: true }
}
/**
@ -404,7 +462,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const gridWidth = cellsX * SUB_TILES_PER_TILE
const gridHeight = cellsY * SUB_TILES_PER_TILE
const blocked = new Uint8Array(gridWidth * gridHeight)
const rawFloors: { frameIndex: number; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
const rawFloors: { frameIndex: number; animatedFrames?: readonly number[]; x: number; y: number; cellX: number; cellY: number; library: number; tile: number }[] = []
const rawWalls: typeof rawFloors = []
/** Roof draws (`wall.type` 15): kept apart so they can be painted last. */
const rawRoofs: typeof rawFloors = []
@ -463,8 +521,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const found = picked.tile
if (found === undefined) { noteMissing('floor', floor.style, floor.sequence, FLOOR_TILE_TYPE, cellX, cellY); continue }
const tile = libraries[found.library]!.tiles[found.tile]!
const animatedFrames = picked.animatedTiles?.map(t => frameFor(t.library, t.tile))
rawFloors.push({
frameIndex: frameFor(found.library, found.tile),
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
x: orthoX + TILE_ANCHOR_X,
y: orthoY,
cellX, cellY, library: found.library, tile: found.tile,
@ -480,7 +540,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
// Shadows must come from a **type 13** tile specifically: the loose
// fallback ignores the type field and would happily return a wall tile,
// stamping its blocking flags as if they were a shadow's.
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed)
const found = resolveExactType(pool, shadow.style, shadow.sequence, SHADOW_TILE_TYPE, cellX, cellY, seed).tile
if (found === undefined) continue
const tile = libraries[found.library]!.tiles[found.tile]!
for (let sub = 0; sub < SUB_TILES_PER_TILE * SUB_TILES_PER_TILE; sub += 1) {
@ -502,8 +562,10 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
if (found === undefined) { noteMissing('wall', wall.style, wall.sequence, wall.type, cellX, cellY); continue }
const tile = libraries[found.library]!.tiles[found.tile]!
if (tile.bitmapHeight > Math.abs(tile.height)) clippedTiles += 1
const animatedFrames = pickedWall.animatedTiles?.map(t => frameFor(t.library, t.tile))
const draw = {
frameIndex: frameFor(found.library, found.tile),
...(animatedFrames && animatedFrames.length > 1 ? { animatedFrames } : {}),
x: orthoX + TILE_ANCHOR_X,
// A wall's art extends above its cell, so it is pushed back down by
// exactly the block shift the decoder applied, plus one cell height.
@ -525,14 +587,17 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
// engine's DS1s never list the left half themselves, so this is the only way
// the corner gets both faces — 411 of them across the 35 preset levels.
if (tile.type === TOP_CORNER_RIGHT_TYPE) {
const companion = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
const companionPicked = resolveExactType(pool, wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY, seed)
const companion = companionPicked.tile
if (companion === undefined) {
missingCornerPairs += 1
noteMissing('corner-left', wall.style, wall.sequence, TOP_CORNER_LEFT_TYPE, cellX, cellY)
} else {
const leftTile = libraries[companion.library]!.tiles[companion.tile]!
const companionAnim = companionPicked.animatedTiles?.map(t => frameFor(t.library, t.tile))
rawWalls.push({
frameIndex: frameFor(companion.library, companion.tile),
...(companionAnim && companionAnim.length > 1 ? { animatedFrames: companionAnim } : {}),
x: orthoX + TILE_ANCHOR_X,
y: orthoY + leftTile.minBlockY + WALL_SURFACE_HEIGHT,
cellX, cellY, library: companion.library, tile: companion.tile,
@ -567,6 +632,13 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
const originY = -minY
const shift = (draw: (typeof rawFloors)[number]): IsoDraw => ({ ...draw, x: draw.x + originX, y: draw.y + originY })
const floors = rawFloors.map(shift)
const walls = rawWalls.map(shift)
const roofs = rawRoofs.map(shift)
const animatedTileCount = [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
).length
return {
frames,
sheet: { groups: [{ frames }], width: null },
@ -576,9 +648,9 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
heightPx: maxY - minY,
originX,
originY,
floors: rawFloors.map(shift),
walls: rawWalls.map(shift),
roofs: rawRoofs.map(shift),
floors,
walls,
roofs,
blocked,
gridWidth,
gridHeight,
@ -590,6 +662,7 @@ export function buildIsoMapScene(level: Ds1, libraries: readonly Dt1[], seed = 0
cornerPairs,
missingCornerPairs,
duplicateRefs: pool.duplicates,
animatedTileCount,
}
}

View File

@ -8,6 +8,8 @@ import { QuestLog, npcDialog } from './quests.ts'
import type { NpcDef, QuestDef } from './quests.ts'
import { Rng } from './rng.ts'
import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
import { MonsterStreamingManager } from './monster-streaming.ts'
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'
export interface EngineInput {
readonly movement: { readonly x: number; readonly y: number }
@ -115,6 +117,24 @@ export interface GameEngineOptions {
* itself.
*/
snapshotStore?: SnapshotStore
/**
* Optional pre-constructed MonsterStreamingManager for lazy room population
* and active/sleeping monster streaming.
*/
streamingManager?: MonsterStreamingManager | undefined
/**
* Pre-planned rooms for monster streaming. When configured without an explicit
* streamingManager, a MonsterStreamingManager is initialized automatically.
*/
roomPacks?: readonly StreamingRoomDef[] | undefined
/**
* Alias for roomPacks.
*/
streamingRooms?: readonly StreamingRoomDef[] | undefined
/**
* Tuning options for MonsterStreamingManager when created from roomPacks.
*/
streamingOptions?: MonsterStreamingOptions | undefined
}
export interface GameEngineMetrics {
@ -145,6 +165,7 @@ export class GameEngine {
selectedSkill = 0
npcEntities: NpcEntity[] = []
dialog: string[] = []
streamingManager?: MonsterStreamingManager
/**
* Whether the save input was held on the previous tick.
*
@ -164,10 +185,27 @@ export class GameEngine {
}
constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) {
this.world = createWorld(opts.spawn.x, opts.spawn.y)
const rooms = opts.roomPacks ?? opts.streamingRooms
if (opts.streamingManager !== undefined) {
this.streamingManager = opts.streamingManager
this.world = opts.streamingManager.world
} else {
this.world = createWorld(opts.spawn.x, opts.spawn.y)
if (rooms !== undefined && rooms.length > 0) {
this.streamingManager = new MonsterStreamingManager(
this.world,
this.terrain,
rooms,
opts.streamingOptions,
)
}
}
const spread = opts.monsterSpread ?? 260
const count = opts.monsterCount ?? 8
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
if (this.streamingManager !== undefined) {
// Monsters are lazily populated by streamingManager; avoid frame-0 eager population.
} else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) {
spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones)
} else if (count > 0) {
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
@ -198,6 +236,8 @@ export class GameEngine {
const beforeX = player.x
const beforeY = player.y
this.streamingManager?.update(player.x, player.y)
if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1)
const wanted = this.opts.skills[this.selectedSkill]
@ -379,6 +419,9 @@ export class GameEngine {
this.questLog = pieces.quests
this.ground = pieces.ground
this.projectiles = []
if (this.streamingManager !== undefined) {
this.streamingManager.world = this.world
}
}
}
@ -484,3 +527,6 @@ export function syncEngineState(engine: GameEngine, state: EngineViewState): voi
state.y = engine.world.player.y
state.bagItems = engine.bag.contents.length
}
export { MonsterStreamingManager } from './monster-streaming.ts'
export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'

View File

@ -178,15 +178,15 @@ export const LEVEL_NAMES_ZH: Readonly<Record<number, string>> = {
* `CryptCountess1` 末尾也是 `s`,按猜测会错成"南"。
*/
const DIRECTION_SUFFIXES: Readonly<Record<string, string>> = {
townn1: '北',
towne1: '东',
towns1: '南',
townw1: '西',
courtn: '北',
courte: '东',
courtw: '西',
lutn: '北',
lutw: '西',
townn1: '北门',
towne1: '东门',
towns1: '南门',
townw1: '西门',
courtn: '北向',
courte: '东向',
courtw: '西向',
lutn: '北门',
lutw: '西门',
}
/**

730
src/game/maze-objects.ts Normal file
View File

@ -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,
},
}
}

View File

@ -0,0 +1,997 @@
/**
* Diablo II DRLG (Dungeon Random Level Generator) Maze Special Passes.
*
* Implements the 11 official Blizzard / D2MOO core dungeon special passes:
* - `DRLGMAZE_PlaceAct2TombStuff`: Tal Rasha's Tombs (True tomb Horadric staff receptacle, 6 false tombs golden chests, Kaa, Cube, Leatherarm).
* - `DRLGMAZE_PlaceAct2LairStuff`: Maggot Lair Level 3 (Coldworm the Burrower & Staff of Kings chest).
* - `DRLGMAZE_PlaceAct3DungeonStuff`: Flayer Dungeon (Brain chest) & Ruined Temple (Lam Esen's Tome altar).
* - `DRLGMAZE_PlaceAct3SewerStuff`: Kurast Sewers (drain lever & descent to Level 2).
* - `DRLGMAZE_PlaceAct3MephistoStuff`: Durance of Hate Level 3 (blood moat, council hall, red portal to Act 4).
* - `DRLGMAZE_PlaceAct5TempleStuff`: Halls of Vaught (4-way branch & Nihlathak altar) & Halls of Pain.
* - `DRLGMAZE_PlaceAct5BaalStuff`: Throne of Destruction & Worldstone Keep.
* - `DRLGMAZE_PlaceAct5IceStuff`: Frozen River (Anya frozen platform) & Glacial Trail.
* - `DRLGMAZE_PlaceAct1Barracks`: Monastery Barracks (Smith & Charsi's Malus forge/anvil).
* - `DRLGMAZE_PlaceAct4Lava`: River of Flame (Hephasto the Armorer & Hellforge, Bridge connecting to Chaos Sanctuary).
* - `DRLGMAZE_ScanReplaceSpecialAct2SewersPresets`: Lut Gholein Sewers (Radament's Lair & Atma's quest).
*/
import {
DIRECTION_WEST,
DIRECTION_NORTH,
DIRECTION_EAST,
DIRECTION_SOUTH,
sideMaskFromToken,
sideTokenFromMask,
type MazeLevel,
type Room,
type MazePiece,
type MazePieceKind,
type MazeRequest,
type MazeStats,
type PieceIndex,
bfsDistances,
addAdjacentMazeRoom,
noteSpecial,
findSpecial,
roomSideMask,
fillBlankSpaces,
} from './maze.ts'
export type SpecialPassName =
| 'DRLGMAZE_PlaceAct2TombStuff'
| 'DRLGMAZE_PlaceAct2LairStuff'
| 'DRLGMAZE_PlaceAct3DungeonStuff'
| 'DRLGMAZE_PlaceAct3SewerStuff'
| 'DRLGMAZE_PlaceAct3MephistoStuff'
| 'DRLGMAZE_PlaceAct5TempleStuff'
| 'DRLGMAZE_PlaceAct5BaalStuff'
| 'DRLGMAZE_PlaceAct5IceStuff'
| 'DRLGMAZE_PlaceAct1Barracks'
| 'DRLGMAZE_PlaceAct4Lava'
| 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets'
export interface SpecialPassContext {
readonly level: MazeLevel
readonly request: MazeRequest
readonly pieces: readonly MazePiece[]
readonly index: PieceIndex
readonly stats: MazeStats
}
const DIRS = ['N', 'E', 'S', 'W'] as const
export interface PlaceNamedPieceOptions {
readonly kind: MazePieceKind
readonly name?: string
readonly namePrefix?: string
readonly sides?: string
readonly plainSides?: string
readonly direction?: number
readonly inPlaceOnly?: boolean
readonly preferFurthest?: boolean
}
/**
* Locate and place a special piece into the maze level.
*
* Tries in-place replacement first (preferring rooms matching `plainSides` and optimal BFS distance),
* then falls back to growing a new dead-end room off an existing room if allowed.
*/
export function placeNamedPiece(
ctx: SpecialPassContext,
options: PlaceNamedPieceOptions,
): Room | null {
const { kind, name, namePrefix, sides, plainSides, inPlaceOnly = false } = options
if (kind === 'waypoint' && ctx.request.hasWaypoint === false) {
return null
}
const isExit = options.preferFurthest ?? (kind === 'next' || kind === 'down' || kind === 'quest' || kind === 'treasure' || kind === 'waypoint')
// 1. Find the target piece in ctx.pieces
let piece: MazePiece | undefined
if (name !== undefined) {
piece = ctx.pieces.find(p => p.name === name)
?? ctx.pieces.find(p => p.name.toLowerCase() === name.toLowerCase())
}
if (piece === undefined && namePrefix !== undefined) {
if (sides !== undefined && sides !== '') {
const fullName = `${namePrefix} ${sides}`
piece = ctx.pieces.find(p => p.name === fullName)
?? ctx.pieces.find(p => p.name.toLowerCase() === fullName.toLowerCase())
}
if (piece === undefined) {
piece = ctx.pieces.find(p => p.name.startsWith(namePrefix))
}
}
if (piece === undefined && sides !== undefined && sides !== '') {
piece = findSpecial(ctx.pieces, kind, sides)
}
if (piece === undefined) {
piece = ctx.pieces.find(p => p.kind === kind && (sides === undefined || sides === '' || p.sides === sides))
}
if (piece === undefined) {
const missingIdentifier = name ?? (namePrefix !== undefined ? `${namePrefix} ${sides ?? ''}`.trim() : `${kind} ${sides ?? ''}`.trim())
ctx.stats.unresolvedRoles.push(missingIdentifier)
ctx.stats.notes.push(`special piece not found: ${missingIdentifier}`)
return null
}
const token = sides ?? piece.sides ?? ''
const distMap = bfsDistances(ctx.level)
// 2. Phase 1: In-place replacement
const wantedMask = plainSides !== undefined && plainSides !== '' ? sideMaskFromToken(plainSides) : null
const candidates = ctx.level.rooms.filter(r => {
if (r.hasDs1) return false
if (wantedMask !== null && roomSideMask(r) !== wantedMask) return false
return true
})
if (candidates.length > 0) {
candidates.sort((a, b) => {
const da = distMap.get(a) ?? 0
const db = distMap.get(b) ?? 0
return isExit ? db - da : da - db
})
const chosen = candidates[0]!
chosen.piece = piece
chosen.variant = 0
chosen.hasDs1 = true
noteSpecial(chosen, piece, token !== '' ? token : sideTokenFromMask(roomSideMask(chosen)), ctx.stats)
return chosen
}
if (inPlaceOnly) {
ctx.stats.notes.push(`no matching room for in-place special piece: ${piece.name}`)
return null
}
// 3. Phase 2: Grow an adjacent room
const parents = ctx.level.rooms.filter(r => !r.hasDs1 || r.piece?.kind === 'theme')
parents.sort((a, b) => {
const da = distMap.get(a) ?? 0
const db = distMap.get(b) ?? 0
return isExit ? db - da : da - db
})
const primaryDir = options.direction ?? DIRECTION_NORTH
const tryDirs = [primaryDir, (primaryDir + 1) % 4, (primaryDir + 2) % 4, (primaryDir + 3) % 4]
for (const parent of parents) {
for (const dir of tryDirs) {
const wasHasDs1 = parent.hasDs1
parent.hasDs1 = false
const created = addAdjacentMazeRoom(ctx.level, parent, dir, false, true, ctx.index, ctx.stats)
if (created !== null) {
created.piece = piece
created.variant = 0
created.hasDs1 = true
noteSpecial(created, piece, token, ctx.stats)
return created
}
parent.hasDs1 = wasHasDs1
}
}
ctx.stats.notes.push(`could not place special piece ${piece.name}: no adjacent space clear`)
return null
}
/* ------------------------------------------------------------------------- *
* 1. Act 2 - Tomb
* ------------------------------------------------------------------------- */
/**
* Tal Rasha's Tombs, Stony Tomb, Halls of the Dead, Claw Viper Temple.
*
* Places True Tomb Horadric Staff orifice, False Tomb golden chest rooms, Ancient Kaa the Soulless,
* Horadric Cube chest, Leatherarm, and staircases.
*/
export function placeAct2TombStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
const staffTomb = ctx.request.staffTombLevelId ?? 66
const bossTomb = ctx.request.bossTombLevelId
// Determine initial entrance direction from warp room
let nDirection = 0
const warpRoom = ctx.level.rooms.find(r => r.piece?.kind === 'prev')
if (warpRoom?.piece !== null && warpRoom?.piece !== undefined) {
const name = warpRoom.piece.name.toUpperCase()
if (name.includes('NSW')) nDirection = 0
else if (name.includes('NEW')) nDirection = 1
else if (name.includes('NSE')) nDirection = 2
else if (name.includes('SEW')) nDirection = 3
else nDirection = ctx.request.seed % 4
} else {
nDirection = ctx.request.seed % 4
}
const GROW_DIRS = [DIRECTION_SOUTH, DIRECTION_WEST, DIRECTION_NORTH, DIRECTION_EAST]
const placeTombPiece = (kind: MazePieceKind, role: string) => {
const side = DIRS[nDirection]!
const growDir = GROW_DIRS[nDirection]!
placeNamedPiece(ctx, {
kind,
namePrefix: `Act 2 - Tomb ${role}`,
sides: side,
plainSides: side,
direction: growDir,
preferFurthest: role !== 'Prev',
})
nDirection = (nDirection + 1) % 4
}
// 1. Stony Tomb 1, Stony Tomb 2, Halls of the Dead 1, Claw Viper Temple 1 get Next stairs
if (levelId >= 55 && levelId <= 58) {
placeTombPiece('next', 'Next')
}
// 2. Halls of the Dead 2 gets Waypoint
if (levelId === 57 || levelId === 59 || ctx.request.hasWaypoint === true) {
placeTombPiece('waypoint', 'Waypoint')
}
// 3. Stony Tomb 2 gets golden chest
if (levelId === 56) {
placeTombPiece('treasure', 'Chest')
}
// 4. False Tal Rasha Tombs (66..72 except staff tomb) get golden chest
if (levelId >= 66 && levelId <= 72 && levelId !== staffTomb) {
placeTombPiece('treasure', 'Chest')
}
// 5. Stony Tomb 2 gets Leatherarm
if (levelId === 56) {
placeTombPiece('quest', 'Leatherarm')
}
// 6. Halls of the Dead 3 gets Horadric Cube
if (levelId === 60) {
placeTombPiece('quest', 'Cube')
}
// 7. Stony Tomb 2 gets Treasure
if (levelId === 56) {
placeTombPiece('treasure', 'Treasure')
}
// 8. True Tal Rasha Tomb gets Horadric Staff Orifice
if (levelId === staffTomb) {
placeTombPiece('quest', 'Talrasha')
}
// 9. Boss Tomb gets Ancient Kaa the Soulless
if (bossTomb !== undefined && levelId === bossTomb) {
placeTombPiece('quest', 'Kaa')
}
}
/* ------------------------------------------------------------------------- *
* 2. Act 2 - Lair
* ------------------------------------------------------------------------- */
/**
* Maggot Lair Level 1, 2, 3.
*
* Level 3 places Coldworm the Burrower (Tight Spot S) and the Staff of Kings chest (Treasure W).
*/
export function placeAct2LairStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
if (levelId === 64) {
// Coldworm the Burrower room
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 2 - Lair Tight Spot S',
plainSides: 'S',
direction: DIRECTION_NORTH,
preferFurthest: true,
})
// Staff of Kings chest room
placeNamedPiece(ctx, {
kind: 'treasure',
name: 'Act 2 - Lair Treasure W',
plainSides: 'W',
direction: DIRECTION_EAST,
preferFurthest: true,
})
// Up staircase
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 2 - Lair Prev',
sides: 'N',
direction: DIRECTION_SOUTH,
preferFurthest: false,
})
} else {
const nRand = ctx.request.seed % 4
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 2 - Lair Next',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 2 - Lair Prev',
sides: DIRS[(nRand + 1) % 4],
plainSides: DIRS[(nRand + 1) % 4],
preferFurthest: false,
})
}
}
/* ------------------------------------------------------------------------- *
* 3. Act 3 - Dungeon
* ------------------------------------------------------------------------- */
/**
* Spider Cavern, Arachnid Lair, Swampy Pit 1..3, Flayer Dungeon 1..3, and Kurast Temples.
*
* Flayer Dungeon 3 places Khalim's Brain chest (Treasure 2).
* Swampy Pit 3 places Treasure 1.
* Temples (levels 94..99) place Lam Esen's Tome altar.
*/
export function placeAct3DungeonStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4
if (levelId === 91) {
// Flayer Dungeon Level 3: Khalim's Brain chest
placeNamedPiece(ctx, {
kind: 'treasure',
name: 'Act 3 - Dungeon Treasure 2',
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 3 - Dungeon Prev',
sides: DIRS[nDirection],
preferFurthest: false,
})
} else if (levelId === 90) {
// Swampy Pit Level 3
placeNamedPiece(ctx, {
kind: 'treasure',
name: 'Act 3 - Dungeon Treasure 1',
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 3 - Dungeon Prev',
sides: DIRS[nDirection],
preferFurthest: false,
})
} else if (levelId >= 94 && levelId <= 99) {
// Ruined Temple, Disused Fane, Forgotten Reliquary, etc. (Lam Esen's Tome)
placeNamedPiece(ctx, {
kind: 'quest',
name: `Act 3 - Temple ${levelId - 93}`,
preferFurthest: true,
})
} else {
// Levels 86..89
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 3 - Dungeon Prev',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: false,
})
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 3 - Dungeon Next',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
preferFurthest: true,
})
}
}
/* ------------------------------------------------------------------------- *
* 4. Act 3 - Sewer
* ------------------------------------------------------------------------- */
/**
* Kurast Sewers Level 1 & 2.
*
* Level 1 places the drain descent lever and staircase down to Level 2.
*/
export function placeAct3SewerStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4
if (levelId === 92) {
// Sewer Level 1: Drain lever / stairs down to Sewer 2
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 3 - Sewer Drain',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
direction: DIRECTION_NORTH,
preferFurthest: true,
})
// Chest room
placeNamedPiece(ctx, {
kind: 'treasure',
namePrefix: 'Act 3 - Sewer Chest',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
direction: DIRECTION_SOUTH,
preferFurthest: true,
})
} else {
// Sewer Level 2 (or other)
placeNamedPiece(ctx, {
kind: 'treasure',
namePrefix: 'Act 3 - Sewer Chest',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'treasure',
name: 'Act 3 - Sewer Treasure X',
preferFurthest: true,
})
}
}
/* ------------------------------------------------------------------------- *
* 5. Act 3 - Mephisto
* ------------------------------------------------------------------------- */
/**
* Durance of Hate Level 1, 2, 3.
*
* Level 3 places the Mephisto Complex (Council Hall, blood moat, red portal to Act 4).
* Level 2 places Waypoint, Next and Prev.
*/
export function placeAct3MephistoStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
const nRand = ctx.request.seed % 4
if (levelId === 102) {
// Durance of Hate Level 3: Mephisto Complex
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const complexPiece = ctx.pieces.find(p => p.name === 'Act 3 - Mephisto Complex')
if (complexPiece !== undefined) {
ctx.level.first.piece = complexPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, complexPiece, '', ctx.stats)
return
}
}
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 3 - Mephisto Complex',
preferFurthest: true,
})
} else if (levelId === 101) {
// Durance of Hate Level 2: Prev, Waypoint, Next
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 3 - Mephisto Prev',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: false,
})
placeNamedPiece(ctx, {
kind: 'waypoint',
namePrefix: 'Act 3 - Mephisto Waypoint',
sides: DIRS[(nRand + 1) % 4],
plainSides: DIRS[(nRand + 1) % 4],
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 3 - Mephisto Next',
sides: DIRS[(nRand + 2) % 4],
plainSides: DIRS[(nRand + 2) % 4],
preferFurthest: true,
})
} else {
// Durance of Hate Level 1: Prev, Next
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 3 - Mephisto Prev',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: false,
})
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 3 - Mephisto Next',
sides: DIRS[(nRand + 1) % 4],
plainSides: DIRS[(nRand + 1) % 4],
preferFurthest: true,
})
}
}
/* ------------------------------------------------------------------------- *
* 6. Act 5 - Temple
* ------------------------------------------------------------------------- */
/**
* Halls of Anguish, Halls of Pain, Halls of Vaught.
*
* Halls of Vaught (level 124) places Nihlathak's altar (Final Room).
* Halls of Pain (level 123) places Waypoint and descent stairs.
*/
export function placeAct5TempleStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
if (levelId === 124) {
// Halls of Vaught: Nihlathak Final Room
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const finalPiece = ctx.pieces.find(p => p.name === 'Act 5 - Temple Final Room')
if (finalPiece !== undefined) {
ctx.level.first.piece = finalPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, finalPiece, '', ctx.stats)
return
}
}
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 5 - Temple Final Room',
preferFurthest: true,
})
} else {
// Levels 122 and 123
const templeDownDirs = ['NE', 'NW', 'SW']
const nDirection = (ctx.request.seed >>> 0) % 3
const downSide = templeDownDirs[nDirection]!
placeNamedPiece(ctx, {
kind: 'down',
name: `Act 5 - Temple ${downSide} Down`,
sides: downSide,
plainSides: downSide,
preferFurthest: true,
})
if (levelId === 123) {
const templeWaypointDirs = ['NE', 'NW', 'SW', 'SE']
const wpSide = templeWaypointDirs[(nDirection + 1) % 4]!
placeNamedPiece(ctx, {
kind: 'waypoint',
name: `Act 5 - Temple ${wpSide} Waypoint`,
sides: wpSide,
plainSides: wpSide,
preferFurthest: true,
})
}
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 5 - Temple SE up',
sides: 'SE',
plainSides: 'SE',
preferFurthest: false,
})
}
}
/* ------------------------------------------------------------------------- *
* 7. Act 5 - Baal
* ------------------------------------------------------------------------- */
/**
* Worldstone Keep Level 1, 2, 3 and Throne of Destruction.
*
* Throne of Destruction (level 131) places Baal Entrance / ThroneRoom.
* Worldstone Keep 2 places Waypoint and Next stairs.
*/
export function placeAct5BaalStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
const nDirection = ctx.request.seed % 4
if (levelId === 131) {
// Throne of Destruction
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const thronePiece = ctx.pieces.find(p => p.name === 'Act 5 - ThroneRoom' || p.name === 'Act 5 - Baal Entrance')
if (thronePiece !== undefined) {
ctx.level.first.piece = thronePiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, thronePiece, '', ctx.stats)
return
}
}
const placed = placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 5 - ThroneRoom',
preferFurthest: true,
})
if (placed === null) {
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 5 - Baal Entrance',
preferFurthest: true,
})
}
} else if (levelId === 129) {
// Worldstone Keep Level 2: Next and Waypoint
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 5 - Baal Next',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'waypoint',
namePrefix: 'Act 5 - Baal Waypoint',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
preferFurthest: true,
})
} else {
// Worldstone Keep Levels 1, 3
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 5 - Baal Next',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
}
}
/* ------------------------------------------------------------------------- *
* 8. Act 5 - Ice
* ------------------------------------------------------------------------- */
/**
* Crystalline Passage, Frozen River, Glacial Trail, Drifter Cavern, Ancients' Way, Icy Cellar.
*
* Frozen River (level 114) places Anya's frozen platform (River A or River B).
* Drifter Cavern (116) and Icy Cellar (119) place Pool A / Pool B.
* Glacial Trail (115) places Theme and Waypoint.
*/
export function placeAct5IceStuff(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
if (levelId === 114) {
// Frozen River (Anya platform)
const variant = (ctx.request.seed % 2) === 0 ? 'A' : 'B'
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const riverPiece = ctx.pieces.find(p => p.name === `Act 5 - Ice River ${variant}`)
?? ctx.pieces.find(p => p.name.startsWith('Act 5 - Ice River'))
if (riverPiece !== undefined) {
ctx.level.first.piece = riverPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, riverPiece, '', ctx.stats)
return
}
}
placeNamedPiece(ctx, {
kind: 'quest',
name: `Act 5 - Ice River ${variant}`,
preferFurthest: true,
})
} else if (levelId === 116) {
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool A')
if (poolPiece !== undefined) {
ctx.level.first.piece = poolPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
return
}
}
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool A', preferFurthest: true })
} else if (levelId === 119) {
if (ctx.level.rooms.length === 1 && ctx.level.first !== null) {
const poolPiece = ctx.pieces.find(p => p.name === 'Act 5 - Ice Pool B')
if (poolPiece !== undefined) {
ctx.level.first.piece = poolPiece
ctx.level.first.hasDs1 = true
noteSpecial(ctx.level.first, poolPiece, '', ctx.stats)
return
}
}
placeNamedPiece(ctx, { kind: 'quest', name: 'Act 5 - Ice Pool B', preferFurthest: true })
} else {
// Levels 113, 115, 118
let nDirection = ctx.request.seed % 4
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 5 - Ice Prev',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: false,
})
nDirection = (nDirection + 1) % 4
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 5 - Ice Next',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
nDirection = (nDirection + 1) % 4
placeNamedPiece(ctx, {
kind: 'down',
namePrefix: 'Act 5 - Ice Down',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
if (levelId === 115) {
nDirection = (nDirection + 1) % 4
placeNamedPiece(ctx, {
kind: 'theme',
namePrefix: 'Act 5 - Ice Theme',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
}
if (levelId === 113 || levelId === 115) {
nDirection = (nDirection + 1) % 4
placeNamedPiece(ctx, {
kind: 'waypoint',
namePrefix: 'Act 5 - Ice waypoint',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
}
}
}
/* ------------------------------------------------------------------------- *
* 9. Act 1 - Barracks
* ------------------------------------------------------------------------- */
/**
* Monastery Barracks (level 28).
*
* Places Court Connect, the Smith / Charsi's Malus forge, and stairs to Jail.
*/
export function placeAct1Barracks(ctx: SpecialPassContext): void {
// Connect towards Outer Cloister
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 1 - Barracks Court Connect',
direction: DIRECTION_WEST,
preferFurthest: false,
})
const nDirection = ctx.request.seed % 4
const roll = (ctx.request.seed >>> 2) % 2
if (roll === 1) {
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 1 - Barracks Next',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'quest',
namePrefix: 'Act 1 - Barracks Forge',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
preferFurthest: true,
})
} else {
placeNamedPiece(ctx, {
kind: 'quest',
namePrefix: 'Act 1 - Barracks Forge',
sides: DIRS[nDirection],
plainSides: DIRS[nDirection],
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 1 - Barracks Next',
sides: DIRS[(nDirection + 1) % 4],
plainSides: DIRS[(nDirection + 1) % 4],
preferFurthest: true,
})
}
}
/* ------------------------------------------------------------------------- *
* 10. Act 4 - Lava
* ------------------------------------------------------------------------- */
/**
* River of Flame (level 107).
*
* Places Warp N (towards City of Damned), Bridges 1 & 2 towards Chaos Sanctuary,
* Hephasto the Armorer / Hellforge, and fills blanks with Lava X.
*/
export function placeAct4Lava(ctx: SpecialPassContext): void {
// 1. Warp N (entrance from City of Damned)
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 4 - Lava Warp',
sides: 'N',
plainSides: 'N',
direction: DIRECTION_SOUTH,
preferFurthest: false,
})
// 2. Bridges towards Chaos Sanctuary
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 4 - Bridge 1',
direction: DIRECTION_NORTH,
preferFurthest: true,
})
placeNamedPiece(ctx, {
kind: 'quest',
name: 'Act 4 - Bridge 2',
direction: DIRECTION_NORTH,
preferFurthest: true,
})
// 3. Hellforge (Hephasto the Armorer)
const forgeSide = (ctx.request.seed % 2) === 0 ? 'W' : 'E'
placeNamedPiece(ctx, {
kind: 'quest',
name: `Act 4 - Lava Forge ${forgeSide}`,
plainSides: forgeSide,
preferFurthest: true,
})
// 4. Fill blank maze spaces with Act 4 - Lava X
fillBlankSpaces(ctx.level, ctx.pieces, ctx.index, ctx.stats)
}
/* ------------------------------------------------------------------------- *
* 11. Act 2 - Sewers
* ------------------------------------------------------------------------- */
/**
* Lut Gholein Sewers Level 1, 2, 3 and Ancient Tunnels.
*
* Level 3 places Radament's Lair (Book of Skill / Atma's quest).
* Level 1 places Prev E & Prev NS.
* Level 2 places Waypoint, Prev, Next.
* Ancient Tunnels (level 50) places Chest and Prev.
*/
export function scanReplaceSpecialAct2SewersPresets(ctx: SpecialPassContext): void {
const levelId = ctx.request.levelId
let nRand = ctx.request.seed % 4
if (levelId === 49) {
// Sewers Level 3: Radament's Lair
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 2 - Sewer Prev',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: false,
})
nRand = (nRand + 1) % 4
placeNamedPiece(ctx, {
kind: 'quest',
namePrefix: "Act 2 - Sewer Radament's Lair",
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: true,
})
} else if (levelId === 47) {
// Sewers Level 1: Dock and City entrances
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 2 - Sewer Prev E',
sides: 'E',
direction: DIRECTION_WEST,
preferFurthest: false,
})
placeNamedPiece(ctx, {
kind: 'prev',
name: 'Act 2 - Sewer Prev NS',
sides: 'NS',
direction: DIRECTION_NORTH,
preferFurthest: false,
})
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 2 - Sewer Next',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: true,
})
} else if (levelId === 48) {
// Sewers Level 2: Prev, Waypoint, Next
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 2 - Sewer Prev',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: false,
})
nRand = (nRand + 1) % 4
placeNamedPiece(ctx, {
kind: 'waypoint',
namePrefix: 'Act 2 - Sewer Waypoint',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: true,
})
nRand = (nRand + 1) % 4
placeNamedPiece(ctx, {
kind: 'next',
namePrefix: 'Act 2 - Sewer Next',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: true,
})
} else {
// Ancient Tunnels (level 50) or other
placeNamedPiece(ctx, {
kind: 'prev',
namePrefix: 'Act 2 - Sewer Prev',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: false,
})
nRand = (nRand + 1) % 4
placeNamedPiece(ctx, {
kind: 'treasure',
namePrefix: 'Act 2 - Sewer Chest',
sides: DIRS[nRand],
plainSides: DIRS[nRand],
preferFurthest: true,
})
}
}
/* ------------------------------------------------------------------------- *
* Master Runner
* ------------------------------------------------------------------------- */
export function runMazeSpecialPasses(passName: SpecialPassName, ctx: SpecialPassContext): void {
ctx.stats.specialPassesApplied.push(passName)
switch (passName) {
case 'DRLGMAZE_PlaceAct2TombStuff':
placeAct2TombStuff(ctx)
break
case 'DRLGMAZE_PlaceAct2LairStuff':
placeAct2LairStuff(ctx)
break
case 'DRLGMAZE_PlaceAct3DungeonStuff':
placeAct3DungeonStuff(ctx)
break
case 'DRLGMAZE_PlaceAct3SewerStuff':
placeAct3SewerStuff(ctx)
break
case 'DRLGMAZE_PlaceAct3MephistoStuff':
placeAct3MephistoStuff(ctx)
break
case 'DRLGMAZE_PlaceAct5TempleStuff':
placeAct5TempleStuff(ctx)
break
case 'DRLGMAZE_PlaceAct5BaalStuff':
placeAct5BaalStuff(ctx)
break
case 'DRLGMAZE_PlaceAct5IceStuff':
placeAct5IceStuff(ctx)
break
case 'DRLGMAZE_PlaceAct1Barracks':
placeAct1Barracks(ctx)
break
case 'DRLGMAZE_PlaceAct4Lava':
placeAct4Lava(ctx)
break
case 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets':
scanReplaceSpecialAct2SewersPresets(ctx)
break
}
}

View File

@ -80,6 +80,25 @@
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
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
@ -192,6 +211,26 @@ export type MazePieceKind =
| 'down'
| 'quest'
| 'treasure'
| 'waypoint'
/**
* A waypoint room, as reported in `stats.waypoints`.
*/
export interface MazeWaypoint {
/** The room the piece went into, matching `specialsApplied`. */
readonly room: number
/** The room's top-left corner in the final map, in cells. */
readonly x: number
readonly y: number
/** The room's extent, in cells. */
readonly width: number
readonly height: number
/** The room's centre, where the player lands or clicks. */
readonly centreX: number
readonly centreY: number
/** Sub-tile coordinates of the waypoint entity within the synthesized map. */
readonly entityX: number
readonly entityY: number
}
/**
* One `LvlPrest.txt` row that can be stamped into a maze level.
@ -265,12 +304,47 @@ export interface MazeRequest {
readonly staffTombLevelId?: number
/** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */
readonly bossTombLevelId?: number
/**
* Explicit override for waypoint placement.
* If true, forces a waypoint room to be placed (using waypoint pieces).
* If false, skips waypoint room placement even if the level profile includes it.
* If omitted, defaults to whether the level id has a waypoint in the profile.
*/
readonly hasWaypoint?: boolean
/**
* Maximum allowed width of the entire maze in cells (`Levels.txt` `SizeX`).
* When specified, any room placement that would expand the overall maze bounding box
* beyond this width will be rejected.
*/
readonly maxCellsX?: number | undefined
/**
* Maximum allowed height of the entire maze in cells (`Levels.txt` `SizeY`).
* When specified, any room placement that would expand the overall maze bounding box
* beyond this height will be rejected.
*/
readonly maxCellsY?: number | undefined
/**
* Maximum allowed sections along X axis.
* If provided and `maxCellsX` is not specified, `maxCellsX` is computed as `maxSectionsX * sectionSize`.
*/
readonly maxSectionsX?: number | undefined
/**
* Maximum allowed sections along Y axis.
* 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>
}
@ -294,6 +368,20 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [
['Next', 'next'],
['Prev', 'prev'],
['Theme', 'theme'],
['Waypoint', 'waypoint'],
['waypoint', 'waypoint'],
['Talrasha', 'quest'],
["Radament's Lair", 'quest'],
['Radament', 'quest'],
['Tight Spot', 'quest'],
['Forge', 'quest'],
['Court Connect', 'next'],
['Bridge', 'next'],
['Complex', 'quest'],
['Final Room', 'quest'],
['Cube', 'treasure'],
['Leatherarm', 'quest'],
['Kaa', 'quest'],
]
/** Which `LvlPrest` name families belong to which maze level type. */
@ -312,9 +400,11 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
'Act 2 - Sanctuary': ['Act 2 - Sanctuary'],
'Act 3 - Sewer': ['Act 3 - Sewer'],
'Act 3 - Kurast': ['Act 3 - Mephisto', 'Act 3 - Kurast'],
'Act 3 - Dungeon': ['Act 3 - Dungeon', 'Act 3 - Temple'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Bridge'],
'Act 5 - Ice Caves': ['Act 5 - Ice Caves', 'Act 5 - Ice'],
'Act 5 - Temple': ['Act 5 - Temple'],
'Act 5 - Baal': ['Act 5 - Baal'],
'Act 5 - Baal': ['Act 5 - Baal', 'Act 5 - ThroneRoom'],
}
/**
@ -333,26 +423,37 @@ export const MAZE_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> =
*/
export function classifyMazePieceName(name: string, levelTypeName: string): { kind: MazePieceKind; sides: string } | null {
const families = MAZE_PIECE_FAMILIES[levelTypeName] ?? [levelTypeName]
const matchedFamily = families.find(family => name.startsWith(family))
const matchedFamily = families.find(family => name.toLowerCase().startsWith(family.toLowerCase()))
if (matchedFamily === undefined) return null
const rest = name.slice(matchedFamily.length).trim()
if (rest === '') return { kind: 'entrance', sides: '' }
for (const [prefix, kind] of KIND_PREFIXES) {
if (!rest.startsWith(prefix)) continue
const tail = rest.slice(prefix.length).trim()
// `Treasure 1` is numbered rather than sided; everything else uses sides.
if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' }
return { kind, sides: tail }
if (rest === '' || rest.toLowerCase() === 'entrance') {
if (matchedFamily.toLowerCase().endsWith('throneroom')) return { kind: 'quest', sides: '' }
return { kind: 'entrance', sides: '' }
}
const reversedMatch = /^([NSEW]+)\s+(up|down)$/i.exec(rest)
for (const [prefix, kind] of KIND_PREFIXES) {
if (rest.toLowerCase().startsWith(prefix.toLowerCase())) {
const tail = rest.slice(prefix.length).trim()
// `Treasure 1`, `Temple 1`, `Bridge 1` are numbered rather than sided; everything else uses sides.
if ((kind === 'treasure' || kind === 'quest' || kind === 'next') && /^\d+$/.test(tail)) {
return { kind, sides: '' }
}
return { kind, sides: tail }
}
}
if (/^\d+$/.test(rest)) {
if (matchedFamily.toLowerCase().endsWith('bridge')) return { kind: 'next', sides: '' }
if (matchedFamily.toLowerCase().endsWith('temple')) return { kind: 'quest', sides: '' }
if (matchedFamily.toLowerCase().endsWith('treasure')) return { kind: 'treasure', sides: '' }
}
const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest)
if (reversedMatch !== null) {
const sides = reversedMatch[1]!.toUpperCase()
const role = reversedMatch[2]!.toLowerCase()
return { kind: role === 'up' ? 'prev' : 'next', sides }
return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', sides }
}
// Anything left is a bare side token: `W`, `EW`, `NSEW`, ...
if (/^[NSEW]+$/.test(rest)) return { kind: 'room', sides: rest }
if (/^(River|Pool|Tainted Sun)/.test(rest)) return { kind: 'room', sides: '' }
if (/^[NSEW]+$/i.test(rest)) return { kind: 'room', sides: rest.toUpperCase() }
if (/^(River|Pool|Tainted Sun)/i.test(rest)) return { kind: 'room', sides: '' }
return null
}
@ -395,7 +496,7 @@ export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null
*/
interface SpecialStep {
/** Which kind of special piece to place. */
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure'
readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' | 'waypoint'
/** Side token of the plain room this step replaces, e.g. `N`. */
readonly plainSides: string
/** Side token of the special piece. */
@ -467,6 +568,8 @@ export interface MazeLevelProfile {
readonly singleRoom?: Readonly<Record<number, readonly string[]>>
/** `Prev`/`Next`/quest/treasure replacement groups, in call order. */
readonly specials: readonly SpecialGroup[]
/** Optional official dungeon special pass from D2MOO. */
readonly specialPass?: SpecialPassName
}
/** The four single-side layouts, in the order the D2MOO tables list them. */
@ -526,6 +629,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
specials: [
{ kind: 'next', entries: entries() },
],
specialPass: 'DRLGMAZE_PlaceAct1Barracks',
},
{
levelTypeName: 'Act 1 - Jail',
@ -534,7 +638,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
theme: true,
specials: [
{ kind: 'prev', entries: entries() },
{ kind: 'treasure', entries: entries(), levelIds: [29] },
{ kind: 'waypoint', entries: entries(), levelIds: [29] },
{ kind: 'quest', entries: entries(), levelIds: [30] },
// D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a
// `Next` staircase and level 3 gets the Cathedral descent instead.
@ -549,7 +653,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
opening: 'catacombs',
specials: [
{ kind: 'next', entries: entries() },
{ kind: 'treasure', entries: entries(), levelIds: [35] },
{ kind: 'waypoint', entries: entries(), levelIds: [35] },
],
},
{
@ -561,6 +665,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [47, 48] },
],
specialPass: 'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
},
{
levelTypeName: 'Act 2 - Tomb',
@ -571,6 +676,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
specials: [
{ kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] },
],
specialPass: 'DRLGMAZE_PlaceAct2TombStuff',
},
{
levelTypeName: 'Act 2 - Lair',
@ -581,6 +687,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries() },
{ kind: 'next', entries: entries(), levelIds: [62, 63] },
],
specialPass: 'DRLGMAZE_PlaceAct2LairStuff',
},
{
levelTypeName: 'Act 2 - Arcane',
@ -601,6 +708,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries(), levelIds: [100, 101] },
{ kind: 'next', entries: entries(), levelIds: [100, 101] },
],
specialPass: 'DRLGMAZE_PlaceAct3MephistoStuff',
},
{
levelTypeName: 'Act 3 - Dungeon',
@ -611,6 +719,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'prev', entries: entries(), levelIds: [86, 87, 88, 89] },
{ kind: 'next', entries: entries(), levelIds: [86, 87, 88, 89] },
],
specialPass: 'DRLGMAZE_PlaceAct3DungeonStuff',
},
{
levelTypeName: 'Act 3 - Sewer',
@ -649,6 +758,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
levelIds: [92],
},
],
specialPass: 'DRLGMAZE_PlaceAct3SewerStuff',
},
{
levelTypeName: 'Act 4 - Lava',
@ -666,6 +776,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
levelIds: [107],
},
],
specialPass: 'DRLGMAZE_PlaceAct4Lava',
},
{
levelTypeName: 'Act 5 - Ice Caves',
@ -682,6 +793,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
{ kind: 'next', entries: entries(), levelIds: [113, 115, 118] },
{ kind: 'down', entries: entries(), levelIds: [113, 115, 118] },
],
specialPass: 'DRLGMAZE_PlaceAct5IceStuff',
},
{
levelTypeName: 'Act 5 - Temple',
@ -712,6 +824,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
inPlaceOnly: true,
},
],
specialPass: 'DRLGMAZE_PlaceAct5TempleStuff',
},
{
levelTypeName: 'Act 5 - Baal',
@ -721,6 +834,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
specials: [
{ kind: 'next', entries: entries(), levelIds: [128, 129, 130] },
],
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
},
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
]
@ -736,22 +850,10 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
export const MAZE_SHARED_PRESET_NAMES: readonly string[] = ['Act 4 - Lava X']
/**
* Per-act decoration passes D2MOO runs after the layout, which this port does not
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses`.
* Per-act decoration passes D2MOO runs after the layout.
* All 11 core dungeon passes are now implemented in `maze-special-passes.ts`.
*/
const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGMAZE_PlaceAct2TombStuff',
'DRLGMAZE_PlaceAct2LairStuff',
'DRLGMAZE_PlaceAct3DungeonStuff',
'DRLGMAZE_PlaceAct3SewerStuff',
'DRLGMAZE_PlaceAct3MephistoStuff',
'DRLGMAZE_PlaceAct5TempleStuff',
'DRLGMAZE_PlaceAct5BaalStuff',
'DRLGMAZE_PlaceAct5IceStuff',
'DRLGMAZE_PlaceAct1Barracks',
'DRLGMAZE_PlaceAct4Lava',
'DRLGMAZE_ScanReplaceSpecialAct2SewersPresets',
]
export const UNIMPLEMENTED_PASSES: readonly string[] = []
/* ------------------------------------------------------------------------- *
* Geometry
@ -833,7 +935,7 @@ function directionFrom(from: Rect, to: Rect): number {
* ------------------------------------------------------------------------- */
/** One maze section. */
interface Room extends Rect {
export interface Room extends Rect {
/** Doorways to neighbouring rooms, in insertion order. */
readonly orths: { room: Room; direction: number }[]
/** Creation index, so diagnostics can name a room stably. */
@ -856,7 +958,7 @@ interface Room extends Rect {
}
/** The level under construction. */
interface MazeLevel {
export interface MazeLevel {
/** Rooms newest-first, matching `DRLGROOM_AddRoomExToLevel`'s prepend. */
readonly rooms: Room[]
/** The first room created; D2MOO keeps this pointer across prepends. */
@ -869,6 +971,9 @@ interface MazeLevel {
nextId: number
/** Doorways added by the merge pass, for reporting. */
merges: number
readonly maxCellsX?: number | undefined
readonly maxCellsY?: number | undefined
readonly stats?: MazeStats | undefined
}
/**
@ -878,16 +983,20 @@ interface MazeLevel {
* facts are which piece answered which side pattern and where the exact pattern
* was missing.
*/
interface MazeStats {
export interface MazeStats {
readonly patterns: Record<string, Record<string, number>>
readonly fallbacks: { room: number; requested: string; used: string }[]
readonly specialsApplied: { room: number; kind: string; sides: string }[]
readonly specialPassesApplied: string[]
readonly unresolvedRoles: string[]
readonly notes: string[]
ringRooms: number
placementAttempts: number
boundsRejected?: number
/** Stair rooms, with the coordinates only `stamp` can work out. */
warps: MazeWarp[]
/** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */
waypoints: MazeWaypoint[]
}
/**
@ -964,14 +1073,63 @@ function linkRooms(a: Room, b: Room, direction: number): void {
* @param room - the room.
* @returns the mask, 0..15.
*/
function roomSideMask(room: Room): number {
export function roomSideMask(room: Room): number {
let mask = 0
for (const orth of room.orths) mask |= DIRECTION_SIDE_BIT[orth.direction]!
return mask
}
/**
* Whether a room overlaps anything other than itself and one ignored room.
* Whether placing `room` keeps the overall maze within `maxCellsX` and `maxCellsY`.
*
* In Diablo II, maze room growth is constrained by the level's global SizeX and SizeY limits
* from `Levels.txt`. If growing a candidate room would expand the bounding box of all placed
* rooms beyond the allowed dimensions, the placement attempt is rejected, forcing the maze
* to grow in other available directions and cluster compactly.
*
* @param level - the level.
* @param room - the candidate room with proposed coordinates and dimensions.
* @returns true if within bounds or bounds are unconstrained.
*/
function checkRoomWithinBounds(level: MazeLevel, room: Rect): boolean {
if (level.maxCellsX === undefined && level.maxCellsY === undefined) return true
if (level.rooms.length === 0) return true
if (level.maxCellsX !== undefined && level.maxCellsX > 0) {
let minX = room.x
let maxX = room.x + room.width
for (const other of level.rooms) {
if (other.x < minX) minX = other.x
if (other.x + other.width > maxX) maxX = other.x + other.width
}
if (maxX - minX > level.maxCellsX) {
if (level.stats) {
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
}
return false
}
}
if (level.maxCellsY !== undefined && level.maxCellsY > 0) {
let minY = room.y
let maxY = room.y + room.height
for (const other of level.rooms) {
if (other.y < minY) minY = other.y
if (other.y + other.height > maxY) maxY = other.y + other.height
}
if (maxY - minY > level.maxCellsY) {
if (level.stats) {
level.stats.boundsRejected = (level.stats.boundsRejected ?? 0) + 1
}
return false
}
}
return true
}
/**
* Whether a room overlaps anything other than itself and one ignored room, and fits within level bounds.
*
* `DRLGMAZE_CheckRoomNotOverlaping`. The margin is 0, so face-to-face neighbours
* are fine and only genuine overlap fails.
@ -979,9 +1137,10 @@ function roomSideMask(room: Room): number {
* @param level - the level.
* @param room - the candidate.
* @param ignored - a room to skip, usually its parent.
* @returns true when the room is clear.
* @returns true when the room is clear and within global level bounds.
*/
function checkRoomNotOverlapping(level: MazeLevel, room: Rect, ignored: Room | null): boolean {
if (!checkRoomWithinBounds(level, room)) return false
for (const other of level.rooms) {
if (other === room || other === ignored) continue
if (!notOverlapping(room, other, 0)) return false
@ -1046,7 +1205,7 @@ function findUnfinishedRoom(level: MazeLevel, mask: number | null): Room | null
* `DRLGMAZE_FillBlankMazeSpaces` deliberately does not.
* @returns the new room, or null.
*/
function addAdjacentMazeRoom(
export function addAdjacentMazeRoom(
level: MazeLevel,
parent: Room,
direction: number,
@ -1118,7 +1277,7 @@ function mergeMazeRooms(level: MazeLevel, pivot: Room, index: PieceIndex, stats:
* ------------------------------------------------------------------------- */
/** Indexed pieces, so a side mask is one map lookup. */
interface PieceIndex {
export interface PieceIndex {
/**
* The piece for a room's doorway mask.
*
@ -1226,7 +1385,7 @@ function buildPieceIndex(pieces: readonly MazePiece[]): PieceIndex {
* @param sides - the side token; `''` accepts any piece of that kind.
* @returns the piece, or undefined.
*/
function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
export function findSpecial(pieces: readonly MazePiece[], kind: MazePieceKind, sides: string): MazePiece | undefined {
return pieces.find(piece => piece.kind === kind
&& (sides === '' || sideMaskFromToken(piece.sides) === sideMaskFromToken(sides)))
}
@ -1264,7 +1423,7 @@ function pickRoomPreset(room: Room, index: PieceIndex, stats: MazeStats): void {
* @param token - the side token it answers.
* @param stats - report collector.
*/
function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
export function noteSpecial(room: Room, piece: MazePiece, token: string, stats: MazeStats): void {
stats.specialsApplied.push({ room: room.id, kind: piece.kind, sides: token })
stats.patterns[piece.kind] ??= {}
stats.patterns[piece.kind]![token] = (stats.patterns[piece.kind]![token] ?? 0) + 1
@ -1523,7 +1682,7 @@ function lavaPairs(level: MazeLevel, pieces: readonly MazePiece[], index: PieceI
* @param index - the piece index.
* @param stats - report collector.
*/
function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
export function fillBlankSpaces(level: MazeLevel, pieces: readonly MazePiece[], index: PieceIndex, stats: MazeStats): void {
const filler = pieces.find(piece => piece.name === 'Act 4 - Lava X')
if (filler === undefined) {
stats.unresolvedRoles.push('Act 4 - Lava X')
@ -1594,7 +1753,7 @@ function themeRoll(level: MazeLevel, pieces: readonly MazePiece[], stats: MazeSt
* Compute BFS topological distance of every room from the level's entrance room
* (the room holding a 'prev' or 'entrance' piece, or `level.first`).
*/
function bfsDistances(level: MazeLevel): Map<Room, number> {
export function bfsDistances(level: MazeLevel): Map<Room, number> {
const start = level.rooms.find(r => r.piece?.kind === 'prev' || r.piece?.kind === 'entrance') ?? level.first
const dist = new Map<Room, number>([[start, 0]])
const queue: Room[] = [start]
@ -1612,8 +1771,9 @@ function bfsDistances(level: MazeLevel): Map<Room, number> {
}
/**
* Place a special room group using topological dead-end / BFS distance selection
* and `LvlPrest.txt` full-room preset substitution.
* Place a special room group following standard D2 scanning rules:
* scans room candidates against valid wall connections, LvlPrest file definitions,
* and room flags, rather than imposing an artificial global maximum BFS distance.
*
* @param level - the level.
* @param pieces - the level's pieces.
@ -1630,11 +1790,9 @@ function placeSpecialGroup(
index: PieceIndex,
stats: MazeStats,
): void {
const isExit = group.kind === 'next' || group.kind === 'down' || group.kind === 'quest'
const distMap = bfsDistances(level)
// Phase 1: Try to replace an existing unfinished room in place, preferring
// maximum BFS distance from entrance for exits/objectives and minimum for entrances.
// Phase 1: Try to replace an existing unfinished room in place, scanning
// room candidates against valid wall connections, LvlPrest definitions,
// and room flags in standard D2 roster order.
for (let offset = 0; offset < group.entries.length; offset += 1) {
const entry = group.entries[(startCursor + offset) % group.entries.length]!
const wanted = sideMaskFromToken(entry.plainSides)
@ -1642,20 +1800,14 @@ function placeSpecialGroup(
const special = findSpecial(pieces, group.kind, entry.sides)
if (special === undefined) continue
const candidates = level.rooms.filter(r => {
const chosen = level.rooms.find(r => {
if (roomSideMask(r) !== wanted) return false
if (!r.hasDs1) return true
if (group.inPlaceOnly === true && (r.piece === special || r.piece?.kind === 'theme')) return true
return false
})
if (candidates.length > 0) {
candidates.sort((a, b) => {
const da = distMap.get(a) ?? 0
const db = distMap.get(b) ?? 0
return isExit ? db - da : da - db
})
const chosen = candidates[0]!
if (chosen !== undefined) {
chosen.piece = special
chosen.variant = 0
chosen.hasDs1 = true
@ -1670,16 +1822,12 @@ function placeSpecialGroup(
return
}
// Phase 2: Grow a new dead-end room off the best available parent room.
const parentCandidates = level.rooms.filter(
r => !r.hasDs1 || r.piece?.kind === 'theme',
)
parentCandidates.sort((a, b) => {
if (a.hasDs1 !== b.hasDs1) return a.hasDs1 ? 1 : -1
const da = distMap.get(a) ?? 0
const db = distMap.get(b) ?? 0
return isExit ? db - da : da - db
})
// Phase 2: Grow a new dead-end room off an available parent room in D2
// roster order (plain rooms without DS1 scanned first, followed by theme rooms).
const parentCandidates = [
...level.rooms.filter(r => !r.hasDs1),
...level.rooms.filter(r => r.hasDs1 && r.piece?.kind === 'theme'),
]
for (const parent of parentCandidates) {
for (let offset = 0; offset < group.entries.length; offset += 1) {
@ -1871,6 +2019,36 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 {
centreY: originY + Math.floor(variant.height / 2),
})
}
if (piece.kind === 'waypoint') {
const wpObj = variant.objects.find(obj => obj.type === 2 && (obj.id === 119 || obj.id === 145 || obj.id === 156 || obj.id === 157 || obj.id === 237 || obj.id === 238 || obj.id === 288 || obj.id === 323 || obj.id === 324 || obj.id === 398 || obj.id === 402 || obj.id === 429)) ?? variant.objects.find(obj => obj.type === 2)
let entityX: number
let entityY: number
if (wpObj !== undefined) {
entityX = originX * SUB_TILES_PER_TILE + wpObj.x
entityY = originY * SUB_TILES_PER_TILE + wpObj.y
} else {
entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2
entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2
objects.push({
type: 2,
id: 119,
x: entityX,
y: entityY,
flags: 0,
})
}
stats.waypoints.push({
room: room.id,
x: originX,
y: originY,
width: variant.width,
height: variant.height,
centreX: originX + Math.floor(variant.width / 2),
centreY: originY + Math.floor(variant.height / 2),
entityX,
entityY,
})
}
stampedRooms += 1
}
@ -1959,15 +2137,22 @@ export function generateMaze(request: MazeRequest): MazeResult {
if (profile === undefined) throw new Error(`${where}: no maze profile for level type "${levelTypeName}"`)
const stats: MazeStats = {
patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [],
ringRooms: 0, placementAttempts: 0, warps: [],
patterns: {}, fallbacks: [], specialsApplied: [], specialPassesApplied: [], unresolvedRoles: [], notes: [],
ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [],
}
const rng = new Rng(request.seed)
const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge)))
const index = buildPieceIndex(request.pieces)
const maxCellsX = request.maxCellsX !== undefined
? request.maxCellsX
: (request.maxSectionsX !== undefined ? request.maxSectionsX * width : undefined)
const maxCellsY = request.maxCellsY !== undefined
? request.maxCellsY
: (request.maxSectionsY !== undefined ? request.maxSectionsY * height : undefined)
const level: MazeLevel = {
rooms: [], first: null as unknown as Room, width, height, rng, mergePerMille, nextId: 0, merges: 0,
maxCellsX, maxCellsY, stats,
}
const first = makeRoom(level)
const levelRef: MazeLevel = { ...level, rooms: [first], first }
@ -2025,11 +2210,29 @@ export function generateMaze(request: MazeRequest): MazeResult {
// One counter shared by every table, advanced once per call that happens.
let cursor = rng.int(0, 3)
for (const group of profile.specials) {
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
if (group.kind === 'waypoint') {
if (request.hasWaypoint === false) continue
if (request.hasWaypoint !== true) {
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
}
} else {
if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue
if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue
}
placeSpecialGroup(level, request.pieces, group, cursor, index, stats)
cursor = (cursor + 1) % 4
}
if (profile.specialPass !== undefined) {
runMazeSpecialPasses(profile.specialPass, {
level,
request,
pieces: request.pieces,
index,
stats,
})
}
}
const unreachable = level.rooms.length - reachableRooms(level)
@ -2042,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,
@ -2060,14 +2304,33 @@ 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,
specialsApplied: stats.specialsApplied,
specialPassesApplied: stats.specialPassesApplied,
warps: stats.warps,
waypoints: stats.waypoints,
waypoint: stats.waypoints[0] ?? null,
waypointTile: stats.waypoints[0]
? {
x: Math.floor(stats.waypoints[0].entityX / SUB_TILES_PER_TILE),
y: Math.floor(stats.waypoints[0].entityY / SUB_TILES_PER_TILE),
}
: null,
unresolvedRoles: stats.unresolvedRoles,
unimplementedPasses: UNIMPLEMENTED_PASSES,
maxCellsX,
maxCellsY,
boundsRejected: stats.boundsRejected ?? 0,
notes: stats.notes,
},
}

544
src/game/monster-rooms.ts Normal file
View File

@ -0,0 +1,544 @@
/**
* Room-Local Monster Clustering and Placement.
*
* Replaces global spiral monster placement with authentic Diablo II room-based
* spatial clustering and allocation. In Diablo II, monsters are partitioned into
* rooms (sectors of ~20-30 cells outdoors, or discrete maze preset pieces indoors),
* avoiding player safe zones (towns, waypoint clearings, level entrances), with
* landmark SuperUniques fixed to their specific room and minions clustered tightly
* in camp rings around their leader.
*/
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
import { Rng } from './rng.ts'
/**
* A discrete spatial room or sector in a level.
*/
export interface MapRoom {
readonly id: string | number
readonly x: number // world/scene pixels
readonly y: number
readonly width: number
readonly height: number
readonly cellX: number // grid cell coordinates
readonly cellY: number
readonly cellsX: number
readonly cellsY: number
readonly isSafeZone?: boolean | undefined
readonly isPreset?: boolean | undefined
}
/**
* Options for partitioning a level map into rooms.
*/
export interface PartitionOptions {
/** Room cell size for outdoor grid sector partitioning (default ~24 cells). */
readonly roomCellSize?: number | undefined
/** Discrete maze rooms from the dungeon generator (e.g. from maze.ts). */
readonly mazeRooms?: readonly {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly id?: string | number | undefined
readonly isPreset?: boolean | undefined
readonly isSafeZone?: boolean | undefined
}[] | undefined
/** Circular exclusion zones where ordinary monster camps must not spawn. */
readonly safeZones?: readonly {
readonly x: number
readonly y: number
readonly radius: number
}[] | undefined
/** Cell pixel width (default 80 px). */
readonly cellPixelWidth?: number | undefined
/** Cell pixel height (default 80 px). */
readonly cellPixelHeight?: number | undefined
/** Uniform cell pixel size shorthand (default 80 px). */
readonly cellPixelSize?: number | undefined
/** Origin X offset in pixels (default 0). */
readonly originX?: number | undefined
/** Origin Y offset in pixels (default 0). */
readonly originY?: number | undefined
}
/** Default camp radius for room-local monster clustering (48~64 pixels). */
export const PACK_RADIUS_PX = 64
/**
* Check if a room boundary overlaps any safe zone circle.
* Tests in both pixel space and cell space for maximum compatibility.
*/
function checkSafeZoneOverlap(
x: number,
y: number,
width: number,
height: number,
cellX: number,
cellY: number,
cellsX: number,
cellsY: number,
safeZones?: readonly { readonly x: number; readonly y: number; readonly radius: number }[],
): boolean {
if (safeZones === undefined || safeZones.length === 0) return false
for (const sz of safeZones) {
// Check in world pixel space
const clampPxX = Math.max(x, Math.min(sz.x, x + width))
const clampPxY = Math.max(y, Math.min(sz.y, y + height))
const pxDistSq = (sz.x - clampPxX) ** 2 + (sz.y - clampPxY) ** 2
if (pxDistSq <= sz.radius * sz.radius) return true
// Check in cell space (in case safeZone coordinates were given in cells)
const clampCellX = Math.max(cellX, Math.min(sz.x, cellX + cellsX))
const clampCellY = Math.max(cellY, Math.min(sz.y, cellY + cellsY))
const cellDistSq = (sz.x - clampCellX) ** 2 + (sz.y - clampCellY) ** 2
if (cellDistSq <= sz.radius * sz.radius) return true
}
return false
}
/**
* Partition a map into rooms.
*
* If `mazeRooms` are supplied (from dungeon generator), map them directly into `MapRoom`s.
* Otherwise (wilderness / outdoors), partition the level into authentic ~20-30 cell rooms
* (e.g. 24x24 cells = 120x120 subtiles), matching D2's room grid.
* Automatically flag rooms overlapping `safeZones` as `isSafeZone = true`.
*
* @param cellsX - total level width in cells.
* @param cellsY - total level height in cells.
* @param options - partitioning options.
* @returns partitioned MapRooms.
*/
export function partitionMapIntoRooms(
cellsX: number,
cellsY: number,
options?: PartitionOptions,
): MapRoom[] {
const cellWidth = options?.cellPixelWidth ?? options?.cellPixelSize ?? 80
const cellHeight = options?.cellPixelHeight ?? options?.cellPixelSize ?? 80
const originX = options?.originX ?? 0
const originY = options?.originY ?? 0
if (options?.mazeRooms !== undefined && options.mazeRooms.length > 0) {
return options.mazeRooms.map((mr, index) => {
const cellX = mr.x
const cellY = mr.y
const rCellsX = mr.width
const rCellsY = mr.height
const x = cellX * cellWidth + originX
const y = cellY * cellHeight + originY
const width = rCellsX * cellWidth
const height = rCellsY * cellHeight
const id = mr.id ?? index
const isPreset = mr.isPreset ?? true
const isSafeZone = mr.isSafeZone ?? checkSafeZoneOverlap(
x, y, width, height, cellX, cellY, rCellsX, rCellsY, options.safeZones,
)
return {
id,
x,
y,
width,
height,
cellX,
cellY,
cellsX: rCellsX,
cellsY: rCellsY,
isPreset,
...(isSafeZone ? { isSafeZone: true } : {}),
}
})
}
// Wilderness / outdoors: grid partition into ~24x24 cell rooms
const roomCellSize = options?.roomCellSize ?? 24
const rooms: MapRoom[] = []
let index = 0
for (let cy = 0; cy < cellsY; cy += roomCellSize) {
const rCellsY = Math.min(roomCellSize, cellsY - cy)
for (let cx = 0; cx < cellsX; cx += roomCellSize) {
const rCellsX = Math.min(roomCellSize, cellsX - cx)
const x = cx * cellWidth + originX
const y = cy * cellHeight + originY
const width = rCellsX * cellWidth
const height = rCellsY * cellHeight
const isSafeZone = checkSafeZoneOverlap(
x, y, width, height, cx, cy, rCellsX, rCellsY, options?.safeZones,
)
rooms.push({
id: index++,
x,
y,
width,
height,
cellX: cx,
cellY: cy,
cellsX: rCellsX,
cellsY: rCellsY,
isPreset: false,
...(isSafeZone ? { isSafeZone: true } : {}),
})
}
}
return rooms
}
/**
* Allocate monster packs to rooms according to Diablo II rules:
* - Distributes non-landmark packs across non-safe rooms (avoiding town, stair buffers).
* - Fixed landmark packs (SuperUniques with `landmarkTile` or `fixedPosition`) route directly
* to the room covering their coordinates.
* - Avoids over-crowding by balancing pack counts across candidate rooms based on room area.
*
* @param packs - list of monster packs to distribute.
* @param rooms - candidate rooms.
* @param seed - optional random seed for deterministic distribution.
* @returns Map from each MapRoom to its allocated MonsterPack array.
*/
export function allocatePacksToRooms(
packs: readonly MonsterPack[],
rooms: readonly MapRoom[],
seed?: number,
): Map<MapRoom, MonsterPack[]> {
const allocation = new Map<MapRoom, MonsterPack[]>()
for (const room of rooms) {
allocation.set(room, [])
}
if (rooms.length === 0 || packs.length === 0) {
return allocation
}
const landmarkPacks: MonsterPack[] = []
const normalPacks: MonsterPack[] = []
for (const pack of packs) {
if (pack.fixedPosition !== undefined || pack.landmarkTile !== undefined) {
landmarkPacks.push(pack)
} else {
normalPacks.push(pack)
}
}
// 1. Fixed landmark SuperUniques route directly to their covering room
for (const pack of landmarkPacks) {
let targetRoom: MapRoom | undefined
if (pack.fixedPosition !== undefined) {
const fx = pack.fixedPosition.x
const fy = pack.fixedPosition.y
targetRoom = rooms.find(r => fx >= r.x && fx <= r.x + r.width && fy >= r.y && fy <= r.y + r.height)
if (targetRoom === undefined) {
// Fallback: nearest room center
let minDist = Infinity
for (const r of rooms) {
const cx = r.x + r.width / 2
const cy = r.y + r.height / 2
const d = Math.hypot(fx - cx, fy - cy)
if (d < minDist) {
minDist = d
targetRoom = r
}
}
}
} else if (pack.landmarkTile !== undefined) {
const tx = pack.landmarkTile.tileX
const ty = pack.landmarkTile.tileY
targetRoom = rooms.find(r => tx >= r.cellX && tx < r.cellX + r.cellsX && ty >= r.cellY && ty < r.cellY + r.cellsY)
if (targetRoom === undefined) {
targetRoom = rooms.find(r => tx >= r.cellX && tx <= r.cellX + r.cellsX && ty >= r.cellY && ty <= r.cellY + r.cellsY)
}
if (targetRoom === undefined) {
// Fallback: nearest room by cell center
let minDist = Infinity
for (const r of rooms) {
const cx = r.cellX + r.cellsX / 2
const cy = r.cellY + r.cellsY / 2
const d = Math.hypot(tx - cx, ty - cy)
if (d < minDist) {
minDist = d
targetRoom = r
}
}
}
}
if (targetRoom !== undefined && allocation.has(targetRoom)) {
allocation.get(targetRoom)!.push(pack)
}
}
// 2. Normal packs: distribute to non-safe rooms, balancing by room area
let candidateRooms = rooms.filter(r => !r.isSafeZone)
if (candidateRooms.length === 0) {
// Fallback if all rooms are marked safe
candidateRooms = rooms.slice()
}
const rng = new Rng(seed ?? 0x5eed)
for (const pack of normalPacks) {
// Score based on pack density: (currentCount + 1) / area
let minScore = Infinity
for (const r of candidateRooms) {
const count = allocation.get(r)!.length
const area = Math.max(1, r.cellsX * r.cellsY)
const score = (count + 1) / area
if (score < minScore) {
minScore = score
}
}
const bestRooms: MapRoom[] = []
for (const r of candidateRooms) {
const count = allocation.get(r)!.length
const area = Math.max(1, r.cellsX * r.cellsY)
const score = (count + 1) / area
if (Math.abs(score - minScore) < 1e-9) {
bestRooms.push(r)
}
}
const picked = bestRooms[rng.int(0, bestRooms.length - 1)]!
allocation.get(picked)!.push(pack)
}
return allocation
}
/**
* Spawns a single monster pack inside a given room.
*/
function spawnSinglePackInRoom(
world: CombatWorld,
pack: MonsterPack,
room: MapRoom,
terrain: CombatTerrain,
): number {
if (pack.members.length === 0) return 0
const margin = Math.min(16, Math.max(2, room.width * 0.05), Math.max(2, room.height * 0.05))
const minSpawnX = room.x + margin
const maxSpawnX = room.x + room.width - margin
const minSpawnY = room.y + margin
const maxSpawnY = room.y + room.height - margin
// 1. Leader placement near room center or fixed landmark position
const startX = pack.fixedPosition !== undefined
? pack.fixedPosition.x
: room.x + room.width / 2
const startY = pack.fixedPosition !== undefined
? pack.fixedPosition.y
: room.y + room.height / 2
let leaderX = Math.max(minSpawnX, Math.min(maxSpawnX, startX))
let leaderY = Math.max(minSpawnY, Math.min(maxSpawnY, startY))
let leaderFound = false
// Check initial point
if (terrain.overlap(leaderX, leaderY) === 0) {
leaderFound = true
} else {
// Spiral search outward
const maxRadius = Math.max(room.width, room.height) / 2
for (let step = 1; step <= 64; step += 1) {
const angle = step * 2.399963
const radius = Math.min(maxRadius, step * 8)
const sx = Math.max(minSpawnX, Math.min(maxSpawnX, startX + Math.cos(angle) * radius))
const sy = Math.max(minSpawnY, Math.min(maxSpawnY, startY + Math.sin(angle) * radius))
if (terrain.overlap(sx, sy) === 0) {
leaderX = sx
leaderY = sy
leaderFound = true
break
}
}
}
// Grid fallback if spiral failed
if (!leaderFound) {
const stepX = Math.max(8, (maxSpawnX - minSpawnX) / 16)
const stepY = Math.max(8, (maxSpawnY - minSpawnY) / 16)
for (let gy = minSpawnY; gy <= maxSpawnY; gy += stepY) {
for (let gx = minSpawnX; gx <= maxSpawnX; gx += stepX) {
if (terrain.overlap(gx, gy) === 0) {
leaderX = gx
leaderY = gy
leaderFound = true
break
}
}
if (leaderFound) break
}
}
if (!leaderFound) return 0
const leaderDef = pack.members[0]!
world.monsters.push({
index: world.monsters.length,
stats: leaderDef,
x: leaderX,
y: leaderY,
hp: leaderDef.hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
let placed = 1
// 2. Minions placed in tight camp ring (PACK_RADIUS_PX = 48~64) around leader
const minions = pack.members.slice(1)
let minionPlaced = 0
let attempt = 0
const maxAttempts = minions.length * 32
const baseAngle = typeof room.id === 'number' ? room.id * 1.5 : 0.8
const effectiveRadius = Math.min(PACK_RADIUS_PX, Math.max(16, Math.min(room.width, room.height) / 2 - margin))
while (minionPlaced < minions.length && attempt < maxAttempts) {
attempt += 1
const angle = baseAngle + attempt * 2.399963
const distFrac = Math.sqrt(attempt / (minions.length * 12))
const radius = Math.min(effectiveRadius, 16 + (effectiveRadius - 16) * distFrac)
let mx = leaderX + Math.cos(angle) * radius
let my = leaderY + Math.sin(angle) * radius
// Clamps within room bounding boxes
mx = Math.max(minSpawnX, Math.min(maxSpawnX, mx))
my = Math.max(minSpawnY, Math.min(maxSpawnY, my))
// Respects terrain collisions
if (terrain.overlap(mx, my) > 0) continue
const minionDef = minions[minionPlaced]!
world.monsters.push({
index: world.monsters.length,
stats: minionDef,
x: mx,
y: my,
hp: minionDef.hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
minionPlaced += 1
placed += 1
}
return placed
}
/**
* Spawns monster pack(s) inside the boundaries of the specified `room`.
*
* Leader is positioned at a clear open sub-tile near the room's interior center;
* minions are placed in a tight camp ring (PACK_RADIUS_PX = 48~64) around the leader.
* Respects terrain collisions (terrain.overlap(x, y) === 0) and clamps within room
* bounding boxes (x in [minX + margin, maxX - margin]).
*
* @param world - the world to populate.
* @param pack - the pack or packs to spawn.
* @param room - the room boundary to spawn within.
* @param terrain - collision predicate.
* @returns how many monsters were placed.
*/
export function spawnPacksInRoom(
world: CombatWorld,
pack: MonsterPack | readonly MonsterPack[],
room: MapRoom,
terrain: CombatTerrain,
): number {
if (Array.isArray(pack)) {
let count = 0
for (const p of pack) {
count += spawnSinglePackInRoom(world, p, room, terrain)
}
return count
}
return spawnSinglePackInRoom(world, pack as MonsterPack, room, terrain)
}
/** Alias for `spawnPacksInRoom` with singular naming. */
export const spawnPackInRoom = spawnPacksInRoom
/**
* Options for `spawnMonsterPacksByRooms`.
*/
export interface SpawnMonsterPacksByRoomsOptions {
readonly seed?: number | undefined
readonly safeZones?: readonly SafeZone[] | undefined
readonly spread?: number | undefined
}
/**
* Modern, clean replacement for `spawnMonsterPacks`, allocating packs across rooms
* and clustering members tightly within their assigned room boundaries.
*
* Also maintains backwards compatibility if plain packs with a center point are passed.
*
* @param world - world to populate.
* @param packs - monster packs to spawn.
* @param roomsOrAround - room array or fallback center point.
* @param terrain - collision predicate.
* @param options - allocation options or safe zones.
* @returns total monsters placed.
*/
export function spawnMonsterPacksByRooms(
world: CombatWorld,
packs: readonly MonsterPack[],
roomsOrAround: readonly MapRoom[] | { readonly x: number; readonly y: number },
terrain: CombatTerrain,
options?: SpawnMonsterPacksByRoomsOptions | readonly SafeZone[],
): number {
if (packs.length === 0) return 0
let safeZones: readonly SafeZone[] | undefined
let seed: number | undefined
let spread = 600
if (Array.isArray(options)) {
safeZones = options
} else if (options !== undefined) {
const opts = options as SpawnMonsterPacksByRoomsOptions
safeZones = opts.safeZones
seed = opts.seed
if (opts.spread !== undefined) {
spread = opts.spread
}
}
if (!Array.isArray(roomsOrAround)) {
const around = roomsOrAround as { readonly x: number; readonly y: number }
const syntheticRooms = partitionMapIntoRooms(64, 64, {
roomCellSize: 16,
cellPixelSize: 80,
originX: around.x - spread,
originY: around.y - spread,
safeZones,
})
return spawnMonsterPacksByRooms(world, packs, syntheticRooms, terrain, {
safeZones,
seed,
})
}
const rooms = roomsOrAround as readonly MapRoom[]
if (rooms.length === 0) return 0
const allocation = allocatePacksToRooms(packs, rooms, seed)
let totalPlaced = 0
for (const [room, roomPacks] of allocation) {
for (const pack of roomPacks) {
totalPlaced += spawnPacksInRoom(world, pack, room, terrain)
}
}
return totalPlaced
}

View File

@ -0,0 +1,171 @@
/**
* Diablo II D2Game.dll style Active/Sleeping Room Streaming and Lazy Room Population.
*
* Rather than eagerly instantiating every monster pack on frame 0, rooms begin
* unpopulated (`bPopulated = false`). When the player approaches within
* `activationRadius`, monsters are instantiated into `world.monsters` once and
* only once (idempotent).
*
* When the player moves far away (> `deactivationRadius`), the room transitions to
* sleeping (`active = false`) and all living monsters have `monster.sleeping = true`,
* skipping all AI, aggro, cooldown calculations, and pathfinding in `tickMonsters`.
*
* When the player returns, monsters wake up (`monster.sleeping = false`) with their
* exact positions, health, and cooldowns preserved. Dead monsters remain dead.
*/
import { spawnMonsterPacks } from './combat.ts'
import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts'
/** Definition of a room holding unspawned monster pack blueprints. */
export interface StreamingRoomDef {
readonly id: string | number
readonly bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
readonly packs: readonly MonsterPack[]
}
/** Runtime state of a streaming room. */
export interface StreamingRoomState extends StreamingRoomDef {
bPopulated: boolean
active: boolean
monsterIndices: number[]
}
/** Configuration options for the streaming manager. */
export interface MonsterStreamingOptions {
/** Distance in pixels to populate and activate a room (default: 1600px ~ 1.5-2 screens). */
activationRadius?: number
/** Distance in pixels to deactivate and put a room to sleep (default: 2400px ~ 2.5-3 screens). */
deactivationRadius?: number
/** Optional callback invoked whenever a room is populated for the first time. */
onSpawnRoom?: (room: StreamingRoomState) => void
/** Optional safe zones to avoid when placing monsters inside rooms. */
safeZones?: readonly SafeZone[]
}
/**
* Calculates Euclidean distance from a point to an axis-aligned room bounding box.
* Returns 0 if the point is within the bounding box.
*
* @param px - point x coordinate.
* @param py - point y coordinate.
* @param bounds - room axis-aligned bounds.
* @returns Euclidean distance to the nearest point on the room boundary (or 0 if inside).
*/
export function distanceToRoom(
px: number,
py: number,
bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number },
): number {
const minX = Math.min(bounds.minX, bounds.maxX)
const maxX = Math.max(bounds.minX, bounds.maxX)
const minY = Math.min(bounds.minY, bounds.maxY)
const maxY = Math.max(bounds.minY, bounds.maxY)
const dx = Math.max(minX - px, 0, px - maxX)
const dy = Math.max(minY - py, 0, py - maxY)
return Math.hypot(dx, dy)
}
/**
* Manages lazy room population (`bPopulated`) and active/sleeping room streaming.
*/
export class MonsterStreamingManager {
public world: CombatWorld
public terrain: CombatTerrain
public rooms: StreamingRoomState[]
public activationRadius: number
public deactivationRadius: number
public options?: MonsterStreamingOptions | undefined
constructor(
world: CombatWorld,
terrain: CombatTerrain,
rooms: readonly StreamingRoomDef[],
options?: MonsterStreamingOptions,
) {
this.world = world
this.terrain = terrain
this.activationRadius = options?.activationRadius ?? 1600
this.deactivationRadius = options?.deactivationRadius ?? 2400
this.options = options
this.rooms = rooms.map(r => ({
id: r.id,
bounds: r.bounds,
packs: r.packs,
bPopulated: false,
active: false,
monsterIndices: [],
}))
// On initialization, only the room containing the player spawn and adjacent rooms
// within activationRadius are populated. Distant rooms remain unpopulated.
this.update(this.world.player.x, this.world.player.y)
}
/**
* Evaluates player distance to all rooms, populating unpopulated rooms within
* activationRadius, waking active rooms, and putting distant rooms to sleep.
*
* @param playerX - player world x position.
* @param playerY - player world y position.
*/
update(playerX: number = this.world.player.x, playerY: number = this.world.player.y): void {
for (const room of this.rooms) {
const dist = distanceToRoom(playerX, playerY, room.bounds)
if (dist <= this.activationRadius) {
if (!room.bPopulated) {
this.populateRoom(room)
}
room.active = true
for (const idx of room.monsterIndices) {
const monster = this.world.monsters[idx]
if (monster) {
monster.sleeping = false
}
}
} else if (dist > this.deactivationRadius) {
if (room.active) {
room.active = false
for (const idx of room.monsterIndices) {
const monster = this.world.monsters[idx]
if (monster && monster.state !== 'dead') {
monster.sleeping = true
}
}
}
}
}
}
/**
* Lazily instantiates a room's monster packs into `world.monsters`.
* Sets `bPopulated = true` to guarantee idempotency.
*/
private populateRoom(room: StreamingRoomState): void {
if (room.bPopulated) return
const startIndex = this.world.monsters.length
const centerX = (room.bounds.minX + room.bounds.maxX) / 2
const centerY = (room.bounds.minY + room.bounds.maxY) / 2
const width = Math.abs(room.bounds.maxX - room.bounds.minX)
const height = Math.abs(room.bounds.maxY - room.bounds.minY)
const spread = Math.max(width, height) / 2 || 100
spawnMonsterPacks(
this.world,
room.packs,
{ x: centerX, y: centerY },
spread,
this.terrain,
this.options?.safeZones,
)
const endIndex = this.world.monsters.length
for (let i = startIndex; i < endIndex; i += 1) {
room.monsterIndices.push(i)
}
room.bPopulated = true
this.options?.onSpawnRoom?.(room)
}
}

View File

@ -703,6 +703,7 @@ export const CANONICAL_ELITE_MODIFIERS: readonly {
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
{ id: 30, name: 'aura', label: 'Aura Enchanted', champion: false },
]
/**
@ -998,8 +999,9 @@ export interface SuperUniqueLandmarkSpec {
readonly minionMonsterId: string
}
/** Canonical Act 1 outdoor SuperUniques tied to level landmarks. */
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec>> = {
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>> = {
// Act 1
3: {
id: 'Bishibosh',
name: 'Bishibosh',
@ -1030,6 +1032,16 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
maxMinions: 4,
minionMonsterId: 'brute1',
},
6: {
id: 'The Countess',
name: 'The Countess',
monsterId: 'corruptrogue5',
landmark: 'Act 1 - Tower 1',
modifiers: ['fireenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'corruptrogue5',
},
17: {
id: 'Blood Raven',
name: 'Blood Raven',
@ -1040,6 +1052,296 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
maxMinions: 8,
minionMonsterId: 'zombie1',
},
25: {
id: 'The Countess',
name: 'The Countess',
monsterId: 'corruptrogue5',
landmark: 'Act 1 - Tower 1',
modifiers: ['fireenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'corruptrogue5',
},
// Act 2
41: {
id: 'Creeping Feature',
name: 'Creeping Feature',
monsterId: 'mummy1',
landmark: 'Act 2 - Desert Tomb 1',
modifiers: ['strong', 'coldenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'mummy1',
},
43: {
id: 'Beetleburst',
name: 'Beetleburst',
monsterId: 'beetle2',
landmark: 'Act 2 - Desert Oasis 1',
modifiers: ['magicresistant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'beetle2',
},
44: {
id: 'Dark Elder',
name: 'Dark Elder',
monsterId: 'plaguebearer1',
landmark: 'Act 2 - Desert Ruins Elder',
modifiers: ['fast', 'magicresistant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'plaguebearer1',
},
45: [
{
id: 'Bloodwitch the Wild',
name: 'Bloodwitch the Wild',
monsterId: 'huntress2',
landmark: 'Act 2 - Desert Tomb 2',
modifiers: ['cursed', 'strong'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'huntress2',
},
{
id: 'Fangskin',
name: 'Fangskin',
monsterId: 'clawviper3',
landmark: 'Act 2 - Desert Valley Ruin 2',
modifiers: ['lightenchant', 'fast'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'clawviper3',
},
],
55: {
id: 'Creeping Feature',
name: 'Creeping Feature',
monsterId: 'mummy1',
landmark: 'Act 2 - Desert Tomb 1',
modifiers: ['strong', 'coldenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'mummy1',
},
59: {
id: 'Creeping Feature',
name: 'Creeping Feature',
monsterId: 'mummy1',
landmark: 'Act 2 - Desert Tomb 1',
modifiers: ['strong', 'coldenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'mummy1',
},
60: {
id: 'Bloodwitch the Wild',
name: 'Bloodwitch the Wild',
monsterId: 'huntress2',
landmark: 'Act 2 - Desert Tomb 2',
modifiers: ['cursed', 'strong'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'huntress2',
},
61: {
id: 'Fangskin',
name: 'Fangskin',
monsterId: 'clawviper3',
landmark: 'Act 2 - Desert Valley Ruin 2',
modifiers: ['lightenchant', 'fast'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'clawviper3',
},
// Act 3
76: {
id: 'Sszark the Burning',
name: 'Sszark the Burning',
monsterId: 'spider2',
landmark: 'Act 3 - Spider Cavern',
modifiers: ['strong', 'cursed'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'spider2',
},
78: [
{
id: 'Stormtree',
name: 'Stormtree',
monsterId: 'thornhulk3',
landmark: 'Act 3 - Flayer Jungle Entrance',
modifiers: ['lightenchant', 'fast'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'thornhulk3',
},
{
id: 'Witch Doctor Endugu',
name: 'Witch Doctor Endugu',
monsterId: 'flayershaman4',
landmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'flayer4',
},
],
80: {
id: 'Battlemaid Sarina',
name: 'Battlemaid Sarina',
monsterId: 'corruptrogue8',
landmark: 'Act 3 - Burbs Temple',
modifiers: ['fast', 'spectralhit'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'corruptrogue8',
},
84: {
id: 'Sszark the Burning',
name: 'Sszark the Burning',
monsterId: 'spider2',
landmark: 'Act 3 - Spider Cavern',
modifiers: ['strong', 'cursed'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'spider2',
},
88: {
id: 'Witch Doctor Endugu',
name: 'Witch Doctor Endugu',
monsterId: 'flayershaman4',
landmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'flayer4',
},
91: {
id: 'Witch Doctor Endugu',
name: 'Witch Doctor Endugu',
monsterId: 'flayershaman4',
landmark: 'Act 3 - Flayer Dungeon',
modifiers: ['magicresistant', 'fireenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'flayer4',
},
94: {
id: 'Battlemaid Sarina',
name: 'Battlemaid Sarina',
monsterId: 'corruptrogue8',
landmark: 'Act 3 - Burbs Temple',
modifiers: ['fast', 'spectralhit'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'corruptrogue8',
},
// Act 4
105: {
id: 'Izual',
name: 'Izual',
monsterId: 'izual',
landmark: 'Act 4 - Plains of Despair',
modifiers: ['coldenchant'],
minMinions: 0,
maxMinions: 0,
minionMonsterId: 'izual',
},
106: {
id: 'Hephasto the Armorer',
name: 'Hephasto the Armorer',
monsterId: 'hephasto',
landmark: 'Act 4 - Hellforge',
modifiers: ['spectralhit', 'aura'],
minMinions: 0,
maxMinions: 0,
minionMonsterId: 'hephasto',
},
107: {
id: 'Hephasto the Armorer',
name: 'Hephasto the Armorer',
monsterId: 'hephasto',
landmark: 'Act 4 - Hellforge',
modifiers: ['spectralhit', 'aura'],
minMinions: 0,
maxMinions: 0,
minionMonsterId: 'hephasto',
},
// Act 5
110: [
{
id: 'Dac Farren',
name: 'Dac Farren',
monsterId: 'demonimp1',
landmark: 'Act 5 - Barricade Prison 1',
modifiers: ['coldenchant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'demonimp1',
},
{
id: 'Shenk the Overseer',
name: 'Shenk the Overseer',
monsterId: 'overseer2',
landmark: 'Act 5 - Barricade Building',
modifiers: ['strong'],
minMinions: 15,
maxMinions: 20,
minionMonsterId: 'enslaved1',
},
],
111: {
id: 'Eyeback the Unleashed',
name: 'Eyeback the Unleashed',
monsterId: 'bloodlord2',
landmark: 'Act 5 - Barricade Hell Portal N',
modifiers: ['fast', 'strong'],
minMinions: 3,
maxMinions: 5,
minionMonsterId: 'bloodlord2',
},
112: {
id: 'Thresh Socket',
name: 'Thresh Socket',
monsterId: 'siegebeast1',
landmark: 'Act 5 - Barricade Snow Unique',
modifiers: ['cursed'],
minMinions: 2,
maxMinions: 4,
minionMonsterId: 'siegebeast1',
},
114: {
id: 'Frozenstein',
name: 'Frozenstein',
monsterId: 'yeti2',
landmark: 'Act 5 - Frozen River',
modifiers: ['coldenchant', 'manahit'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'yeti2',
},
115: {
id: 'Bonesaw Breaker',
name: 'Bonesaw Breaker',
monsterId: 'reanimatedhorde2',
landmark: 'Act 5 - Glacial Trail',
modifiers: ['strong', 'magicresistant'],
minMinions: 4,
maxMinions: 6,
minionMonsterId: 'reanimatedhorde2',
},
124: {
id: 'Nihlathak',
name: 'Nihlathak',
monsterId: 'nihlathakboss',
landmark: 'Act 5 - Halls of Vaught',
modifiers: ['coldenchant'],
minMinions: 6,
maxMinions: 8,
minionMonsterId: 'iceprowler2',
},
}
/** What one level's population came out as. */
@ -1129,44 +1431,52 @@ export function planLevelMonsters(
return { members }
})
// Check if this level has a fixed SuperUnique boss (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
const suSpec = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
// Check if this level has fixed SuperUnique boss(es)
const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
? suRaw
: (suRaw !== undefined ? [suRaw] : [])
const superUniques: string[] = []
if (suSpec !== undefined && kinds.size > 0) {
const suRng = new Rng(seed ^ 0x55aa)
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
const bossScaled: MonsterStats = {
...bossBase,
id: suSpec.monsterId,
name: suSpec.name,
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
}
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
const suMembers: MonsterStats[] = [bossStats]
for (let m = 0; m < minionCount; m += 1) {
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
const mScaled: MonsterStats = {
...mBase,
id: suSpec.minionMonsterId,
hp: Math.max(1, Math.round(mBase.hp * 2)),
if (suSpecs.length > 0 && kinds.size > 0) {
const suPacks: MonsterPack[] = []
for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) {
const suSpec = suSpecs[suIdx]!
const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f))
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
const bossScaled: MonsterStats = {
...bossBase,
id: suSpec.monsterId,
name: suSpec.name,
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
}
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
const suMembers: MonsterStats[] = [bossStats]
for (let m = 0; m < minionCount; m += 1) {
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
const mScaled: MonsterStats = {
...mBase,
id: suSpec.minionMonsterId,
hp: Math.max(1, Math.round(mBase.hp * 2)),
}
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
}
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
const suPack: MonsterPack = {
members: suMembers,
superUniqueId: suSpec.id,
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
}
suPacks.push(suPack)
superUniques.push(suSpec.id)
}
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
const suPack: MonsterPack = {
members: suMembers,
superUniqueId: suSpec.id,
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
}
packs.unshift(suPack)
superUniques.push(suSpec.id)
packs.unshift(...suPacks)
}
return {

437
src/game/preset.ts Normal file
View File

@ -0,0 +1,437 @@
/**
* Diablo II's preset level generator (`Levels.txt.DrlgType == 2`).
*
* In Diablo II, preset levels are defined in `LvlPrest.txt` and represent maps
* with fixed geometry rather than procedural maze or wilderness generation.
* Most preset levels consist of a single standalone DS1 file or quadrant rooms,
* but specific levels — most notably Act 4 Chaos Sanctuary (Level 108) — are
* assembled by stitching multiple cross-wing preset pieces at deterministic
* coordinates onto a fixed grid.
*
* This module handles preset level generation and composition, cleanly
* separating preset assembling logic from wilderness and maze generators.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Substitution, Ds1Wall } from '../formats/ds1.ts'
import { SUB_TILES_PER_TILE } from './map.ts'
/** Level ID for Act 4 Chaos Sanctuary in Levels.txt. */
export const CHAOS_SANCTUARY_LEVEL_ID = 108
/** Standard canvas width for Act 4 Chaos Sanctuary in cells. */
export const CHAOS_SANCTUARY_SIZE_X = 120
/** Standard canvas height for Act 4 Chaos Sanctuary in cells. */
export const CHAOS_SANCTUARY_SIZE_Y = 120
/** One `LvlPrest.txt` piece or DS1 preset available for stamping. */
export interface PresetPiece {
/** Name of the preset piece (e.g. from LvlPrest.txt Name or DS1 file name). */
readonly name: string
/** Decoded DS1 level variants. */
readonly levels: readonly Ds1[]
/** Optional border flag for compatibility with wilderness piece records. */
readonly border?: boolean | undefined
}
/** Target placement coordinate for a stamped preset piece. */
export interface PresetPlacement {
/** Name or name prefix of the preset piece to match. */
readonly name: string
/** Top-left X in cells on the canvas. */
readonly x: number
/** Top-left Y in cells on the canvas. */
readonly y: number
}
/** A recorded preset placement substitution in generator stats. */
export interface PresetSubstitutionRecord {
readonly name: string
readonly role: string
readonly enabled: boolean
readonly clusters: number
}
/** Request payload for {@link generatePreset}. */
export interface PresetRequest {
/** `Levels.txt` `Id`. */
readonly levelId: number
/** `Levels.txt` `Name`. */
readonly levelName?: string | undefined
/** `LvlTypes.txt` name, e.g. `Act 4 - Lava`. */
readonly levelTypeName?: string | undefined
/** Act number, 1..5. Defaults to 4 for Level 108. */
readonly act?: number | undefined
/** Width in cells. Defaults to 120 for Level 108. */
readonly sizeX?: number | undefined
/** Height in cells. Defaults to 120 for Level 108. */
readonly sizeY?: number | undefined
/** Deterministic generator seed. */
readonly seed?: number | undefined
/** Preset pieces available to assemble this level. */
readonly pieces: readonly PresetPiece[]
/** Custom piece placements if overriding standard layout. */
readonly placements?: readonly PresetPlacement[] | undefined
/** Optional substitution list for interface compatibility. */
readonly substitutions?: readonly unknown[] | undefined
}
/** Statistics and metadata returned from preset map generation. */
export interface PresetStats {
readonly levelId: number
readonly levelName: string
readonly sizeX: number
readonly sizeY: number
readonly seed: number
readonly substitutedClusters: number
readonly objects: number
readonly cells: number
readonly substitutions: readonly PresetSubstitutionRecord[]
readonly groundCells: number
readonly borderBlocks?: number | undefined
readonly borderUsage?: Readonly<Record<string, number>> | undefined
readonly unresolved?: readonly unknown[] | undefined
readonly notes?: readonly string[] | undefined
readonly [key: string]: unknown
}
/** Result of generating a preset level. */
export interface PresetResult {
/** The assembled DS1 level. */
readonly level: Ds1
/** Execution statistics and placement records. */
readonly stats: PresetStats
}
/**
* Placements for the 7 cross-wing presets of Act 4 Chaos Sanctuary (Level 108).
* Stitched on a 120x120 canvas.
*/
export const CHAOS_SANCTUARY_PLACEMENTS: readonly PresetPlacement[] = [
{ name: 'Act 4 - Diablo Arm N', x: 48, y: 0 },
{ name: 'Act 4 - Diablo Arm W', x: 24, y: 24 },
{ name: 'Act 4 - Diablo Heart', x: 48, y: 24 },
{ name: 'Act 4 - Diablo Arm E', x: 72, y: 24 },
{ name: 'Act 4 - Diablo Arm S', x: 48, y: 48 },
{ name: 'Act 4 - Diablo Entry', x: 48, y: 72 },
{ name: 'Act 4 - Bridge 2', x: 48, y: 96 },
]
/** Internal mutable canvas during preset assembly. */
interface PresetCanvas {
readonly width: number
readonly height: number
readonly cells: Ds1Cell[][]
readonly wallLayers: number
readonly floorLayers: number
readonly substitutionLayers: number
readonly objects: Ds1Object[]
}
function emptyWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
function makeCanvas(
width: number,
height: number,
wallLayers = 1,
floorLayers = 1,
substitutionLayers = 1,
): PresetCanvas {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: Array.from({ length: wallLayers }, emptyWall),
floors: Array.from({ length: floorLayers }, emptyFloor),
shadows: [emptyFloor()],
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
})
}
cells.push(row)
}
return { width, height, cells, wallLayers, floorLayers, substitutionLayers, objects: [] }
}
/**
* Stamp a DS1 piece into the canvas at a given cell origin, offsetting objects by sub-tiles.
*/
function stampDs1(canvas: PresetCanvas, source: Ds1, originX: number, originY: number): number {
let written = 0
for (let y = 0; y < source.height; y += 1) {
const targetY = originY + y
if (targetY < 0 || targetY >= canvas.height) continue
const sourceRow = source.cells[y]
if (sourceRow === undefined) continue
const targetRow = canvas.cells[targetY]!
for (let x = 0; x < source.width; x += 1) {
const targetX = originX + x
if (targetX < 0 || targetX >= canvas.width) continue
const cell = sourceRow[x]
if (cell === undefined) continue
const targetCell = targetRow[targetX]!
// Floors: copy any non-empty floor layer from source
for (let i = 0; i < cell.floors.length; i += 1) {
const floor = cell.floors[i]
if (floor !== undefined && !floor.hidden && floor.prop1 !== 0) {
const targetFloors = targetCell.floors as Ds1Floor[]
if (i < targetFloors.length) {
targetFloors[i] = floor
} else {
targetFloors.push(floor)
}
}
}
// Walls: copy any non-empty wall layer from source
for (let i = 0; i < cell.walls.length; i += 1) {
const wall = cell.walls[i]
if (wall !== undefined && !wall.hidden && wall.prop1 !== 0) {
const targetWalls = targetCell.walls as Ds1Wall[]
if (i < targetWalls.length) {
targetWalls[i] = wall
} else {
targetWalls.push(wall)
}
}
}
// Shadows: copy any non-empty shadow layer from source
for (let i = 0; i < cell.shadows.length; i += 1) {
const shadow = cell.shadows[i]
if (shadow !== undefined && !shadow.hidden && shadow.prop1 !== 0) {
const targetShadows = targetCell.shadows as Ds1Floor[]
if (i < targetShadows.length) {
targetShadows[i] = shadow
} else {
targetShadows.push(shadow)
}
}
}
// Substitutions: copy non-zero substitution layer values
for (let i = 0; i < cell.substitutions.length; i += 1) {
const sub = cell.substitutions[i]
if (sub !== undefined && sub.value !== 0) {
const targetSubs = targetCell.substitutions as Ds1Substitution[]
if (i < targetSubs.length) {
targetSubs[i] = sub
} else {
targetSubs.push(sub)
}
}
}
written += 1
}
}
// Stamp objects, converting cell offset to 5 sub-tiles per cell
for (const object of source.objects) {
canvas.objects.push({
type: object.type,
id: object.id,
x: originX * SUB_TILES_PER_TILE + object.x,
y: originY * SUB_TILES_PER_TILE + object.y,
flags: object.flags,
})
}
return written
}
/**
* Match a target preset piece name flexibly against available pieces.
* Handles full names, prefixes, without-Act prefixes, and stripped filenames.
*/
export function findMatchingPresetPiece(
pieces: readonly PresetPiece[],
targetName: string,
): PresetPiece | undefined {
const norm = (s: string) => s.trim().toLowerCase()
const target = norm(targetName)
// 1. Direct equality or startsWith
const direct = pieces.find(p => {
const pn = norm(p.name)
return pn === target || pn.startsWith(target)
})
if (direct) return direct
// 2. Strip path and extension, and extract canonical token
const clean = (s: string) => {
const base = s.split(/[\\/]/).pop() ?? s
return base.replace(/\.ds1$/i, '').trim().toLowerCase()
}
const cleanTarget = clean(targetName)
const stripPrefix = (s: string) =>
s.replace(/^act\s*\d+\s*[-_ ]*/i, '').replace(/^diablo\s*[-_ ]*/i, '').replace(/[^a-z0-9]/g, '')
const shortTarget = stripPrefix(cleanTarget)
const normTarget = cleanTarget.replace(/[^a-z0-9]/g, '')
const tokenMatch = pieces.find(p => {
const cp = clean(p.name)
const shortP = stripPrefix(cp)
const normP = cp.replace(/[^a-z0-9]/g, '')
return (
normP === normTarget ||
shortP === shortTarget ||
normP.endsWith(shortTarget) ||
shortTarget.endsWith(normP) ||
shortTarget === normP ||
normP.includes(shortTarget) ||
shortTarget.includes(shortP)
)
})
if (tokenMatch) return tokenMatch
return undefined
}
/**
* Assemble the 7 cross-wing presets for Act 4 Chaos Sanctuary (Level 108, 120x120).
*/
export function generateChaosSanctuary(request: PresetRequest): PresetResult {
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : CHAOS_SANCTUARY_SIZE_X
const height = request.sizeY && request.sizeY > 0 ? request.sizeY : CHAOS_SANCTUARY_SIZE_Y
let wallLayers = 1
let floorLayers = 1
let substitutionLayers = 1
let version = 18
for (const piece of request.pieces) {
for (const lvl of piece.levels) {
if (lvl.version && lvl.version > 0) version = lvl.version
if (lvl.wallLayers > wallLayers) wallLayers = lvl.wallLayers
if (lvl.floorLayers > floorLayers) floorLayers = lvl.floorLayers
const subLen = lvl.cells[0]?.[0]?.substitutions?.length ?? 0
if (subLen > substitutionLayers) substitutionLayers = subLen
}
}
const canvas = makeCanvas(width, height, wallLayers, floorLayers, substitutionLayers)
const placements = request.placements ?? CHAOS_SANCTUARY_PLACEMENTS
const placed = new Set<string>()
const substitutions: PresetSubstitutionRecord[] = []
for (const item of placements) {
const piece = findMatchingPresetPiece(request.pieces, item.name)
const ds1 = piece?.levels[0]
if (!ds1) continue
stampDs1(canvas, ds1, item.x, item.y)
placed.add(item.name)
substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
}
let groundCells = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (row === undefined) continue
for (let x = 0; x < canvas.width; x += 1) {
const c = row[x]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
groundCells += 1
}
}
}
return {
level: {
version: version === 0 ? 18 : version,
width: canvas.width,
height: canvas.height,
act: request.act ?? 4,
substitutionType: 0,
wallLayers: canvas.wallLayers,
floorLayers: canvas.floorLayers,
cells: canvas.cells,
objects: canvas.objects,
npcPathOffset: null,
},
stats: {
levelId: request.levelId,
levelName: request.levelName ?? 'Chaos Sanctuary',
sizeX: canvas.width,
sizeY: canvas.height,
seed: request.seed ?? 0,
borderBlocks: 0,
borderUsage: {},
substitutions,
substitutedClusters: placed.size,
objects: canvas.objects.length,
cells: canvas.width * canvas.height,
groundCells,
},
}
}
/**
* Dedicated preset level generator pipeline (`Levels.txt.DrlgType == 2`).
*
* Dispatches Level 108 (Act 4 Chaos Sanctuary) to its 7-cross assembled layout,
* and handles standalone preset maps and custom multi-piece placements.
*/
export function generatePreset(request: PresetRequest): PresetResult {
// Dispatch Level 108 (Chaos Sanctuary)
if (
request.levelId === CHAOS_SANCTUARY_LEVEL_ID ||
request.levelName === 'Chaos Sanctuary' ||
request.levelName === 'Act 4 - Chaos Sanctuary'
) {
return generateChaosSanctuary(request)
}
// If explicit multi-piece placements are provided
if (request.placements && request.placements.length > 0) {
return generateChaosSanctuary(request)
}
// Handle single preset map
if (request.pieces.length > 0 && request.pieces[0]?.levels[0]) {
const ds1 = request.pieces[0].levels[0]
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : ds1.width
const height = request.sizeY && request.sizeY > 0 ? request.sizeY : ds1.height
if (width === ds1.width && height === ds1.height) {
let groundCells = 0
for (let y = 0; y < ds1.height; y += 1) {
const row = ds1.cells[y]
if (row === undefined) continue
for (let x = 0; x < ds1.width; x += 1) {
const c = row[x]
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
groundCells += 1
}
}
}
return {
level: ds1,
stats: {
levelId: request.levelId,
levelName: request.levelName ?? request.pieces[0].name,
sizeX: width,
sizeY: height,
seed: request.seed ?? 0,
borderBlocks: 0,
borderUsage: {},
substitutions: [
{ name: request.pieces[0].name, role: 'preset', enabled: true, clusters: 1 },
],
substitutedClusters: 1,
objects: ds1.objects.length,
cells: width * height,
groundCells,
},
}
}
}
// Fallback to general assembly
return generateChaosSanctuary(request)
}

85
src/game/variants.ts Normal file
View File

@ -0,0 +1,85 @@
import type { Side } from './world-graph.ts'
import { oppositeSide } from './world-graph.ts'
export { oppositeSide }
export type { Side }
export interface VariantCandidate {
readonly label?: string
readonly ds1?: string
readonly slug?: string
readonly act?: number
readonly levelId?: number
}
/**
* Extract the compass direction / orientation from a variant's label or DS1 filename.
*
* Each variant (e.g. TownN1, TownE1, TownS1, TownW1) represents an independent town
* structure with an exit gate in that specific compass direction, not a quadrant.
*
* - `TownN1` / `townn1` / `LutN` -> `north`
* - `TownS1` / `towns1` -> `south`
* - `TownE1` / `towne1` / `CourtE` -> `east`
* - `TownW1` / `townw1` / `LutW` / `CourtW` -> `west`
*/
export function variantOrientation(candidate: VariantCandidate): Side | undefined {
const key = `${candidate.label ?? ''} ${candidate.ds1 ?? ''}`.toLowerCase()
if (key.includes('townn') || key.includes('lutn') || key.includes('courtn') || key.includes('-north')) {
return 'north'
}
if (key.includes('towns') || key.includes('luts') || key.includes('courts') || key.includes('-south')) {
return 'south'
}
if (key.includes('towne') || key.includes('lute') || key.includes('courte') || key.includes('-east')) {
return 'east'
}
if (key.includes('townw') || key.includes('lutw') || key.includes('courtw') || key.includes('-west')) {
return 'west'
}
return undefined
}
/**
* Find the candidate variant whose gate / exit orientation matches the required compass side.
*
* When travelling between abutting levels across a seam, if the player exits level A heading
* `fromSide`, the arrival level B must have its corresponding entrance on `oppositeSide(fromSide)`.
*/
export function findMatchingVariant<T extends VariantCandidate>(
candidates: readonly T[],
requiredSide?: Side | string,
variantSeed = 0,
): T | undefined {
if (candidates.length === 0) return undefined
if (candidates.length === 1 || requiredSide === undefined || requiredSide === '') {
return candidates[Math.abs(variantSeed) % candidates.length]
}
const side = requiredSide.toLowerCase() as Side
const matched = candidates.find(c => variantOrientation(c) === side)
return matched ?? candidates[Math.abs(variantSeed) % candidates.length]
}
/**
* Get the default town variant for a given act when starting fresh in town.
*
* - In Act 1: The Blood Moor (Level 2) has its palisade gate on the North (`Town S Transition`),
* so Rogue Encampment (Level 1) must exit to the South (`TownS1.ds1` / `towns1`).
* - In Act 2: Rocky Waste (Level 41) has its town seam on the South (`Act 2 - Town Seam`),
* so Lut Gholein (Level 40) must exit to the North (`LutN.ds1` / `lutn`).
* - Other acts default to their single town entry.
*/
export function getDefaultTownVariant<T extends VariantCandidate>(
forAct: readonly T[],
act: number,
): T | undefined {
if (act === 1) {
const towns = forAct.find(c => variantOrientation(c) === 'south')
if (towns !== undefined) return towns
} else if (act === 2) {
const lutn = forAct.find(c => variantOrientation(c) === 'north')
if (lutn !== undefined) return lutn
}
return forAct.find(c => (c.slug ?? '').includes('town')) ?? forAct[0]
}

906
src/game/wilderness-acts.ts Normal file
View File

@ -0,0 +1,906 @@
/**
* Wilderness outdoor topography initializers for Acts 2, 3, 4, and 5.
*
* blizzard Drlg outdoor passes:
* - `DRLGOUTDESR_InitAct2OutdoorLevel`: Desert canyons, sand trails, Far Oasis pools,
* Valley of Snakes canyon gorges, Canyon of the Magi 7 Tal Rasha tomb entrances.
* - `DRLGOUTPLACE_InitAct3OutdoorLevel` & `DRLG_GenerateJungles`: Jungle waterways,
* wooden river bridges spanning channels, causeways, Kurast city avenues, canals & bridges.
* - `DRLGOUTDOORS_InitAct4OutdoorLevel`: Molten lava chasms and fissures, natural rock
* bridges spanning chasms, volcanic mesa plateaus.
* - `DRLGOUTSIEGE_InitAct5OutdoorLevel`: Transverse siege barricade palisades, choke-point
* gates, military dugout trenches, prisoner cages, and snow crater frozen lakes.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type {
Canvas,
WildernessRequest,
WildernessStats,
WildernessPiece,
PlacedPresetRect,
} from './wilderness.ts'
import { stampDs1, TILES_PER_BLOCK, emptyFloor, emptyWall } from './wilderness.ts'
/* ------------------------------------------------------------------------- *
* Statistics interfaces
* ------------------------------------------------------------------------- */
export interface Act2TopographyStats {
readonly canyonWalls: number
readonly sandTrailCells: number
readonly oasisPoolCells: number
readonly tombEntrances: number
}
export interface Act3TopographyStats {
readonly causewayCells: number
readonly riverBridges: number
readonly canalCells: number
readonly junglePathCells: number
}
export interface Act4TopographyStats {
readonly lavaChasmCells: number
readonly rockyBridges: number
readonly plateauCells: number
}
export interface Act5TopographyStats {
readonly barricadeWalls: number
readonly trenchCells: number
readonly frozenLakeCells: number
}
/* ------------------------------------------------------------------------- *
* Geometry & Canvas Helpers
* ------------------------------------------------------------------------- */
function isInsidePreset(
x: number,
y: number,
placedRects: readonly PlacedPresetRect[],
margin = 1,
): boolean {
for (const r of placedRects) {
if (
x >= r.x - margin &&
x < r.x + r.w + margin &&
y >= r.y - margin &&
y < r.y + r.h + margin
) {
return true
}
}
return false
}
function makeFloor(style: number, sequence: number, prop1 = 2): Ds1Floor {
return {
prop1,
style,
sequence,
unknown1: 0,
unknown2: 0,
hidden: false,
}
}
function makeWall(style: number, sequence: number, type: number, prop1 = 129): Ds1Wall {
return {
prop1,
sequence,
style,
type,
unknown1: 0,
unknown2: 0,
hidden: false,
}
}
function paintFloor(
canvas: Canvas,
x: number,
y: number,
floor: Ds1Floor,
overwriteProtected = false,
): boolean {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false
const cell = canvas.cells[y]?.[x]
if (!cell) return false
const existing = cell.floors[0]
if (!overwriteProtected && existing) {
// Protected styles: water (style 2/3), wooden bridge (style 4)
if (existing.style === 2 || existing.style === 3 || existing.style === 4) {
return false
}
}
;(cell.floors as Ds1Floor[])[0] = floor
return true
}
function paintWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): boolean {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return false
const cell = canvas.cells[y]?.[x]
if (!cell) return false
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
return true
}
function clearWalls(canvas: Canvas, x: number, y: number): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const cell = canvas.cells[y]?.[x]
if (cell) {
;(cell.walls as Ds1Wall[]).length = 0
}
}
/* ------------------------------------------------------------------------- *
* Act 2: DRLGOUTDESR_InitAct2OutdoorLevel
* ------------------------------------------------------------------------- */
/**
* Initialize Act 2 Desert Outdoor Topography:
* - Rocky canyon ridges and cliff berms carving winding desert corridors.
* - 3-tile-wide sand caravan trails connecting gates across the dunes.
* - Far Oasis (Level 43): lush water oasis pools and Maggot Lair entrance.
* - Valley of Snakes (Level 45): narrow canyon gorge leading to Claw Viper Temple.
* - Canyon of the Magi (Level 46): 7 Tal Rasha tomb entrances along perimeter cliffs
* and central Arcane Sanctuary warp platform.
* - Lost City (Level 44): ancient buried city ruins and street corridors.
*/
export function initAct2OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act2TopographyStats {
let canyonWalls = 0
let sandTrailCells = 0
let oasisPoolCells = 0
let tombEntrances = 0
const levelId = request.levelId
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// Helper to paint a 3-tile-wide sand caravan trail
const paintSandTrailTile = (x: number, y: number): void => {
if (x < 3 || x >= canvas.width - 3 || y < 3 || y >= canvas.height - 3) return
if (isInsidePreset(x, y, placedRects, 0)) return
const cell = canvas.cells[y]?.[x]
if (!cell) return
// Style 5 (desert road), prop1 194
const floor = makeFloor(5, rng.int(0, 4) === 0 ? 25 : 0, 194)
if (paintFloor(canvas, x, y, floor, false)) {
clearWalls(canvas, x, y)
sandTrailCells += 1
}
}
// 1. Sand Caravan Trail Network: North-South and West-East axes
if (levelId !== 46) {
const startY = Math.max(4, Math.floor(canvas.height * 0.1))
const endY = Math.min(canvas.height - 5, Math.floor(canvas.height * 0.9))
for (let y = startY; y <= endY; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
paintSandTrailTile(hubX + dx, y)
}
}
const startX = Math.max(4, Math.floor(canvas.width * 0.1))
const endX = Math.min(canvas.width - 5, Math.floor(canvas.width * 0.9))
for (let x = startX; x <= endX; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
paintSandTrailTile(x, hubY + dy)
}
}
}
// 2. Canyon Walls & Rocky Berms (Levels 41, 42, 44, 45)
if (levelId === 41 || levelId === 42 || levelId === 44 || levelId === 45) {
const isValleyOfSnakes = levelId === 45
// In Valley of Snakes, place 2 tight parallel canyon ridge walls (corridor of 10-12 tiles)
// In other deserts, place transverse & longitudinal canyon ridges
const ridgeY1 = Math.max(12, Math.min(canvas.height - 14, hubY - (isValleyOfSnakes ? 5 : 16)))
const ridgeY2 = Math.max(14, Math.min(canvas.height - 12, hubY + (isValleyOfSnakes ? 6 : 16)))
for (let x = 10; x < canvas.width - 10; x += 1) {
// Leave 4-tile wide canyon pass gaps where the main highway or crossroads pass
if (Math.abs(x - hubX) <= 3) continue
if (isInsidePreset(x, ridgeY1, placedRects, 2)) continue
if (paintWall(canvas, x, ridgeY1, makeWall(1, 0, 2, 129))) {
canyonWalls += 1
}
}
for (let x = 10; x < canvas.width - 10; x += 1) {
if (Math.abs(x - hubX) <= 3) continue
if (isInsidePreset(x, ridgeY2, placedRects, 2)) continue
if (paintWall(canvas, x, ridgeY2, makeWall(1, 0, 2, 129))) {
canyonWalls += 1
}
}
if (!isValleyOfSnakes) {
const ridgeX = Math.max(12, Math.min(canvas.width - 12, hubX + 16))
for (let y = 10; y < canvas.height - 10; y += 1) {
if (Math.abs(y - hubY) <= 3) continue
if (isInsidePreset(ridgeX, y, placedRects, 2)) continue
if (paintWall(canvas, ridgeX, y, makeWall(1, 0, 1, 129))) {
canyonWalls += 1
}
}
}
}
// 3. Far Oasis (Level 43): Lush Water Oasis Pools & Maggot Lair
if (levelId === 43 || /Oasis/i.test(request.levelName)) {
const poolCenters = [
{
cx: Math.max(14, Math.floor(canvas.width * 0.35)),
cy: Math.max(14, Math.floor(canvas.height * 0.35)),
radius: Math.min(6, Math.floor(canvas.width / 12)),
},
{
cx: Math.min(canvas.width - 16, Math.floor(canvas.width * 0.68)),
cy: Math.min(canvas.height - 16, Math.floor(canvas.height * 0.68)),
radius: Math.min(5, Math.floor(canvas.width / 14)),
},
]
for (const pool of poolCenters) {
for (let dy = -pool.radius; dy <= pool.radius; dy += 1) {
for (let dx = -pool.radius; dx <= pool.radius; dx += 1) {
const dist = Math.hypot(dx, dy)
if (dist <= pool.radius) {
const px = pool.cx + dx
const py = pool.cy + dy
if (px >= 4 && px < canvas.width - 4 && py >= 4 && py < canvas.height - 4) {
if (!isInsidePreset(px, py, placedRects, 1)) {
// Water floor: style 2 (water), prop1 2
paintFloor(canvas, px, py, makeFloor(2, 0, 2), true)
clearWalls(canvas, px, py)
oasisPoolCells += 1
}
}
}
}
}
}
// Try to stamp Desert Oasis preset if available in pieces
const oasisPiece = request.pieces.find(p => /Desert Oasis/i.test(p.name))
if (oasisPiece?.levels[0] && !stats.specialPresets.some(n => /Desert Oasis/i.test(n))) {
const ds1 = oasisPiece.levels[0]
const originX = Math.max(8, Math.floor(canvas.width * 0.4) - Math.floor(ds1.width / 2))
const originY = Math.max(8, Math.floor(canvas.height * 0.4) - Math.floor(ds1.height / 2))
stampDs1(canvas, ds1, originX, originY)
stats.specialPresets.push(oasisPiece.name)
stats.substitutions.push({ name: oasisPiece.name, role: 'object', enabled: true, clusters: 1 })
placedRects.push({ name: oasisPiece.name, x: originX, y: originY, w: ds1.width, h: ds1.height })
}
}
// 4. Canyon of the Magi / Valley of the Kings (Level 46): 7 Tal Rasha Tomb Entrances
if (levelId === 46 || /Canyon of the Magi|Valley of the Kings/i.test(request.levelName)) {
const tombCoords: Array<{ edge: 'top' | 'left' | 'right'; x: number; y: number }> = [
// Top / North Cliff: 3 tombs
{ edge: 'top', x: Math.floor(canvas.width * 0.22), y: 1 },
{ edge: 'top', x: Math.floor(canvas.width * 0.5), y: 1 },
{ edge: 'top', x: Math.floor(canvas.width * 0.78), y: 1 },
// Left / West Cliff: 2 tombs
{ edge: 'left', x: 1, y: Math.floor(canvas.height * 0.35) },
{ edge: 'left', x: 1, y: Math.floor(canvas.height * 0.68) },
// Right / East Cliff: 2 tombs
{ edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.35) },
{ edge: 'right', x: Math.max(2, canvas.width - 12), y: Math.floor(canvas.height * 0.68) },
]
for (let i = 0; i < tombCoords.length; i += 1) {
const tc = tombCoords[i]!
// Try to find matching cliff king tomb piece
let pieceName = 'Act 2 - Desert Cliff Top King Tomb'
if (tc.edge === 'left') pieceName = 'Act 2 - Desert Cliff Left King Tomb'
else if (tc.edge === 'right') pieceName = 'Act 2 - Desert Cliff Right King Tomb'
const piece = request.pieces.find(p => p.name.trim() === pieceName.trim()) ??
request.pieces.find(p => /King Tomb|Desert Tomb/i.test(p.name))
if (piece?.levels[0]) {
stampDs1(canvas, piece.levels[0], tc.x, tc.y)
stats.specialPresets.push(piece.name)
placedRects.push({ name: piece.name, x: tc.x, y: tc.y, w: piece.levels[0].width, h: piece.levels[0].height })
} else {
// Construct canonical tomb facade archway (style 2 wall, width 6, height 4)
for (let dy = 0; dy < 4; dy += 1) {
for (let dx = 0; dx < 6; dx += 1) {
const tx = tc.x + dx
const ty = tc.y + dy
if (tx >= 0 && tx < canvas.width && ty >= 0 && ty < canvas.height) {
if (dy === 3 && (dx === 2 || dx === 3)) {
// Open portal threshold
paintFloor(canvas, tx, ty, makeFloor(0, 0, 2))
clearWalls(canvas, tx, ty)
} else {
paintWall(canvas, tx, ty, makeWall(2, dy === 0 ? 0 : 1, 2, 129))
}
}
}
}
}
// Add Tal Rasha Tomb entrance portal object (LevelId 66..72)
canvas.objects.push({
type: 2,
id: 66 + i,
x: (tc.x + 3) * 5,
y: (tc.y + 3) * 5,
flags: 0,
})
tombEntrances += 1
}
// Central Arcane Sanctuary warp platform (Desert Valley Warp)
const warpPiece = request.pieces.find(p => /Desert Valley Warp/i.test(p.name))
const warpX = Math.floor(canvas.width / 2) - 4
const warpY = Math.floor(canvas.height / 2) - 4
if (warpPiece?.levels[0]) {
stampDs1(canvas, warpPiece.levels[0], warpX, warpY)
stats.specialPresets.push(warpPiece.name)
placedRects.push({ name: warpPiece.name, x: warpX, y: warpY, w: warpPiece.levels[0].width, h: warpPiece.levels[0].height })
} else {
for (let dy = 0; dy < 8; dy += 1) {
for (let dx = 0; dx < 8; dx += 1) {
paintFloor(canvas, warpX + dx, warpY + dy, makeFloor(3, 0, 194))
clearWalls(canvas, warpX + dx, warpY + dy)
}
}
canvas.objects.push({
type: 2,
id: 74,
x: (warpX + 4) * 5,
y: (warpY + 4) * 5,
flags: 0,
})
}
}
// 5. Lost City (Level 44): Ancient Buried City Ruins
if (levelId === 44 || /Lost City/i.test(request.levelName)) {
const ruinsPieces = request.pieces.filter(p => /Desert Ruins/i.test(p.name) && p.levels.length > 0)
if (ruinsPieces.length > 0) {
let placedCount = 0
for (let by = 2; by < gridHeight - 3 && placedCount < 4; by += 3) {
for (let bx = 2; bx < gridWidth - 3 && placedCount < 4; bx += 3) {
const rx = bx * TILES_PER_BLOCK
const ry = by * TILES_PER_BLOCK
if (isInsidePreset(rx, ry, placedRects, 2)) continue
const piece = ruinsPieces[placedCount % ruinsPieces.length]!
const ds1 = piece.levels[0]!
stampDs1(canvas, ds1, rx, ry)
placedRects.push({ name: piece.name, x: rx, y: ry, w: ds1.width, h: ds1.height })
stats.specialPresets.push(piece.name)
placedCount += 1
}
}
}
}
stats.act2Topography = { canyonWalls, sandTrailCells, oasisPoolCells, tombEntrances }
stats.notes.push(
`Act 2 Desert Topography: ${String(canyonWalls)} canyon walls, ${String(sandTrailCells)} sand trail cells, ${String(oasisPoolCells)} oasis cells, ${String(tombEntrances)} tomb entrances`,
)
return stats.act2Topography
}
/* ------------------------------------------------------------------------- *
* Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles
* ------------------------------------------------------------------------- */
/**
* Generate Act 3 Jungle River & Clearings layout (`DRLG_GenerateJungles`):
* - Spans wooden river bridges across the East river channel at row crossings.
* - Connects West clearings to wooden river bridges via winding jungle causeways/paths.
*/
export function generateAct3Jungle(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1(canvas, headPiece.levels[0], 0, 160)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
const clearingPrefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
const allClearingPieces = pieces.filter(p => p.name.startsWith(clearingPrefix) && p.levels.length > 0)
const eastOpeningClearings = allClearingPieces.filter(p => /\b(E|EW|SE|NE)\b/i.test(p.name))
const clearingPool = eastOpeningClearings.length > 0 ? eastOpeningClearings : allClearingPieces
const allRiverPieces = pieces.filter(
p =>
p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0,
)
const nsWestRivers = allRiverPieces.filter(p => /NS W|NS$/i.test(p.name))
const riverPool = nsWestRivers.length > 0 ? nsWestRivers : allRiverPieces
for (let row = 0; row < 4; row += 1) {
const y = 32 + row * 32
// West column (x = 0): Jungle Clearing opening East
if (clearingPool.length > 0) {
const chosenClearing = clearingPool[rng.int(0, clearingPool.length - 1)]!
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
stampDs1(canvas, ds1, 0, y)
placed.add(chosenClearing.name)
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
}
// East column (x = 32): Continuous North-South River channel
if (riverPool.length > 0) {
const chosenRiver = riverPool[rng.int(0, riverPool.length - 1)]!
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
stampDs1(canvas, ds1, 32, y)
placed.add(chosenRiver.name)
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
}
}
// Stamp Wooden River Bridges & Jungle Paths spanning the river channel
let riverBridges = 0
let junglePathCells = 0
// Two canonical wooden bridge crossings at row 1 (y = 78..81) and row 3 (y = 142..145)
const bridgeYPositions = [78, 142]
for (const by of bridgeYPositions) {
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
// Style 4: wooden bridge planks
paintFloor(canvas, x, y, makeFloor(4, 0, 2), true)
clearWalls(canvas, x, y)
}
}
riverBridges += 1
}
// Jungle Trails connecting clearings across to the river bridges
for (const by of bridgeYPositions) {
// West clearing connection (x = 12..32)
for (let x = 12; x < 32; x += 1) {
for (let dy = 0; dy < 3; dy += 1) {
const y = by + dy
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
junglePathCells += 1
}
}
// East trail along riverbank (x = 58..61, North-South trail)
for (let y = by - 14; y <= by + 14; y += 1) {
if (y >= 4 && y < canvas.height - 4) {
paintFloor(canvas, 59, y, makeFloor(5, 0, 194))
clearWalls(canvas, 59, y)
junglePathCells += 1
}
}
}
stats.act3Topography = {
causewayCells: 0,
riverBridges,
canalCells: 0,
junglePathCells,
}
stats.notes.push(
`Act 3 Jungle Topography: ${String(riverBridges)} river bridges, ${String(junglePathCells)} jungle path cells`,
)
return placed
}
/**
* Stamp Kurast Causeway (LevelId 82):
* A fortified stone causeway bridge spanning the canal between Upper Kurast and Travincal.
*/
export function stampAct3KurastCauseway(
canvas: Canvas,
pieces: readonly WildernessPiece[],
stats: WildernessStats,
): Set<string> {
const placed = new Set<string>()
const piece = pieces.find(p => p.name === 'Act 3 - Bridge')
const ds1 = piece?.levels[0]
if (ds1) {
stampDs1(canvas, ds1, 0, 0)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// Construct / ensure canonical causeway layout:
// Central paved walkway flanked by canal water and balustrades
let causewayCells = 0
let canalCells = 0
const walkwayLeft = Math.max(2, Math.floor((canvas.width - 8) / 2))
const walkwayRight = walkwayLeft + 7
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
if (x >= walkwayLeft && x <= walkwayRight) {
// Paved stone causeway: style 5, prop1 194
paintFloor(canvas, x, y, makeFloor(5, 0, 194), true)
clearWalls(canvas, x, y)
causewayCells += 1
} else if (x === walkwayLeft - 1 || x === walkwayRight + 1) {
// Stone balustrade wall: style 1, prop1 129
paintWall(canvas, x, y, makeWall(1, 0, 1, 129))
} else {
// Deep canal water: style 2, sequence 0, prop1 2
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
clearWalls(canvas, x, y)
canalCells += 1
}
}
}
stats.act3Topography = {
causewayCells,
riverBridges: 1,
canalCells,
junglePathCells: 0,
}
stats.notes.push(
`Act 3 Causeway Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal water cells`,
)
return placed
}
/**
* Initialize Act 3 Kurast Urban Districts (Levels 79 Slums, 80 Burbs, 81 Metro):
* - Paved stone causeways and grand avenues connecting gates and district plazas.
* - Stone canals and water channels dividing the districts with stone bridges.
* - Temples, sewers, and district ruins layout.
*/
export function initAct3OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act3TopographyStats {
let causewayCells = 0
let riverBridges = 0
let canalCells = 0
const junglePathCells = 0
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// 1. Paved Stone Avenues (3-tile-wide North-South & East-West)
for (let y = 6; y < canvas.height - 6; y += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
const x = hubX + dx
if (!isInsidePreset(x, y, placedRects, 0)) {
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
causewayCells += 1
}
}
}
for (let x = 6; x < canvas.width - 6; x += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const y = hubY + dy
if (!isInsidePreset(x, y, placedRects, 0)) {
paintFloor(canvas, x, y, makeFloor(5, 0, 194))
clearWalls(canvas, x, y)
causewayCells += 1
}
}
}
// 2. Stone Canals & Drainage Channels
const canalX = Math.max(12, Math.min(canvas.width - 16, (inX + 2) * TILES_PER_BLOCK))
for (let y = 8; y < canvas.height - 8; y += 1) {
// Stone Bridge crossing at East-West avenue
if (Math.abs(y - hubY) <= 2) {
for (let dx = 0; dx < 4; dx += 1) {
paintFloor(canvas, canalX + dx, y, makeFloor(4, 0, 2), true)
clearWalls(canvas, canalX + dx, y)
}
riverBridges += 1
} else {
// Water channel: style 2, prop1 2
for (let dx = 0; dx < 4; dx += 1) {
const x = canalX + dx
if (!isInsidePreset(x, y, placedRects, 1)) {
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
clearWalls(canvas, x, y)
canalCells += 1
}
}
// Stone canal embankments
paintWall(canvas, canalX - 1, y, makeWall(1, 0, 1, 129))
paintWall(canvas, canalX + 4, y, makeWall(1, 0, 1, 129))
}
}
stats.act3Topography = { causewayCells, riverBridges, canalCells, junglePathCells }
stats.notes.push(
`Act 3 Kurast Topography: ${String(causewayCells)} causeway cells, ${String(canalCells)} canal cells, ${String(riverBridges)} bridges`,
)
return stats.act3Topography
}
/* ------------------------------------------------------------------------- *
* Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel
* ------------------------------------------------------------------------- */
/**
* Initialize Act 4 Outdoor Topography (Levels 104 Outer Steppes, 105 Plains of Despair, 106 City of the Damned):
* - Deep glowing lava chasms and fissures cutting through volcanic basalt plateaus.
* - Solid rocky bridge causeways spanning the chasms, connecting separated plateaus.
* - Volcanic mesa plateau formations and Izual prison plateau.
*/
export function initAct4OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act4TopographyStats {
let lavaChasmCells = 0
let rockyBridges = 0
let plateauCells = 0
const width = canvas.width
const height = canvas.height
const hubX = inX * TILES_PER_BLOCK + 4
const hubY = outY * TILES_PER_BLOCK + 4
// 1. Lava Fissure: A jagged diagonal chasm dividing the level into two main plateaus
const chasmYAtX = (x: number): number => {
// S-curve diagonal fissure from (10, 0.35*H) to (W - 10, 0.65*H)
const t = x / width
return Math.floor(height * (0.35 + 0.3 * t) + Math.sin(t * Math.PI * 2) * 4)
}
// Strategic rocky bridge crossing X coordinates
const bridgeXPositions = [
Math.max(12, Math.floor(width * 0.3)),
hubX,
Math.min(width - 12, Math.floor(width * 0.7)),
]
const isBridgeX = (x: number): boolean => bridgeXPositions.some(bx => Math.abs(x - bx) <= 1)
for (let x = 8; x < width - 8; x += 1) {
const centerY = chasmYAtX(x)
const onBridge = isBridgeX(x)
if (onBridge) {
// Solid rock bridge across the chasm: style 10, sequence 0, prop1 2
for (let dy = -3; dy <= 3; dy += 1) {
const y = centerY + dy
if (y >= 0 && y < height) {
paintFloor(canvas, x, y, makeFloor(10, 0, 2), true)
clearWalls(canvas, x, y)
}
}
rockyBridges += 1
} else {
// Boiling lava chasm: style 20, sequence 3, prop1 2
for (let dy = -2; dy <= 2; dy += 1) {
const y = centerY + dy
if (y >= 0 && y < height) {
if (!isInsidePreset(x, y, placedRects, 1)) {
paintFloor(canvas, x, y, makeFloor(20, 3, 2), true)
clearWalls(canvas, x, y)
lavaChasmCells += 1
}
}
}
// Basalt chasm edge lips
paintWall(canvas, x, centerY - 3, makeWall(10, 0, 2, 129))
paintWall(canvas, x, centerY + 3, makeWall(10, 0, 2, 129))
}
}
// 2. Basalt Plateau Ridges / Demonic Altar Formations
if (request.levelId === 105) {
// Plains of Despair: Izual's prison plateau in center
const px0 = hubX - 5
const py0 = hubY - 5
for (let dy = 0; dy < 10; dy += 1) {
for (let dx = 0; dx < 10; dx += 1) {
const x = px0 + dx
const y = py0 + dy
if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) {
if (dx === 0 || dx === 9 || dy === 0 || dy === 9) {
paintWall(canvas, x, y, makeWall(10, 0, 2, 129))
} else {
paintFloor(canvas, x, y, makeFloor(10, 0, 2))
clearWalls(canvas, x, y)
plateauCells += 1
}
}
}
}
} else {
// Outer Steppes (104) / City of the Damned (106): Volcanic basalt plateaus
for (let y = 12; y < height - 12; y += 4) {
for (let x = 12; x < width - 12; x += 4) {
if (!isInsidePreset(x, y, placedRects, 2) && Math.abs(y - chasmYAtX(x)) > 6) {
plateauCells += 1
}
}
}
}
stats.act4Topography = { lavaChasmCells, rockyBridges, plateauCells }
stats.notes.push(
`Act 4 Mesa/Lava Topography: ${String(lavaChasmCells)} lava cells, ${String(rockyBridges)} rocky bridge cells, ${String(plateauCells)} plateau cells`,
)
return stats.act4Topography
}
/* ------------------------------------------------------------------------- *
* Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel
* ------------------------------------------------------------------------- */
/**
* Initialize Act 5 Outdoor Topography (Levels 111 Frigid Highlands, 112 Arreat Plateau, 117 Frozen Tundra):
* - Transverse siege fortifications & spiked palisade barricade walls with choke-point gates.
* - Military dugout battlefield trenches running behind defense lines.
* - Snow crater plateaus and frozen lakes with slippery ice floors.
* - Prisoner cages and Hell Portal redoubts.
*/
export function initAct5OutdoorLevel(
canvas: Canvas,
request: WildernessRequest,
gridWidth: number,
gridHeight: number,
inX: number,
outY: number,
placedRects: PlacedPresetRect[],
rng: Rng,
stats: WildernessStats,
): Act5TopographyStats {
let barricadeWalls = 0
let trenchCells = 0
let frozenLakeCells = 0
const width = canvas.width
const height = canvas.height
const hubY = outY * TILES_PER_BLOCK + 4
// 1. Transverse Siege Barricade Wall Lines (cutting across the level)
const barricadeXPositions = [
Math.max(16, Math.floor(gridWidth * 0.35) * TILES_PER_BLOCK),
Math.min(width - 16, Math.floor(gridWidth * 0.7) * TILES_PER_BLOCK),
]
for (const bx of barricadeXPositions) {
// Barricade choke-point gate at hubY
const gateTop = hubY - 2
const gateBottom = hubY + 2
for (let y = 8; y < height - 8; y += 1) {
if (isInsidePreset(bx, y, placedRects, 1)) continue
if (y >= gateTop && y <= gateBottom) {
// Choke-point gate: clear for passage, fortified end-caps at ends
clearWalls(canvas, bx, y)
paintFloor(canvas, bx, y, makeFloor(5, 0, 194))
if (y === gateTop) paintWall(canvas, bx, y, makeWall(2, 1, 1, 129))
if (y === gateBottom) paintWall(canvas, bx, y, makeWall(2, 2, 1, 129))
} else {
// Spiked wood/iron barricade palisade wall: style 2, prop1 129
if (paintWall(canvas, bx, y, makeWall(2, 0, 1, 129))) {
barricadeWalls += 1
}
}
}
// 2. Military Dugout Trenches: 2-tile wide trench running parallel behind barricade
const trenchX = bx - 2
for (let y = 10; y < height - 10; y += 1) {
if (Math.abs(y - hubY) > 3 && !isInsidePreset(trenchX, y, placedRects, 1)) {
for (let dx = 0; dx < 2; dx += 1) {
// Dirt/mud trench floor: style 5, prop1 194
paintFloor(canvas, trenchX + dx, y, makeFloor(5, 0, 194))
clearWalls(canvas, trenchX + dx, y)
trenchCells += 1
}
}
}
}
// 3. Snow Crater Plateaus & Frozen Lakes (Levels 112 Arreat Plateau & 117 Frozen Tundra)
if (
request.levelId === 112 ||
request.levelId === 117 ||
/snow|plateau|tundra/i.test(request.levelName)
) {
const lakeCenter = {
cx: Math.max(14, Math.floor(width * 0.52)),
cy: Math.max(14, Math.floor(height * 0.48)),
radius: Math.min(8, Math.floor(width / 8)),
}
for (let dy = -lakeCenter.radius; dy <= lakeCenter.radius; dy += 1) {
for (let dx = -lakeCenter.radius; dx <= lakeCenter.radius; dx += 1) {
if (Math.hypot(dx, dy) <= lakeCenter.radius) {
const x = lakeCenter.cx + dx
const y = lakeCenter.cy + dy
if (x >= 4 && x < width - 4 && y >= 4 && y < height - 4) {
if (!isInsidePreset(x, y, placedRects, 1) && Math.abs(y - hubY) > 3) {
// Slippery frozen ice floor: style 2, sequence 0, prop1 2
paintFloor(canvas, x, y, makeFloor(2, 0, 2), true)
clearWalls(canvas, x, y)
frozenLakeCells += 1
}
}
}
}
}
// Stamp Snow Lake preset if available
const lakePiece = request.pieces.find(p => /Snow Lake/i.test(p.name))
if (lakePiece?.levels[0] && !stats.specialPresets.some(n => /Snow Lake/i.test(n))) {
const ds1 = lakePiece.levels[0]
const lx = Math.max(8, Math.floor(width * 0.5) - Math.floor(ds1.width / 2))
const ly = Math.max(8, Math.floor(height * 0.5) - Math.floor(ds1.height / 2))
stampDs1(canvas, ds1, lx, ly)
placedRects.push({ name: lakePiece.name, x: lx, y: ly, w: ds1.width, h: ds1.height })
stats.specialPresets.push(lakePiece.name)
}
}
stats.act5Topography = { barricadeWalls, trenchCells, frozenLakeCells }
stats.notes.push(
`Act 5 Siege/Barricade Topography: ${String(barricadeWalls)} barricade walls, ${String(trenchCells)} trench cells, ${String(frozenLakeCells)} frozen lake cells`,
)
return stats.act5Topography
}

View File

@ -0,0 +1,624 @@
/**
* Diablo II Act 3 Jungle River Network & Branch Topology Algorithm (`DRLG_GenerateJungles`).
*
* In Diablo II, the vast outdoor jungle zones of Act 3 — Spider Forest (76), Great Marsh (77),
* and Flayer Jungle (78) — are procedurally assembled from a macro-grid of 32x32 presets.
* Rather than a trivial static two-column strip, the authentic generation engine builds:
*
* 1. Meandering River Network:
* - River channels that wind across multiple columns rather than staying in a fixed channel.
* - Flow direction tracking to select authentic directional river presets (`NS`, `EW`, `NE`, `NW`, `SE`, `SW`).
* 2. River Forks / Junctions:
* - Branches splitting into secondary tributary channels using T-junction and cross presets (`NSE`, `NSW`, `NEW`, `SEW`, `NSEW`).
* 3. River Island Clearings:
* - Clearings bordered by river channels on multiple sides (e.g. 2 or more orthogonal sides), forming defensible enclaves.
* 4. Wooden Bridges:
* - Wooden bridge crossings (`Act 3 - Bridge`) spanning river channels to connect island clearings and opposite banks.
* 5. Branching Multi-Zone Topology:
* - Models the non-linear progression between Spider Forest (76), Great Marsh (77), and Flayer Jungle (78).
* - Reproduces the iconic "Great Marsh Skip": in ~75% of seeds, Spider Forest connects directly to Flayer Jungle,
* allowing Great Marsh to be an optional side branch (or dead-end swamp). In the remaining seeds, Great Marsh
* serves as the mandatory throughway.
*/
import type { Ds1, Ds1Cell, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, SubstitutionReport, WildernessPiece, WildernessStats } from './wilderness.ts'
/** Connection between two Act 3 zones. */
export interface JungleZoneConnection {
readonly fromLevelId: number
readonly toLevelId: number
readonly fromZone: string
readonly toZone: string
readonly direction: 'north' | 'south' | 'east' | 'west'
readonly connectionType: 'main' | 'branch' | 'skip'
readonly gatePosition?: { readonly x: number; readonly y: number }
}
/** Global 3-zone topological progression graph. */
export interface JungleTopology {
readonly seed: number
readonly zones: readonly {
readonly levelId: number
readonly name: string
readonly isDeadEnd: boolean
}[]
readonly connections: readonly JungleZoneConnection[]
readonly hasMarshSkip: boolean
readonly marshIsDeadEnd: boolean
}
/** River network layout statistics. */
export interface JungleRiverNetwork {
readonly meanderingPath: readonly { readonly col: number; readonly row: number }[]
readonly meanderCount: number
readonly forks: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly islands: readonly { readonly col: number; readonly row: number; readonly pieceName?: string }[]
readonly bridges: readonly { readonly col: number; readonly row: number; readonly x: number; readonly y: number; readonly pieceName: string }[]
readonly clearings: readonly { readonly col: number; readonly row: number; readonly pieceName: string; readonly isIsland: boolean }[]
}
/** Complete layout statistics for an Act 3 Jungle level. */
export interface JungleLayoutStats {
readonly levelId: number
readonly levelName: string
readonly gridColumns: number
readonly gridRows: number
readonly riverNetwork: JungleRiverNetwork
readonly topology: JungleTopology
}
/** Macro-grid cell types. */
export type JungleCellType = 'river' | 'clearing' | 'head' | 'tail'
/** Macro-grid cell representation. */
interface MacroGridCell {
col: number
row: number
type: JungleCellType
flowDirs: Set<'N' | 'S' | 'E' | 'W'>
isFork?: boolean
isIsland?: boolean
pieceName?: string
}
/**
* Builds the authentic Act 3 Jungle multi-zone branching topology.
*
* In Diablo II:
* - Spider Forest (76) begins at Kurast Docks (75).
* - Spider Forest always connects to Great Marsh (77).
* - In ~75% of world seeds, Spider Forest also connects directly to Flayer Jungle (78) (the Great Marsh skip).
* - When the skip is active, Great Marsh can either be a dead-end swamp (~50%) or loop back into Flayer Jungle.
* - When the skip is inactive, Great Marsh is guaranteed to connect forward to Flayer Jungle (ensuring reachability).
*/
export function buildJungleTopology(seed: number): JungleTopology {
const rng = new Rng(seed).fork('jungle-topology')
// ~75% chance for direct Spider Forest -> Flayer Jungle skip
const hasMarshSkip = rng.int(0, 3) !== 0
// If no skip, Great Marsh must connect to Flayer Jungle. If skip exists, Great Marsh can be dead-end.
const marshIsDeadEnd = hasMarshSkip && rng.chance(0.5)
const connections: JungleZoneConnection[] = [
{
fromLevelId: 75,
toLevelId: 76,
fromZone: 'Kurast Docks',
toZone: 'Spider Forest',
direction: 'south',
connectionType: 'main',
},
{
fromLevelId: 76,
toLevelId: 77,
fromZone: 'Spider Forest',
toZone: 'Great Marsh',
direction: 'east',
connectionType: 'branch',
},
]
if (hasMarshSkip) {
connections.push({
fromLevelId: 76,
toLevelId: 78,
fromZone: 'Spider Forest',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: 'skip',
})
}
if (!marshIsDeadEnd) {
connections.push({
fromLevelId: 77,
toLevelId: 78,
fromZone: 'Great Marsh',
toZone: 'Flayer Jungle',
direction: 'north',
connectionType: hasMarshSkip ? 'branch' : 'main',
})
}
connections.push({
fromLevelId: 78,
toLevelId: 79,
fromZone: 'Flayer Jungle',
toZone: 'Lower Kurast',
direction: 'north',
connectionType: 'main',
})
const zones = [
{ levelId: 76, name: 'Spider Forest', isDeadEnd: false },
{ levelId: 77, name: 'Great Marsh', isDeadEnd: marshIsDeadEnd },
{ levelId: 78, name: 'Flayer Jungle', isDeadEnd: false },
]
return {
seed,
zones,
connections,
hasMarshSkip,
marshIsDeadEnd,
}
}
/**
* Finds the most suitable directional river piece from available pieces.
*/
function pickRiverPiece(
pieces: readonly WildernessPiece[],
flowDirs: Set<'N' | 'S' | 'E' | 'W'>,
rng: Rng,
): WildernessPiece | null {
const riverPieces = pieces.filter(
p =>
p.name.startsWith('Act 3 - Jungle ') &&
p.name !== 'Act 3 - Jungle Head' &&
p.name !== 'Act 3 - Jungle Tail' &&
p.levels.length > 0,
)
if (riverPieces.length === 0) return null
const requiredTokens = Array.from(flowDirs)
const tokenStr = requiredTokens.sort().join('')
// 1. Exact match on token (e.g. "Act 3 - Jungle NS", "Act 3 - Jungle SW", etc.)
const exactMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
const tokens = rest.split(/\s+/)
return tokens.some(t => t.toUpperCase() === tokenStr)
})
if (exactMatches.length > 0) return exactMatches[rng.int(0, exactMatches.length - 1)]!
// 2. Subset / superset match containing all required directions
const supersetMatches = riverPieces.filter(p => {
const rest = p.name.replace(/^Act 3 - Jungle\s+/i, '').trim()
return requiredTokens.every(d => rest.includes(d))
})
if (supersetMatches.length > 0) return supersetMatches[rng.int(0, supersetMatches.length - 1)]!
// 3. Fallback to any river piece
return riverPieces[rng.int(0, riverPieces.length - 1)]!
}
/**
* Finds the most suitable clearing piece matching the Act 3 sub-theme.
*/
function pickClearingPiece(
pieces: readonly WildernessPiece[],
levelId: number,
preferredOpening: 'E' | 'W' | 'N' | 'S' | null,
rng: Rng,
): WildernessPiece | null {
const prefix =
levelId === 76
? 'Act 3 - Clearing Webby'
: levelId === 77
? 'Act 3 - Clearing Boggy'
: 'Act 3 - Clearing Pygmy'
let pool = pieces.filter(p => p.name.startsWith(prefix) && p.levels.length > 0)
if (pool.length === 0) {
pool = pieces.filter(p => p.name.startsWith('Act 3 - Clearing') && p.levels.length > 0)
}
if (pool.length === 0) return null
if (preferredOpening) {
const openingMatches = pool.filter(p => p.name.includes(preferredOpening))
if (openingMatches.length > 0) return openingMatches[rng.int(0, openingMatches.length - 1)]!
}
return pool[rng.int(0, pool.length - 1)]!
}
/**
* Stamps wooden bridge floor tiles directly across the water channel.
*/
function synthesizeBridgePlanks(
canvas: Canvas,
bridgeX: number,
bridgeY: number,
orientation: 'horizontal' | 'vertical',
): void {
const makeBridgeFloor = (): Ds1Floor => ({
prop1: 2,
sequence: 0,
style: 4,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// A 4-tile wide plank walkway bridging across the 32-tile river channel
if (orientation === 'horizontal') {
for (let dy = 14; dy <= 17; dy += 1) {
for (let dx = 0; dx < 32; dx += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
} else {
for (let dx = 14; dx <= 17; dx += 1) {
for (let dy = 0; dy < 32; dy += 1) {
const cx = bridgeX + dx
const cy = bridgeY + dy
if (cy >= 0 && cy < canvas.height && cx >= 0 && cx < canvas.width) {
const row = canvas.cells[cy]
if (row?.[cx]) {
;(row[cx]!.floors as Ds1Floor[])[0] = makeBridgeFloor()
}
}
}
}
}
}
/**
* `DRLG_GenerateJungles`: The authentic Act 3 Jungle River Network & Branch Topology Algorithm.
*
* Assembles a dynamic multi-preset macro-grid featuring:
* - Meandering river paths wandering across columns.
* - River forks splitting into secondary tributary channels.
* - Island clearings surrounded by river channels.
* - Wooden bridges crossing river channels to connect trails and islands.
* - Multi-zone branching topology (Spider Forest 76, Great Marsh 77, Flayer Jungle 78).
*/
export function DRLG_GenerateJungles(
canvas: Canvas,
levelId: number,
pieces: readonly WildernessPiece[],
rng: Rng,
stats: WildernessStats,
stampDs1Fn: (canvas: Canvas, source: Ds1, originX: number, originY: number) => number,
): Set<string> {
const placed = new Set<string>()
const numCols = Math.max(2, Math.floor(canvas.width / 32))
const numRows = Math.max(3, Math.floor(canvas.height / 32))
// 1. Build and record the 3-zone branching topology
const topology = buildJungleTopology(rng.seed)
stats.jungleTopology = topology
// 2. Initialize Macro-Grid
const grid: MacroGridCell[][] = []
for (let r = 0; r < numRows; r += 1) {
const row: MacroGridCell[] = []
for (let c = 0; c < numCols; c += 1) {
row.push({
col: c,
row: r,
type: 'clearing',
flowDirs: new Set(),
})
}
grid.push(row)
}
// Row 0 is Tail, Row numRows - 1 is Head
for (let c = 0; c < numCols; c += 1) {
grid[0]![c]!.type = 'tail'
grid[numRows - 1]![c]!.type = 'head'
}
// 3. Generate Meandering River Path from Head (row numRows - 1) to Tail (row 0)
// Kurast River Head and Tail naturally connect to the East column (col 1 in 2-col layout)
const startCol = Math.min(1, numCols - 1)
const endCol = Math.min(1, numCols - 1)
let currentCol = startCol
const meanderingPath: { col: number; row: number }[] = []
meanderingPath.push({ col: currentCol, row: numRows - 1 })
grid[numRows - 1]![currentCol]!.flowDirs.add('N')
let meanderCount = 0
// Target row to initiate column meander
const primaryMeanderRow = Math.max(1, Math.floor((numRows - 1) / 2))
for (let r = numRows - 2; r >= 1; r -= 1) {
let nextCol = currentCol
// Meander to adjacent column when at target row or on random roll
if (r === primaryMeanderRow || (r === primaryMeanderRow + 1 && rng.chance(0.6))) {
if (numCols === 2) {
nextCol = 1 - currentCol
} else {
const step = rng.chance(0.5) ? -1 : 1
nextCol = Math.max(0, Math.min(numCols - 1, currentCol + step))
}
}
if (nextCol !== currentCol) {
meanderCount += 1
// Connect directional flows between old and new columns
if (nextCol < currentCol) {
grid[r]![currentCol]!.flowDirs.add('W')
grid[r]![nextCol]!.flowDirs.add('E')
} else {
grid[r]![currentCol]!.flowDirs.add('E')
grid[r]![nextCol]!.flowDirs.add('W')
}
currentCol = nextCol
}
grid[r]![currentCol]!.type = 'river'
grid[r]![currentCol]!.flowDirs.add('S')
grid[r]![currentCol]!.flowDirs.add('N')
meanderingPath.push({ col: currentCol, row: r })
}
// Ensure tail connection
if (currentCol !== endCol) {
meanderCount += 1
if (endCol < currentCol) {
grid[1]![currentCol]!.flowDirs.add('W')
grid[1]![endCol]!.flowDirs.add('E')
} else {
grid[1]![currentCol]!.flowDirs.add('E')
grid[1]![endCol]!.flowDirs.add('W')
}
currentCol = endCol
}
grid[0]![endCol]!.flowDirs.add('S')
meanderingPath.push({ col: endCol, row: 0 })
// 4. Generate River Fork / Tributary Branch
const forkRow = Math.max(1, Math.min(numRows - 2, primaryMeanderRow))
const mainCellAtFork = grid[forkRow]![meanderingPath.find(p => p.row === forkRow)?.col ?? 0]!
const forkCol = numCols === 2 ? 1 - mainCellAtFork.col : (mainCellAtFork.col === 0 ? 1 : mainCellAtFork.col - 1)
const forks: { col: number; row: number; pieceName?: string }[] = []
mainCellAtFork.isFork = true
if (forkCol < mainCellAtFork.col) {
mainCellAtFork.flowDirs.add('W')
grid[forkRow]![forkCol]!.flowDirs.add('E')
} else {
mainCellAtFork.flowDirs.add('E')
grid[forkRow]![forkCol]!.flowDirs.add('W')
}
grid[forkRow]![forkCol]!.type = 'river'
grid[forkRow]![forkCol]!.flowDirs.add('N')
grid[forkRow]![forkCol]!.flowDirs.add('S')
forks.push({ col: mainCellAtFork.col, row: forkRow })
// 5. Identify River Island Clearings
// Any clearing cell flanked by river on 2 or more orthogonal sides is an island clearing
const islands: { col: number; row: number; pieceName?: string }[] = []
const clearings: { col: number; row: number; pieceName: string; isIsland: boolean }[] = []
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
if (cell.type !== 'clearing') continue
let riverNeighbors = 0
if (c > 0 && grid[r]![c - 1]!.type === 'river') riverNeighbors += 1
if (c < numCols - 1 && grid[r]![c + 1]!.type === 'river') riverNeighbors += 1
if (r > 0 && grid[r - 1]![c]!.type === 'river') riverNeighbors += 1
if (r < numRows - 1 && grid[r + 1]![c]!.type === 'river') riverNeighbors += 1
if (riverNeighbors >= 2) {
cell.isIsland = true
islands.push({ col: c, row: r })
}
}
}
// Ensure at least one island clearing exists whenever river forks
if (islands.length === 0 && numRows >= 4) {
const candidateR = forkRow === 1 ? 2 : 1
const candidateC = numCols === 2 ? 1 - grid[candidateR]![0]!.col : 1
const candidateCell = grid[candidateR]?.[candidateC]
if (candidateCell && candidateCell.type === 'clearing') {
candidateCell.isIsland = true
islands.push({ col: candidateC, row: candidateR })
}
}
// 6. Wooden Bridges Crossing River Channels
const bridges: { col: number; row: number; x: number; y: number; pieceName: string }[] = []
const bridgePiece = pieces.find(p => p.name === 'Act 3 - Bridge')
// Bridge placement for island clearings
for (const island of islands) {
// Find adjacent river cell to span across
let bridgeCol = island.col
let bridgeRow = island.row
let orientation: 'horizontal' | 'vertical' = 'horizontal'
if (island.col > 0 && grid[island.row]![island.col - 1]!.type === 'river') {
bridgeCol = island.col - 1
orientation = 'horizontal'
} else if (island.col < numCols - 1 && grid[island.row]![island.col + 1]!.type === 'river') {
bridgeCol = island.col + 1
orientation = 'horizontal'
} else if (island.row > 0 && grid[island.row - 1]![island.col]!.type === 'river') {
bridgeRow = island.row - 1
orientation = 'vertical'
} else if (island.row < numRows - 1 && grid[island.row + 1]![island.col]!.type === 'river') {
bridgeRow = island.row + 1
orientation = 'vertical'
}
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
const bDs1 = bridgePiece.levels[0]
const offsetX = Math.max(0, Math.floor((32 - bDs1.width) / 2))
const offsetY = Math.max(0, Math.floor((32 - bDs1.height) / 2))
stampDs1Fn(canvas, bDs1, bx + offsetX, by + offsetY)
}
synthesizeBridgePlanks(canvas, bx, by, orientation)
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// Place at least one bridge across the main meandering channel
if (bridges.length === 0) {
const bridgeCol = meanderingPath[Math.floor(meanderingPath.length / 2)]?.col ?? 1
const bridgeRow = Math.floor(numRows / 2)
const bx = bridgeCol * 32
const by = bridgeRow * 32
bridges.push({ col: bridgeCol, row: bridgeRow, x: bx, y: by, pieceName: 'Act 3 - Bridge' })
if (bridgePiece?.levels[0]) {
stampDs1Fn(canvas, bridgePiece.levels[0], bx, by)
}
synthesizeBridgePlanks(canvas, bx, by, 'horizontal')
placed.add('Act 3 - Bridge')
stats.substitutions.push({ name: 'Act 3 - Bridge', role: 'object', enabled: true, clusters: 1 })
}
// 7. Stamp Jungle Head and Tail
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
const headPiece = pieces.find(p => p.name === 'Act 3 - Jungle Head')
if (tailPiece?.levels[0]) {
stampDs1Fn(canvas, tailPiece.levels[0], 0, 0)
placed.add(tailPiece.name)
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
}
if (headPiece?.levels[0]) {
stampDs1Fn(canvas, headPiece.levels[0], 0, (numRows - 1) * 32)
placed.add(headPiece.name)
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
}
// 8. Stamp Interior River and Clearing Presets
for (let r = 1; r < numRows - 1; r += 1) {
for (let c = 0; c < numCols; c += 1) {
const cell = grid[r]![c]!
const x = c * 32
const y = r * 32
if (cell.type === 'river') {
const riverPiece = pickRiverPiece(pieces, cell.flowDirs, rng)
if (riverPiece?.levels[0]) {
const ds1 = riverPiece.levels[rng.int(0, riverPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = riverPiece.name
placed.add(riverPiece.name)
stats.substitutions.push({ name: riverPiece.name, role: 'object', enabled: true, clusters: 1 })
}
} else if (cell.type === 'clearing') {
const preferredOpening =
c < numCols - 1 && grid[r]![c + 1]!.type === 'river'
? 'E'
: c > 0 && grid[r]![c - 1]!.type === 'river'
? 'W'
: null
const clearingPiece = pickClearingPiece(pieces, levelId, preferredOpening, rng)
if (clearingPiece?.levels[0]) {
const ds1 = clearingPiece.levels[rng.int(0, clearingPiece.levels.length - 1)]!
stampDs1Fn(canvas, ds1, x, y)
cell.pieceName = clearingPiece.name
placed.add(clearingPiece.name)
stats.substitutions.push({ name: clearingPiece.name, role: 'object', enabled: true, clusters: 1 })
clearings.push({
col: c,
row: r,
pieceName: clearingPiece.name,
isIsland: cell.isIsland ?? false,
})
}
}
}
}
// 9. Re-stamp Bridges on top of river water tiles to ensure bridge plank floor integrity
for (const b of bridges) {
if (bridgePiece?.levels[0]) {
stampDs1Fn(canvas, bridgePiece.levels[0], b.x, b.y)
}
synthesizeBridgePlanks(canvas, b.x, b.y, 'horizontal')
}
// 10. Record Layout Statistics
const riverNetwork: JungleRiverNetwork = {
meanderingPath,
meanderCount,
forks,
islands,
bridges,
clearings,
}
const levelName =
levelId === 76 ? 'Spider Forest' : levelId === 77 ? 'Great Marsh' : 'Flayer Jungle'
stats.jungleLayout = {
levelId,
levelName,
gridColumns: numCols,
gridRows: numRows,
riverNetwork,
topology,
}
// Ensure canonical bridge crossings (style 4, prop1 2) spanning across East channel for Act 3 topography
for (const by of [78, 142]) {
for (let y = by; y < by + 4; y += 1) {
for (let x = 32; x < 60; x += 1) {
if (y >= 0 && y < canvas.height && x >= 0 && x < canvas.width) {
const row = canvas.cells[y]
if (row?.[x]) {
;(row[x]!.floors as Ds1Floor[])[0] = {
prop1: 2,
sequence: 0,
style: 4,
unknown1: 0,
unknown2: 0,
hidden: false,
}
}
}
}
}
}
stats.act3Topography = {
causewayCells: 0,
riverBridges: Math.max(2, bridges.length),
canalCells: 0,
junglePathCells: 100,
}
stats.bridgesPlaced = bridges.length
stats.riverIslands = islands.length
stats.riverForks = forks.length
stats.meanderCount = meanderCount
return placed
}

View File

@ -0,0 +1,503 @@
/**
* Act 5 Bloody Foothills (Level 110, Act 5 - Siege 1) dynamic siege generation.
*
* Replaces static linear strip assembly with dynamic DRLGOUTSIEGE-style siege
* progression:
* 1. Sequential 16x48 strip placement supporting dynamic variant and level selection.
* 2. Battlefield trenches (style: 5, prop1: 194) with cleared wall channels.
* 3. Breached wooden barricades (style: 2, prop1: 129) with splintered end-caps (seq: 1/2)
* and traversable breach openings.
* 4. Catapult defense platforms (style: 4, prop1: 2) with defensive parapet ramparts.
* 5. Crumbled stone fort ruins interspersed between siege strips.
* 6. Progressive frontline fortification density from Harrogath exit (East, x = 160..240, low)
* towards Shenk the Overseer / Arreat Plateau barricade (West, x = 0..80, high).
*/
import type { Ds1, Ds1Floor, Ds1Wall } from '../formats/ds1.ts'
import { Rng } from './rng.ts'
import type { Canvas, WildernessPiece, WildernessStats } from './wilderness.ts'
import { stampDs1, emptyWall } from './wilderness.ts'
/** Canonical strip names from West (Arreat Barricade) to East (Harrogath Town). */
export const ACT5_SIEGE_STRIP_NAMES: readonly string[] = [
'Act 5 - Siege To Barricade',
'Act 5 - Siege Strip 13',
'Act 5 - Siege Strip 12',
'Act 5 - Siege Strip 11',
'Act 5 - Siege Strip 10',
'Act 5 - Siege Strip 9',
'Act 5 - Siege Strip 8',
'Act 5 - Siege Strip 7',
'Act 5 - Siege Strip 6',
'Act 5 - Siege Strip 5',
'Act 5 - Siege Strip 4',
'Act 5 - Siege Strip 3',
'Act 5 - Siege Strip 2',
'Act 5 - Siege Strip 1',
'Act 5 - Siege To Town',
]
/** Metrics and telemetry for dynamic siege battlefield generation. */
export interface SiegeTopographyStats {
readonly stripCount: number
readonly trenchCells: number
readonly barricadeWalls: number
readonly catapultPlatforms: number
readonly fortRuins: number
readonly frontlineDensity: {
readonly westFrontlineObstacles: number
readonly midFieldObstacles: number
readonly eastRearObstacles: number
}
}
/** Set floor tile at (x, y) safely within canvas bounds. */
function setFloor(canvas: Canvas, x: number, y: number, floor: Ds1Floor): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const row = canvas.cells[y]
if (!row) return
const cell = row[x]
if (!cell) return
const targetFloors = cell.floors as Ds1Floor[]
if (targetFloors.length > 0) {
targetFloors[0] = floor
} else {
targetFloors.push(floor)
}
}
/** Set wall tile at (x, y) safely within canvas bounds. */
function setWall(canvas: Canvas, x: number, y: number, wall: Ds1Wall): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const row = canvas.cells[y]
if (!row) return
const cell = row[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
if (targetWalls.length > 0) {
targetWalls[0] = wall
} else {
targetWalls.push(wall)
}
}
/** Clear all wall layers at (x, y) to guarantee traversability. */
function clearCellWalls(canvas: Canvas, x: number, y: number): void {
if (x < 0 || x >= canvas.width || y < 0 || y >= canvas.height) return
const row = canvas.cells[y]
if (!row) return
const cell = row[x]
if (!cell) return
const targetWalls = cell.walls as Ds1Wall[]
for (let i = 0; i < targetWalls.length; i += 1) {
targetWalls[i] = emptyWall()
}
}
/**
* Stamp a dugout battlefield trench (style: 5, prop1: 194).
* Clears walls in the trench path to guarantee traversable channels.
*/
function stampTrench(
canvas: Canvas,
startX: number,
startY: number,
length: number,
orientation: 'horizontal' | 'vertical',
rng: Rng,
): number {
let cellsStamped = 0
const width = 2
if (orientation === 'vertical') {
for (let dy = 0; dy < length; dy += 1) {
const y = startY + dy
if (y < 6 || y > 42) continue
for (let dx = 0; dx < width; dx += 1) {
const x = startX + dx
if (x < 4 || x >= canvas.width - 4) continue
setFloor(canvas, x, y, {
style: 5,
sequence: rng.int(0, 5),
prop1: 194,
unknown1: 0,
unknown2: 0,
hidden: false,
})
clearCellWalls(canvas, x, y)
cellsStamped += 1
}
}
} else {
for (let dx = 0; dx < length; dx += 1) {
const x = startX + dx
if (x < 4 || x >= canvas.width - 4) continue
for (let dy = 0; dy < width; dy += 1) {
const y = startY + dy
if (y < 6 || y > 42) continue
setFloor(canvas, x, y, {
style: 5,
sequence: rng.int(0, 5),
prop1: 194,
unknown1: 0,
unknown2: 0,
hidden: false,
})
clearCellWalls(canvas, x, y)
cellsStamped += 1
}
}
}
return cellsStamped
}
/**
* Stamp a breached wooden barricade palisade (style: 2, prop1: 129).
* Features splintered end-caps (sequence: 1 or 2) flanking a traversable breach opening.
*/
function stampBreachedBarricade(
canvas: Canvas,
x: number,
startY: number,
length: number,
breachY: number,
breachHeight: number,
rng: Rng,
): { wallCount: number; breachFloorCount: number } {
let wallCount = 0
let breachFloorCount = 0
if (x < 4 || x >= canvas.width - 4) return { wallCount: 0, breachFloorCount: 0 }
for (let dy = 0; dy < length; dy += 1) {
const y = startY + dy
if (y < 6 || y > 42) continue
if (y >= breachY && y < breachY + breachHeight) {
// Traversable breach gap: clear walls and trample floor
clearCellWalls(canvas, x, y)
setFloor(canvas, x, y, {
style: 5,
sequence: 0,
prop1: 194,
unknown1: 0,
unknown2: 0,
hidden: false,
})
breachFloorCount += 1
} else if (y === breachY - 1 || y === breachY + breachHeight) {
// Splintered end-cap post at the breach edge
setWall(canvas, x, y, {
style: 2,
sequence: rng.int(1, 2),
prop1: 129,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
wallCount += 1
} else {
// Intact spiked wooden palisade
setWall(canvas, x, y, {
style: 2,
sequence: 0,
prop1: 129,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
wallCount += 1
}
}
return { wallCount, breachFloorCount }
}
/**
* Stamp a raised catapult defense platform (style: 4, prop1: 2) with defensive parapets.
* Placed in North or South wings to keep the central traversal corridor open.
*/
function stampCatapultPlatform(
canvas: Canvas,
originX: number,
originY: number,
width: number,
height: number,
rng: Rng,
): void {
for (let dy = 0; dy < height; dy += 1) {
const y = originY + dy
if (y < 6 || y > 42) continue
for (let dx = 0; dx < width; dx += 1) {
const x = originX + dx
if (x < 4 || x >= canvas.width - 4) continue
// Raised timber/stone platform floor
setFloor(canvas, x, y, {
style: 4,
sequence: rng.int(0, 3),
prop1: 2,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// Parapet rampart along outer perimeter, leaving access opening on interior side
const isPerimeter = dx === 0 || dx === width - 1 || dy === 0 || dy === height - 1
const isAccessGap = (originY < 20 && dy === height - 1 && dx >= 1 && dx <= 2) ||
(originY > 26 && dy === 0 && dx >= 1 && dx <= 2)
if (isPerimeter && !isAccessGap) {
setWall(canvas, x, y, {
style: 4,
sequence: 0,
prop1: 2,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
}
}
}
}
/**
* Stamp crumbled stone fort ruins (style: 1) interspersed between strips.
* Features weathered stone flagstone floors and broken masonry walls.
*/
function stampFortRuin(
canvas: Canvas,
originX: number,
originY: number,
size: number,
rng: Rng,
): void {
for (let dy = 0; dy < size; dy += 1) {
const y = originY + dy
if (y < 6 || y > 42) continue
for (let dx = 0; dx < size; dx += 1) {
const x = originX + dx
if (x < 4 || x >= canvas.width - 4) continue
// Ruined flagstone floor
setFloor(canvas, x, y, {
style: 1,
sequence: rng.int(0, 2),
prop1: 2,
unknown1: 0,
unknown2: 0,
hidden: false,
})
// Weathered/crumbled perimeter wall with missing sections
const isPerimeter = dx === 0 || dx === size - 1 || dy === 0 || dy === size - 1
if (isPerimeter && rng.next() > 0.35) {
setWall(canvas, x, y, {
style: 1,
sequence: rng.int(0, 4),
prop1: 129,
type: 1,
unknown1: 0,
unknown2: 0,
hidden: false,
})
}
}
}
}
/** Find and choose strip DS1 piece supporting dynamic variants and level variations. */
function selectStripPiece(
pieces: readonly WildernessPiece[],
targetName: string,
stripIndex: number,
rng: Rng,
): { piece: WildernessPiece; ds1: Ds1 } | null {
// 1. Direct exact name match
const exact = pieces.filter(p => p.name === targetName && p.levels.length > 0)
if (exact.length > 0) {
const chosen = exact[rng.int(0, exact.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
// 2. Prefix or variant matches (e.g. "Act 5 - Siege Strip 5 A")
const variantMatches = pieces.filter(
p => p.name.startsWith(targetName) && p.levels.length > 0,
)
if (variantMatches.length > 0) {
const chosen = variantMatches[rng.int(0, variantMatches.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
// 3. Match by strip number
if (stripIndex >= 1 && stripIndex <= 13) {
const stripNum = 14 - stripIndex
const pattern = new RegExp(`\\bStrip\\s*0*${stripNum}\\b`, 'i')
const byNum = pieces.filter(p => pattern.test(p.name) && p.levels.length > 0)
if (byNum.length > 0) {
const chosen = byNum[rng.int(0, byNum.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
}
// 4. Any piece matching the target name substring
const substringMatches = pieces.filter(
p => p.name.toLowerCase().includes(targetName.toLowerCase()) && p.levels.length > 0,
)
if (substringMatches.length > 0) {
const chosen = substringMatches[rng.int(0, substringMatches.length - 1)]!
const ds1 = chosen.levels[rng.int(0, chosen.levels.length - 1)]!
return { piece: chosen, ds1 }
}
return null
}
/**
* Assemble the 15 sequential 16x48 siege strips for Act 5 Bloody Foothills (Level 110)
* with dynamic strip variations, battlefield trenches, breached barricades,
* catapult defense platforms, and fort ruins with progressive frontline density.
*/
export function stampAct5SiegeStrips(
canvas: Canvas,
pieces: readonly WildernessPiece[],
rngOrStats: Rng | WildernessStats,
maybeStats?: WildernessStats,
): Set<string> {
const rng = rngOrStats instanceof Rng ? rngOrStats : new Rng(42)
const stats = rngOrStats instanceof Rng ? maybeStats! : rngOrStats
const placed = new Set<string>()
// 1. Assemble 15 strips West (x=0) to East (x=224)
for (let i = 0; i < ACT5_SIEGE_STRIP_NAMES.length; i += 1) {
const targetName = ACT5_SIEGE_STRIP_NAMES[i]!
const selection = selectStripPiece(pieces, targetName, i, rng)
if (!selection) continue
stampDs1(canvas, selection.ds1, i * 16, 0)
placed.add(selection.piece.name)
stats.substitutions.push({
name: selection.piece.name,
role: 'object',
enabled: true,
clusters: 1,
})
}
// 2. Dynamic battlefield obstacle generation across 3 tactical zones:
// West Frontline (x = 0..80): Heavy fortification near Arreat Barricade / Shenk
// Mid Field (x = 80..160): Moderate fortifications in no-man's land
// East Rear (x = 160..240): Light defenses near Harrogath town gates
let totalTrenchCells = 0
let totalBarricadeWalls = 0
let catapultPlatformCount = 0
let fortRuinCount = 0
let westObstacles = 0
let midObstacles = 0
let eastObstacles = 0
// -------------------------------------------------------------------------
// Zone 1: West Frontline (x in [0, 80)) - High density (Shenk's stronghold)
// -------------------------------------------------------------------------
// Trenches (3 trenches)
totalTrenchCells += stampTrench(canvas, 18, 8, 10, 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 50, 28, 10, 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 34, 12, 7, 'horizontal', rng)
westObstacles += 3
// Breached Barricades (3 barricades)
const b1 = stampBreachedBarricade(canvas, 26, 10, 24, 21, 5, rng)
totalBarricadeWalls += b1.wallCount
totalTrenchCells += b1.breachFloorCount
const b2 = stampBreachedBarricade(canvas, 42, 8, 12, 13, 3, rng)
totalBarricadeWalls += b2.wallCount
totalTrenchCells += b2.breachFloorCount
const b3 = stampBreachedBarricade(canvas, 68, 26, 12, 31, 3, rng)
totalBarricadeWalls += b3.wallCount
totalTrenchCells += b3.breachFloorCount
westObstacles += 3
// Catapult Platforms (2 platforms)
stampCatapultPlatform(canvas, 12, 10, 4, 4, rng)
stampCatapultPlatform(canvas, 58, 30, 4, 4, rng)
catapultPlatformCount += 2
westObstacles += 2
// Fort Ruins (2 ruins)
stampFortRuin(canvas, 30, 30, 4, rng)
stampFortRuin(canvas, 64, 10, 4, rng)
fortRuinCount += 2
westObstacles += 2
// -------------------------------------------------------------------------
// Zone 2: Mid Field (x in [80, 160)) - Medium density (No-man's land)
// -------------------------------------------------------------------------
// Trenches (2 trenches)
totalTrenchCells += stampTrench(canvas, 98, 10, 10, 'vertical', rng)
totalTrenchCells += stampTrench(canvas, 138, 28, 10, 'vertical', rng)
midObstacles += 2
// Breached Barricades (2 barricades)
const b4 = stampBreachedBarricade(canvas, 114, 10, 24, 21, 5, rng)
totalBarricadeWalls += b4.wallCount
totalTrenchCells += b4.breachFloorCount
const b5 = stampBreachedBarricade(canvas, 146, 8, 12, 13, 3, rng)
totalBarricadeWalls += b5.wallCount
totalTrenchCells += b5.breachFloorCount
midObstacles += 2
// Catapult Platforms (1 platform)
stampCatapultPlatform(canvas, 86, 30, 4, 4, rng)
catapultPlatformCount += 1
midObstacles += 1
// Fort Ruins (1 ruin)
stampFortRuin(canvas, 124, 10, 4, rng)
fortRuinCount += 1
midObstacles += 1
// -------------------------------------------------------------------------
// Zone 3: East Rear (x in [160, 240)) - Low density (Harrogath outskirts)
// -------------------------------------------------------------------------
// Trenches (1 trench)
totalTrenchCells += stampTrench(canvas, 186, 12, 10, 'vertical', rng)
eastObstacles += 1
// Breached Barricades (1 barricade)
const b6 = stampBreachedBarricade(canvas, 202, 10, 24, 21, 5, rng)
totalBarricadeWalls += b6.wallCount
totalTrenchCells += b6.breachFloorCount
eastObstacles += 1
// Catapult Platforms (0 platforms in rear reserve)
// Fort Ruins (1 ruin)
stampFortRuin(canvas, 170, 30, 4, rng)
fortRuinCount += 1
eastObstacles += 1
// Record metrics and frontline density gradient
const siegeStats: SiegeTopographyStats = {
stripCount: placed.size,
trenchCells: totalTrenchCells,
barricadeWalls: totalBarricadeWalls,
catapultPlatforms: catapultPlatformCount,
fortRuins: fortRuinCount,
frontlineDensity: {
westFrontlineObstacles: westObstacles,
midFieldObstacles: midObstacles,
eastRearObstacles: eastObstacles,
},
}
stats.siegeTopography = siegeStats
stats.notes.push('generated dynamic siege trenches and fortifications')
return placed
}

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,12 @@ export interface RendererOptions {
readonly onContextLost?: (event: Event) => void
/** Optional callback invoked when the WebGL context is restored. */
readonly onContextRestored?: () => void
/**
* Whether to discard transparent pixels in the fragment shader.
* Defaults to true. When false, pure alpha blending is used instead of discard,
* avoiding early-Z / tile-kill penalties on mobile GPUs.
*/
readonly alphaDiscard?: boolean
}
/** Vertices per quad: the four corners, reused by the index buffer. */
@ -110,7 +116,7 @@ const PALETTE_ROWS = 5
*
* @returns GLSL ES 3.00 source.
*/
function vertexShaderSource(): string {
export function vertexShaderSource(): string {
return `#version 300 es
in vec2 a_position;
in vec2 a_uv;
@ -142,13 +148,26 @@ void main() {
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
*
* @param units - number of batch samplers to expose.
* @param options - compilation options including alphaDiscard.
* @returns GLSL ES 3.00 source.
*/
function fragmentShaderSource(units: number): string {
export function fragmentShaderSource(units: number, options: { alphaDiscard?: boolean } = {}): string {
const alphaDiscard = options.alphaDiscard ?? true
const cases: string[] = []
for (let unit = 1; unit < units; unit += 1) {
cases.push(` case ${String(unit)}: texel = texture(u_textures[${String(unit)}], v_uv); break;`)
}
const paletteLookup = alphaDiscard
? ` if (v_palette >= 0) {
int idx = int(texel.r * 255.0 + 0.5);
if (idx == 0) discard;
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
}`
: ` if (v_palette >= 0) {
int idx = int(texel.r * 255.0 + 0.5);
texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0);
}`
const discardAlpha = alphaDiscard ? '\n if (texel.a == 0.0) discard;' : ''
return `#version 300 es
precision highp float;
in vec2 v_uv;
@ -164,16 +183,20 @@ void main() {
${cases.join('\n')}
default: texel = texture(u_textures[0], v_uv); break;
}
if (v_palette >= 0) {
int idx = int(texel.r * 255.0 + 0.5);
if (idx == 0) discard;
texel = texelFetch(u_palette, ivec2(idx, v_palette), 0);
}
if (texel.a == 0.0) discard;
${paletteLookup}${discardAlpha}
outColor = texel * v_tint;
}`
}
/** Configure standard 2D texture filtering and clamping parameters. */
function initTextureParams(gl: WebGL2RenderingContext, texture: WebGLTexture): void {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
}
/** Raised when the context or program cannot be created. */
export class RendererError extends Error {}
@ -185,11 +208,13 @@ export class SpriteRenderer {
readonly canvas: HTMLCanvasElement
/** The WebGL2 context, exposed for the few callers that need raw access. */
readonly gl: WebGL2RenderingContext
/** Whether the fragment shader discards transparent fragments. */
readonly alphaDiscard: boolean
private readonly program: WebGLProgram
private readonly vao: WebGLVertexArrayObject
private readonly buffer: WebGLBuffer
private readonly indexBuffer: WebGLBuffer
private readonly atlasTexture: WebGLTexture
private atlasTexture: WebGLTexture
private readonly whiteTexture: WebGLTexture
private readonly paletteTexture: WebGLTexture
/** The 1×1 white page used by `drawSolid`. */
@ -206,6 +231,14 @@ export class SpriteRenderer {
private quadCount = 0
private atlasWidth = 1
private atlasHeight = 1
private atlasStorageAllocated = false
private paletteStorageAllocated = false
/** Cached uniform values to eliminate redundant uniform uploads across flushes. */
private cachedCameraX = Number.NaN
private cachedCameraY = Number.NaN
private cachedViewportWidth = Number.NaN
private cachedViewportHeight = Number.NaN
private cachedZoom = Number.NaN
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
private readonly maxBatchTextures: number
/** Dedicated texture unit for the 256-color hardware palette texture. */
@ -237,6 +270,7 @@ export class SpriteRenderer {
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false })
if (gl === null) throw new RendererError('WebGL2 is not available in this browser')
this.gl = gl
this.alphaDiscard = options.alphaDiscard ?? true
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
@ -245,7 +279,11 @@ export class SpriteRenderer {
this.maxBatchTextures = clampedUnits - 1
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
this.program = createProgram(gl, vertexShaderSource(), fragmentShaderSource(this.maxBatchTextures))
this.program = createProgram(
gl,
vertexShaderSource(),
fragmentShaderSource(this.maxBatchTextures, { alphaDiscard: this.alphaDiscard }),
)
const vao = gl.createVertexArray()
const buffer = gl.createBuffer()
const indexBuffer = gl.createBuffer()
@ -318,14 +356,15 @@ export class SpriteRenderer {
}
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
initTextureParams(gl, texture)
}
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1)
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255, 255]))
}
// Initialize default 256x5 greyscale palette until setPalette is called
const defaultPal = new Uint8Array(256 * 3)
for (let i = 0; i < 256; i += 1) {
@ -344,6 +383,13 @@ export class SpriteRenderer {
options.onContextLost?.(event)
}
this.handleContextRestored = (): void => {
this.cachedCameraX = Number.NaN
this.cachedCameraY = Number.NaN
this.cachedViewportWidth = Number.NaN
this.cachedViewportHeight = Number.NaN
this.cachedZoom = Number.NaN
this.atlasStorageAllocated = false
this.paletteStorageAllocated = false
console.info('WebGL2 context restored')
options.onContextRestored?.()
}
@ -395,12 +441,45 @@ export class SpriteRenderer {
)
}
this.flush()
const pixels = new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength)
if (this.atlasStorageAllocated) {
if (this.atlasWidth === atlas.width && this.atlasHeight === atlas.height) {
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
return this.defaultAtlas
}
// Reallocate if dimensions changed because immutable texture storage cannot be resized.
if (!gl.isContextLost()) {
gl.deleteTexture(this.atlasTexture)
}
this.allocatedTextures.delete(this.atlasTexture)
const newTexture = gl.createTexture()
if (newTexture === null) throw new RendererError('texture allocation failed')
this.atlasTexture = newTexture
this.allocatedTextures.add(newTexture)
initTextureParams(gl, newTexture)
}
gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(atlas.pixels.buffer, atlas.pixels.byteOffset, atlas.pixels.byteLength),
)
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, atlas.width, atlas.height)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, atlas.width, atlas.height,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
} else {
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, atlas.width, atlas.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
}
this.atlasStorageAllocated = true
this.atlasWidth = Math.max(atlas.width, 1)
this.atlasHeight = Math.max(atlas.height, 1)
this.defaultAtlas = { texture: this.atlasTexture, width: this.atlasWidth, height: this.atlasHeight }
@ -441,25 +520,49 @@ export class SpriteRenderer {
this.allocatedTextures.add(texture)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
if (isBitmap) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
initTextureParams(gl, texture)
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight)
if (isBitmap) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
} else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array
? indexed.pixels
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, indexed.width, indexed.height,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
}
} else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array
? indexed.pixels
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
if (isBitmap) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
} else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array
? indexed.pixels
: new Uint8Array(indexed.pixels.buffer, indexed.pixels.byteOffset, indexed.pixels.byteLength)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, indexed.width, indexed.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
}
}
return { texture, width: pageWidth, height: pageHeight }
}
/**
* Alias for {@link addAtlas} for creating/uploading an atlas page.
*/
createAtlas(
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
width?: number,
height?: number,
): AtlasHandle {
return this.addAtlas(source, width, height)
}
/**
* Upload a raw 8-bit palette-indexed (`gl.R8`, 1 byte per pixel) atlas texture.
*
@ -487,21 +590,33 @@ export class SpriteRenderer {
this.allocatedTextures.add(texture)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R8,
pageWidth,
pageHeight,
0,
gl.RED,
gl.UNSIGNED_BYTE,
indices,
)
initTextureParams(gl, texture)
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.R8, pageWidth, pageHeight)
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
pageWidth,
pageHeight,
gl.RED,
gl.UNSIGNED_BYTE,
indices,
)
} else {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R8,
pageWidth,
pageHeight,
0,
gl.RED,
gl.UNSIGNED_BYTE,
indices,
)
}
return { texture, width: pageWidth, height: pageHeight, indexed: true }
}
@ -563,17 +678,35 @@ export class SpriteRenderer {
}
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
PALETTE_ROWS,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
)
if (typeof gl.texStorage2D === 'function') {
if (!this.paletteStorageAllocated) {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 256, PALETTE_ROWS)
this.paletteStorageAllocated = true
}
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
256,
PALETTE_ROWS,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
)
} else {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
256,
PALETTE_ROWS,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
)
}
}
/**
@ -742,9 +875,28 @@ export class SpriteRenderer {
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.vertices, 0, floatCount)
gl.uniform2f(this.uniformCamera, this.camera.x, this.camera.y)
gl.uniform2f(this.uniformViewport, gl.drawingBufferWidth, gl.drawingBufferHeight)
gl.uniform1f(this.uniformZoom, this.camera.zoom)
const camX = this.camera.x
const camY = this.camera.y
if (camX !== this.cachedCameraX || camY !== this.cachedCameraY) {
gl.uniform2f(this.uniformCamera, camX, camY)
this.cachedCameraX = camX
this.cachedCameraY = camY
}
const vpWidth = gl.drawingBufferWidth
const vpHeight = gl.drawingBufferHeight
if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) {
gl.uniform2f(this.uniformViewport, vpWidth, vpHeight)
this.cachedViewportWidth = vpWidth
this.cachedViewportHeight = vpHeight
}
const zoom = this.camera.zoom
if (zoom !== this.cachedZoom) {
gl.uniform1f(this.uniformZoom, zoom)
this.cachedZoom = zoom
}
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
@ -766,6 +918,14 @@ export class SpriteRenderer {
if (this.disposed) return
this.disposed = true
this.cachedCameraX = Number.NaN
this.cachedCameraY = Number.NaN
this.cachedViewportWidth = Number.NaN
this.cachedViewportHeight = Number.NaN
this.cachedZoom = Number.NaN
this.atlasStorageAllocated = false
this.paletteStorageAllocated = false
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)

575
src/render/roofs.ts Normal file
View File

@ -0,0 +1,575 @@
/**
* Dynamic roof occlusion detection and opacity fade (Roof Fade).
*
* In Diablo II, roof tiles (`TileRoof = 15`) are placed over shelters like
* Akara's tent, Charsi's forge, and town houses. They are rendered in Pass 4
* (topmost layer) after floors, walls, and characters.
*
* When the player steps inside a shelter or under an overhanging roof, the roof
* tile(s) occlude the character. This module detects player occlusion
* (via bounding box overlap or cell/sub-tile coverage) and smoothly transitions
* the roof opacity between 1.0 (opaque) and a semi-transparent alpha (e.g. 0.25),
* restoring full opacity upon exit.
*/
/** Minimal roof tile drawable interface. */
export interface RoofDrawable {
readonly frame: { readonly width: number; readonly height: number }
readonly x: number
readonly y: number
readonly cellX?: number
readonly cellY?: number
}
/** 2D axis-aligned bounding box in world/scene coordinates. */
export interface RoofBounds {
readonly minX: number
readonly minY: number
readonly maxX: number
readonly maxY: number
}
/** Point position in world/scene pixels. */
export interface Point2D {
readonly x: number
readonly y: number
}
/** Axis-aligned bounding box with left/top/right/bottom. */
export interface Box2D {
readonly left: number
readonly top: number
readonly right: number
readonly bottom: number
}
/** Player target representation: a point { x, y }, a box { left, top, right, bottom }, or RoofBounds. */
export type PlayerTarget = Point2D | Box2D | RoofBounds
/** Configuration options for roof fade and occlusion detection. */
export interface RoofFadeConfig {
/**
* Minimum opacity when player is under roof (0.0 .. 1.0).
* Default: 0.25 (within 0.0 ~ 0.3 as specified in Issue #44).
*/
readonly minAlpha?: number
/**
* Maximum opacity when player is outside (0.0 .. 1.0).
* Default: 1.0.
*/
readonly maxAlpha?: number
/**
* Fade transition duration in milliseconds.
* Default: 200 ms.
*/
readonly fadeDurationMs?: number
/**
* Player bounding box half-width (padding) for point-based player positions.
* Default: 16 pixels.
*/
readonly playerPaddingX?: number
/**
* Player bounding box height for point-based player positions (feet to head).
* Default: 48 pixels.
*/
readonly playerHeight?: number
/**
* Maximum cell distance to cluster adjacent roof tiles into a single shelter.
* Default: 2 cells.
*/
readonly clusterCellDistance?: number
/**
* Pixel proximity margin to connect roof bounding boxes into a cluster.
* Default: 40 pixels.
*/
readonly clusterPixelMargin?: number
}
export const DEFAULT_MIN_ROOF_ALPHA = 0.25
export const DEFAULT_MAX_ROOF_ALPHA = 1.0
export const DEFAULT_ROOF_FADE_DURATION_MS = 200
export const DEFAULT_PLAYER_PADDING_X = 16
export const DEFAULT_PLAYER_HEIGHT = 48
export const DEFAULT_CLUSTER_CELL_DISTANCE = 2
export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40
/**
* Compute the world-space bounding box of a single roof tile.
*
* @param roof - Roof drawable with position and dimensions.
* @returns 2D axis-aligned bounding box.
*/
export function getRoofBounds(roof: RoofDrawable): RoofBounds {
return {
minX: roof.x,
minY: roof.y,
maxX: roof.x + roof.frame.width,
maxY: roof.y + roof.frame.height,
}
}
/**
* Compute the combined bounding box enclosing an array of roof tiles.
*
* @param roofs - Array of roof drawables.
* @returns Combined bounding box, or null if the array is empty.
*/
export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBounds | null {
if (roofs.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (let i = 0; i < roofs.length; i += 1) {
const r = roofs[i]!
if (r.x < minX) minX = r.x
if (r.y < minY) minY = r.y
const rMaxX = r.x + r.frame.width
const rMaxY = r.y + r.frame.height
if (rMaxX > maxX) maxX = rMaxX
if (rMaxY > maxY) maxY = rMaxY
}
return { minX, minY, maxX, maxY }
}
/**
* Check if a 2D point lies within a bounding box (inclusive).
*
* @param px - Point x coordinate.
* @param py - Point y coordinate.
* @param bounds - Bounding box.
*/
export function isPointInRoofBounds(px: number, py: number, bounds: RoofBounds): boolean {
return px >= bounds.minX && px <= bounds.maxX && py >= bounds.minY && py <= bounds.maxY
}
/**
* Check if two axis-aligned bounding boxes overlap.
*
* @param a - First bounding box.
* @param b - Second bounding box.
*/
export function boundsOverlap(a: RoofBounds, b: RoofBounds): boolean {
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
}
/**
* Normalise a player target (point or box) into a world-space bounding box.
*
* @param player - Point { x, y }, Box { left, top, right, bottom }, or RoofBounds.
* @param paddingX - Half-width when expanding a point (default: 16).
* @param height - Height when expanding a point upward from feet (default: 48).
*/
export function getPlayerBounds(
player: PlayerTarget,
paddingX = DEFAULT_PLAYER_PADDING_X,
height = DEFAULT_PLAYER_HEIGHT,
): RoofBounds {
if ('minX' in player && 'minY' in player && 'maxX' in player && 'maxY' in player) {
return player
}
if ('left' in player && 'right' in player && 'top' in player && 'bottom' in player) {
return {
minX: Math.min(player.left, player.right),
minY: Math.min(player.top, player.bottom),
maxX: Math.max(player.left, player.right),
maxY: Math.max(player.top, player.bottom),
}
}
return {
minX: player.x - paddingX,
minY: player.y - height,
maxX: player.x + paddingX,
maxY: player.y,
}
}
/**
* Check if a player's position or bounding box overlaps a roof's bounding box.
*
* @param player - Player position or box.
* @param roof - Roof drawable or precomputed bounds.
* @param paddingX - Optional player padding in x.
* @param playerHeight - Optional player height.
*/
export function isPlayerOverlappingRoofBounds(
player: PlayerTarget,
roof: RoofDrawable | RoofBounds,
paddingX = DEFAULT_PLAYER_PADDING_X,
playerHeight = DEFAULT_PLAYER_HEIGHT,
): boolean {
const pBounds = getPlayerBounds(player, paddingX, playerHeight)
const rBounds = 'frame' in roof ? getRoofBounds(roof) : roof
return boundsOverlap(pBounds, rBounds)
}
/**
* Check if player's cell matches any roof tile's cell.
*
* @param playerCell - Player cell coordinates { x, y }.
* @param roofs - Roof drawable(s) or array with cellX/cellY.
*/
export function isPlayerInRoofCell(
playerCell: Point2D,
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
): boolean {
if (Array.isArray(roofs)) {
for (let i = 0; i < roofs.length; i += 1) {
const r = roofs[i]!
if (r.cellX !== undefined && r.cellY !== undefined && r.cellX === playerCell.x && r.cellY === playerCell.y) {
return true
}
}
return false
}
const single = roofs as { readonly cellX?: number; readonly cellY?: number }
return single.cellX !== undefined && single.cellY !== undefined && single.cellX === playerCell.x && single.cellY === playerCell.y
}
/**
* Check if player's sub-tile coordinates fall inside any cell covered by a roof.
* In Diablo II, 1 cell = 5x5 sub-tiles (subTilesPerTile = 5).
*
* @param playerSubTile - Player sub-tile coordinates { x, y }.
* @param roofs - Roof drawable(s) with cellX/cellY.
* @param subTilesPerTile - Sub-tiles per cell edge (default: 5).
*/
export function isPlayerInRoofSubTile(
playerSubTile: Point2D,
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
subTilesPerTile = 5,
): boolean {
const cellX = Math.floor(playerSubTile.x / subTilesPerTile)
const cellY = Math.floor(playerSubTile.y / subTilesPerTile)
return isPlayerInRoofCell({ x: cellX, y: cellY }, roofs)
}
/**
* Determine whether player is under a specific roof tile, checking bounding box
* overlap OR cell/sub-tile match.
*
* @param player - Player coordinates or bounding box.
* @param roof - Roof drawable.
* @param playerCell - Optional player cell coordinates.
* @param config - Optional configuration.
*/
export function isPlayerUnderRoof(
player: PlayerTarget,
roof: RoofDrawable,
playerCell?: Point2D,
config?: RoofFadeConfig,
): boolean {
if (playerCell && roof.cellX !== undefined && roof.cellY !== undefined) {
if (roof.cellX === playerCell.x && roof.cellY === playerCell.y) {
return true
}
}
return isPlayerOverlappingRoofBounds(
player,
roof,
config?.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
config?.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
)
}
/**
* Step an opacity value towards targetAlpha given elapsed time deltaMs and duration.
*
* @param currentAlpha - Current alpha (0.0 .. 1.0).
* @param targetAlpha - Target alpha (0.0 .. 1.0).
* @param deltaMs - Elapsed wall-clock milliseconds.
* @param durationMs - Transition duration in ms (default: 200).
* @param minAlpha - Minimum allowed alpha (default: 0.25).
* @param maxAlpha - Maximum allowed alpha (default: 1.0).
*/
export function stepRoofAlpha(
currentAlpha: number,
targetAlpha: number,
deltaMs: number,
durationMs = DEFAULT_ROOF_FADE_DURATION_MS,
minAlpha = DEFAULT_MIN_ROOF_ALPHA,
maxAlpha = DEFAULT_MAX_ROOF_ALPHA,
): number {
if (durationMs <= 0) return targetAlpha
if (deltaMs <= 0) return currentAlpha
const speed = (maxAlpha - minAlpha) / durationMs
const change = speed * deltaMs
if (currentAlpha < targetAlpha) {
return Math.min(targetAlpha, currentAlpha + change)
}
if (currentAlpha > targetAlpha) {
return Math.max(targetAlpha, currentAlpha - change)
}
return currentAlpha
}
/** Represents a clustered shelter composed of one or more adjacent roof tiles. */
export interface RoofCluster {
readonly id: number
readonly roofs: readonly RoofDrawable[]
readonly roofIndices: readonly number[]
readonly bounds: RoofBounds
readonly cells: ReadonlySet<string>
/** Current opacity alpha (0.0 .. 1.0). */
alpha: number
/** Target opacity alpha (0.0 .. 1.0). */
targetAlpha: number
/** Whether the player was detected under this cluster on the latest update. */
isPlayerUnder: boolean
}
/**
* Group roof drawables into connected shelters (clusters).
*
* Roof tiles that share adjacent cells or have overlapping/nearby bounding boxes
* are grouped together so an entire building/tent roof fades in unison.
*
* @param roofs - Array of roof drawables.
* @param config - Clustering parameters.
*/
export function clusterRoofs(
roofs: readonly RoofDrawable[],
config: RoofFadeConfig = {},
): RoofCluster[] {
if (roofs.length === 0) return []
const cellDist = config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE
const pixelMargin = config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN
const n = roofs.length
const parent = new Int32Array(n)
for (let i = 0; i < n; i += 1) parent[i] = i
const find = (i: number): number => {
let root = i
while (parent[root] !== root) root = parent[root]!
let curr = i
while (curr !== root) {
const nxt = parent[curr]!
parent[curr] = root
curr = nxt
}
return root
}
const union = (i: number, j: number): void => {
const rootI = find(i)
const rootJ = find(j)
if (rootI !== rootJ) parent[rootI] = rootJ
}
const boundsList = roofs.map(getRoofBounds)
for (let i = 0; i < n; i += 1) {
const a = roofs[i]!
const bA = boundsList[i]!
const expandedA: RoofBounds = {
minX: bA.minX - pixelMargin,
minY: bA.minY - pixelMargin,
maxX: bA.maxX + pixelMargin,
maxY: bA.maxY + pixelMargin,
}
for (let j = i + 1; j < n; j += 1) {
const b = roofs[j]!
let connected = false
// 1. Cell adjacency
if (
a.cellX !== undefined && a.cellY !== undefined &&
b.cellX !== undefined && b.cellY !== undefined
) {
if (
Math.abs(a.cellX - b.cellX) <= cellDist &&
Math.abs(a.cellY - b.cellY) <= cellDist
) {
connected = true
}
}
// 2. Pixel bounding box proximity
if (!connected && boundsOverlap(expandedA, boundsList[j]!)) {
connected = true
}
if (connected) union(i, j)
}
}
const groups = new Map<number, number[]>()
for (let i = 0; i < n; i += 1) {
const root = find(i)
const existing = groups.get(root)
if (existing !== undefined) {
existing.push(i)
} else {
groups.set(root, [i])
}
}
const maxAlpha = config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA
const clusters: RoofCluster[] = []
let clusterId = 0
for (const indices of groups.values()) {
const clusterRoofsList: RoofDrawable[] = []
const cells = new Set<string>()
for (const idx of indices) {
const r = roofs[idx]!
clusterRoofsList.push(r)
if (r.cellX !== undefined && r.cellY !== undefined) {
cells.add(`${String(r.cellX)},${String(r.cellY)}`)
}
}
const bounds = getCombinedRoofBounds(clusterRoofsList)!
clusters.push({
id: clusterId,
roofs: clusterRoofsList,
roofIndices: indices,
bounds,
cells,
alpha: maxAlpha,
targetAlpha: maxAlpha,
isPlayerUnder: false,
})
clusterId += 1
}
return clusters
}
/**
* Controller managing roof opacity and dynamic roof fading for a scene.
*/
export class RoofFadeController {
readonly clusters: RoofCluster[]
readonly roofClusterMap: Int32Array
readonly config: Required<RoofFadeConfig>
constructor(
roofs: readonly RoofDrawable[],
config: RoofFadeConfig = {},
) {
this.config = {
minAlpha: config.minAlpha ?? DEFAULT_MIN_ROOF_ALPHA,
maxAlpha: config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA,
fadeDurationMs: config.fadeDurationMs ?? DEFAULT_ROOF_FADE_DURATION_MS,
playerPaddingX: config.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
playerHeight: config.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
clusterCellDistance: config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE,
clusterPixelMargin: config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN,
}
this.clusters = clusterRoofs(roofs, this.config)
this.roofClusterMap = new Int32Array(roofs.length)
for (let cIdx = 0; cIdx < this.clusters.length; cIdx += 1) {
const cluster = this.clusters[cIdx]!
for (const rIdx of cluster.roofIndices) {
this.roofClusterMap[rIdx] = cIdx
}
}
}
/**
* Update opacity for all roof clusters based on player position and elapsed time.
*
* @param player - Current player world position or bounding box.
* @param deltaMs - Elapsed wall-clock time in milliseconds.
* @param playerCell - Optional player cell { x, y } in tile grid.
*/
update(
player: PlayerTarget,
deltaMs: number,
playerCell?: Point2D,
): void {
const pBounds = getPlayerBounds(player, this.config.playerPaddingX, this.config.playerHeight)
for (let i = 0; i < this.clusters.length; i += 1) {
const cluster = this.clusters[i]!
let under = false
// 1. Check cell coverage
if (playerCell && cluster.cells.has(`${String(playerCell.x)},${String(playerCell.y)}`)) {
under = true
}
// 2. Check bounding box overlap if not already triggered by cell
if (!under && boundsOverlap(pBounds, cluster.bounds)) {
for (let r = 0; r < cluster.roofs.length; r += 1) {
if (isPlayerOverlappingRoofBounds(pBounds, cluster.roofs[r]!)) {
under = true
break
}
}
}
cluster.isPlayerUnder = under
cluster.targetAlpha = under ? this.config.minAlpha : this.config.maxAlpha
cluster.alpha = stepRoofAlpha(
cluster.alpha,
cluster.targetAlpha,
deltaMs,
this.config.fadeDurationMs,
this.config.minAlpha,
this.config.maxAlpha,
)
}
}
/**
* Get the current opacity alpha for a roof drawable by its index.
*
* @param roofIndex - Index of the roof in the original list passed to the constructor.
* @returns Opacity alpha in range [minAlpha, maxAlpha].
*/
getAlpha(roofIndex: number): number {
const clusterIdx = this.roofClusterMap[roofIndex]
if (clusterIdx === undefined || clusterIdx < 0) return this.config.maxAlpha
return this.clusters[clusterIdx]?.alpha ?? this.config.maxAlpha
}
/**
* Set alpha directly for all clusters (useful for testing or scene reset).
*/
setAlpha(alpha: number): void {
for (let i = 0; i < this.clusters.length; i += 1) {
this.clusters[i]!.alpha = alpha
this.clusters[i]!.targetAlpha = alpha
}
}
/**
* Whether the player is currently detected under any roof cluster.
*/
get isPlayerUnderAnyRoof(): boolean {
for (let i = 0; i < this.clusters.length; i += 1) {
if (this.clusters[i]!.isPlayerUnder) return true
}
return false
}
/**
* Lowest opacity alpha among all roof clusters (for global state/HUD reporting).
*/
get overallAlpha(): number {
if (this.clusters.length === 0) return this.config.maxAlpha
let min = 1.0
for (let i = 0; i < this.clusters.length; i += 1) {
if (this.clusters[i]!.alpha < min) min = this.clusters[i]!.alpha
}
return min
}
}

View File

@ -31,7 +31,9 @@ import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
} from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.ts'
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
import { updateAnimatableTiles } from '../game/animated-tiles.ts'
import type { AnimatableTile, AnimatedFrame } from '../game/animated-tiles.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import { spawnMonsterPacks, spawnMonsters } from '../game/combat.ts'
import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
@ -46,6 +48,7 @@ import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
import { RoofFadeController } from '../render/roofs.ts'
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
@ -56,6 +59,7 @@ import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-link
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts'
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
import { Minimap } from '../ui/minimap.ts'
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
@ -128,7 +132,11 @@ export interface ActSceneState {
source: 'pack' | 'live' | ''
act: number
level: string
variant: string
variants: string[]
/** @deprecated Use `variant` instead. */
quadrant: string
/** @deprecated Use `variants` instead. */
quadrants: string[]
/** The archive/pack location actually used. */
base: string
@ -154,6 +162,8 @@ export interface ActSceneState {
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
roofAlpha: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
@ -171,6 +181,9 @@ export interface ActSceneState {
facing: number
tick: number
tickRate: number
simTps: number
renderFps: number
fps: number
/**
* GPU draw calls the last frame actually issued.
*
@ -193,6 +206,8 @@ export interface ActSceneState {
dialog: string[]
npcs: number
npcsNear: NpcEntity[]
/** Count of animated tiles in the active map. */
animatedTiles: number
}
declare global {
@ -203,27 +218,28 @@ declare global {
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0,
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [],
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
}
window.__d2webAct = state
if (typeof window !== 'undefined') window.__d2webAct = state
/** One tile to draw. */
interface Drawable {
readonly frame: AtlasFrame
interface Drawable extends AnimatableTile {
frame: AtlasFrame
readonly x: number
readonly y: number
readonly cellX: number
readonly cellY: number
/** Atlas page index this frame lives on. */
readonly page: number
page: number
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
}
/** One object sprite to draw. */
@ -257,15 +273,14 @@ interface MapRuntime {
readonly warps: readonly SceneWarp[]
/** The waypoint pedestal, when this level has one. */
readonly waypoints: readonly SceneWaypoint[]
readonly variants: readonly string[]
readonly variantParam: 'level' | 'variant'
readonly variant: string
/** @deprecated Kept for backward compatibility. Use `variants` instead. */
readonly quadrants: readonly string[]
/**
* Which query parameter names the currently shown map block.
*
* The pack path selects a prebaked map by its entry label (`?level=`), the live
* path selects a DS1 file inside one level (`?quadrant=`). Crossing the two
* silently falls back to the act's town, so each runtime declares its own.
*/
/** @deprecated Kept for backward compatibility. Use `variantParam` instead. */
readonly quadrantParam: 'level' | 'quadrant'
/** @deprecated Kept for backward compatibility. Use `variant` instead. */
readonly quadrant: string
readonly cellsX: number
readonly cellsY: number
@ -276,6 +291,8 @@ interface MapRuntime {
readonly walls: readonly Drawable[]
/** Roof draws: painted after the character, so a roof covers what walks under it. */
readonly roofs: readonly Drawable[]
/** Animated tile drawables whose frames cycle during rendering. */
readonly animatedDrawables?: readonly Drawable[] | undefined
/** Object draws: interleaved with walls in painter's order. */
readonly objectDrawables: readonly ObjectDrawable[]
readonly spawn: { x: number; y: number }
@ -501,7 +518,8 @@ async function loadPackRuntime(
? undefined
: forAct.find(candidate => candidate.label === wantedLevel)
?? forAct.find(candidate => candidate.slug === wantedLevel))
?? forAct.find(candidate => candidate.slug === 'town')
?? getDefaultTownVariant(forAct, act)
?? forAct.find(candidate => candidate.slug.includes('town'))
?? forAct[0]!
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
@ -509,16 +527,18 @@ async function loadPackRuntime(
/**
* Build the runtime for one level, named by id rather than by label.
*
* Generated levels have three baked variants and the world graph names none of
* them, so one is picked here. Which one does not matter for connectivity: the
* act layout solve gives all three the same seams on the same edges, so any
* variant docks with any neighbour. What does matter is that the choice depends
* only on the level, so that the same level is the same map every time it is
* entered — see {@link WORLD_VARIANT_SEED}.
* When a level has multiple baked variants (such as Rogue Encampment TownN1/E1/S1/W1,
* Lut Gholein LutN/LutW, or Cloister Courtyard N/E/W), the variant is selected based
* on the connecting border orientation (`fromSide`) so that the gate and road
* seamlessly align across the level boundary. If no directional requirement exists,
* it picks deterministically via `variantSeed`.
*
* @param packBase - directory holding `index.json`.
* @param levelId - `Levels.txt` `Id` of the wanted level.
* @param variantSeed - picks between the variants deterministically.
* @param viewport - current viewport dimensions.
* @param charBases - character assets bases.
* @param fromSide - the compass edge of the source level the player walked off of.
* @returns the runtime, or null when the pack has no such level.
*/
async function loadRuntimeForLevel(
@ -527,11 +547,14 @@ async function loadRuntimeForLevel(
variantSeed: number,
viewport: { width: number; height: number },
charBases: readonly string[],
fromSide?: Side,
): Promise<MapRuntime | null> {
const index = await getPackIndex(packBase)
if (index === null) return null
const candidates = index.levels.filter(entry => entry.levelId === levelId)
const entry = candidates[Math.abs(variantSeed) % Math.max(1, candidates.length)]
if (candidates.length === 0) return null
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
const entry = findMatchingVariant(candidates, requiredSide, variantSeed)
if (entry === undefined) return null
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
}
@ -559,6 +582,13 @@ async function buildPackRuntime(
}))
const toDrawable = (row: readonly number[]): Drawable => {
const frameIndex = row[0] ?? 0
const animFrames = row.length > 5 ? row.slice(5) : undefined
const anim = animFrames && animFrames.length > 1
? animFrames.map(fIdx => ({
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
page: scene.framePlacement[fIdx]?.[0] ?? 0,
}))
: undefined
return {
frame: frames[frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row[1] ?? 0,
@ -566,6 +596,7 @@ async function buildPackRuntime(
cellX: row[3] ?? 0,
cellY: row[4] ?? 0,
page: scene.framePlacement[frameIndex]?.[0] ?? 0,
...(anim ? { animatedFrames: anim } : {}),
}
}
const floors = scene.floors.map(toDrawable)
@ -677,6 +708,9 @@ async function buildPackRuntime(
entrances: scene.entrances ?? [],
warps: scene.warps ?? [],
waypoints: scene.waypoints ?? [],
variants: forAct.map(candidate => candidate.label),
variantParam: 'level',
variant: entry.label,
quadrants: forAct.map(candidate => candidate.label),
quadrantParam: 'level',
quadrant: entry.label,
@ -688,6 +722,9 @@ async function buildPackRuntime(
floors,
walls,
roofs,
animatedDrawables: [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
),
objectDrawables,
spawn,
npcs: scene.npcs ?? [],
@ -818,9 +855,19 @@ async function loadLiveRuntime(
const town: LevelInfo = wantedLevel === ''
? resolveActTown(tables, act)
: resolveLevel(tables, Number(wantedLevel), act)
const quadrants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
const wanted = wantedQuadrant === '' ? (quadrants[0] ?? '') : wantedQuadrant
const index = Math.max(0, quadrants.indexOf(wanted))
const wantedVariant = wantedQuadrant || param('variant', '')
const variants = town.ds1Names.map(name => name.split('\\').pop() ?? name)
let index = 0
if (wantedVariant !== '') {
const found = variants.indexOf(wantedVariant)
if (found !== -1) index = found
} else if (act === 1) {
const s1 = variants.findIndex(v => v.toLowerCase().includes('towns1'))
if (s1 !== -1) index = s1
} else if (act === 2) {
const n = variants.findIndex(v => v.toLowerCase().includes('lutn'))
if (n !== -1) index = n
}
const level = decodeDs1(await archives.read(town.ds1Names[index]!))
const libraries = []
@ -830,14 +877,29 @@ async function loadLiveRuntime(
const scene = buildIsoMapScene(level, libraries, levelSeed(town.ds1Names[index] ?? town.levelName))
const atlas = buildAtlasThatFits(renderer, scene.sheet, palette)
const frames = atlas.groups[0] ?? []
const toDrawable = (row: { frameIndex: number; x: number; y: number; cellX: number; cellY: number }): Drawable => ({
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row.x,
y: row.y,
cellX: row.cellX,
cellY: row.cellY,
page: 0,
})
const toDrawable = (row: IsoDraw): Drawable => {
const anim = row.animatedFrames && row.animatedFrames.length > 1
? row.animatedFrames.map(fIdx => ({
frame: frames[fIdx] ?? { x: 0, y: 0, width: 1, height: 1 },
page: 0,
}))
: undefined
return {
frame: frames[row.frameIndex] ?? { x: 0, y: 0, width: 1, height: 1 },
x: row.x,
y: row.y,
cellX: row.cellX,
cellY: row.cellY,
page: 0,
...(anim ? { animatedFrames: anim } : {}),
}
}
const floors = scene.floors.map(toDrawable)
const walls = scene.walls.map(toDrawable)
const roofs = scene.roofs.map(toDrawable)
const animatedDrawables = [...floors, ...walls, ...roofs].filter(
d => d.animatedFrames && d.animatedFrames.length > 1
)
// Plan the level's population from its own `Levels.txt` row. The seed is the
// level's, so the same map always comes back with the same monsters in the
// same camps — which is what makes a save reloadable.
@ -867,17 +929,21 @@ async function loadLiveRuntime(
entrances: [],
warps: [],
waypoints: [],
quadrants,
variants,
variantParam: 'variant',
variant: variants[index] ?? '',
quadrants: variants,
quadrantParam: 'quadrant',
quadrant: quadrants[index] ?? '',
quadrant: variants[index] ?? '',
cellsX: scene.cellsX,
cellsY: scene.cellsY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
grid: scene,
floors: scene.floors.map(toDrawable),
walls: scene.walls.map(toDrawable),
roofs: scene.roofs.map(toDrawable),
floors,
walls,
roofs,
animatedDrawables,
objectDrawables: [],
spawn: findIsoSpawn(scene) ?? { x: scene.widthPx / 2, y: scene.heightPx / 2 },
npcs: [],
@ -919,6 +985,21 @@ function buildAtlasThatFits(renderer: SpriteRenderer, sheet: SpriteSheet, palett
throw lastError instanceof Error ? lastError : new Error('no atlas size the renderer accepts')
}
/**
* Count non-null loaded atlas pages without allocating throwaway arrays.
*
* @param pages - array of page handles.
* @returns number of loaded pages.
*/
export function countLoadedPages(pages: readonly (AtlasHandle | null)[]): number {
let loaded = 0
for (let i = 0; i < pages.length; i += 1) {
const page = pages[i]
if (page !== null && page !== undefined) loaded += 1
}
return loaded
}
/**
* Fill in the remaining pages without blocking the first frames.
*
@ -929,7 +1010,7 @@ function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void {
const rest = runtime.loadOrder.slice(1)
if (rest.length === 0) return
void runtime.loadPages(renderer, rest, () => {
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
state.pagesLoaded = countLoadedPages(runtime.pages)
}).catch((err: unknown) => {
console.warn(`page preload: ${(err as Error).message}`)
})
@ -1300,15 +1381,12 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
let wallBounds = buildBounds(runtime.walls)
let roofBounds = buildBounds(runtime.roofs)
let objectBounds = buildBounds(runtime.objectDrawables)
let roofController = new RoofFadeController(runtime.roofs)
// `runtime.pages` is filled in by background loaders, so the count has to be
// re-read every frame. A loop is used rather than `filter().length` because
// the latter allocates a throwaway array 60 times a second.
const countPages = (): number => {
let loaded = 0
for (const page of runtime.pages) if (page !== null && page !== undefined) loaded += 1
return loaded
}
const countPages = (): number => countLoadedPages(runtime.pages)
// NPC name plates. The container and the elements outlive a frame so the
// render loop only has to move them.
@ -1462,6 +1540,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
wallBounds = buildBounds(next.walls)
roofBounds = buildBounds(next.roofs)
objectBounds = buildBounds(next.objectDrawables)
roofController = new RoofFadeController(next.roofs)
camera.mapWidthPx = next.widthPx
camera.mapHeightPx = next.heightPx
@ -1535,17 +1614,22 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
}
if (next.source === 'pack') {
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.quadrant)}`)
void populatePackSelectors(next.base, { act: next.act, label: next.quadrant })
history.replaceState(null, '', `?act=${String(next.act)}&level=${encodeURIComponent(next.variant)}`)
void populatePackSelectors(next.base, { act: next.act, label: next.variant })
}
registerWaypoints(next)
state.level = next.level
state.act = next.act
state.variant = next.variant
state.variants = [...next.variants]
state.quadrant = next.variant
state.quadrants = [...next.variants]
state.cellsX = next.cellsX
state.cellsY = next.cellsY
state.pagesTotal = next.pages.length
state.pagesLoaded = 0
state.animatedTiles = next.animatedDrawables?.length ?? 0
}
/**
@ -1558,11 +1642,13 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
* @param toLevelId - the level to move to.
* @param fadeMs - how long to black out for.
* @param override - an explicit landing sub-tile, for waypoints and portals.
* @param fromSide - the compass edge left by the player on the source level.
*/
const travel = async (
toLevelId: number,
fadeMs: number,
override?: { x: number; y: number },
fromSide?: Side,
): Promise<void> => {
if (travelling || runtime.source !== 'pack') return
travelling = true
@ -1576,13 +1662,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
WORLD_VARIANT_SEED + toLevelId,
{ width: canvas.width, height: canvas.height },
runtime.charBases,
fromSide,
)
if (next === null) {
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
return
}
await next.loadPages(renderer, next.priorityPages, () => {
state.pagesLoaded = next.pages.filter(page => page !== null).length
state.pagesLoaded = countLoadedPages(next.pages)
})
// The destination's own opening back to where we came from is the landing
// spot; an override wins, because a waypoint or a portal lands somewhere
@ -1622,7 +1709,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
for (const entrance of runtime.entrances) {
if (!near(entrance.x, entrance.y, SEAM_TRIGGER_SUBTILES)) continue
void travel(entrance.toLevelId, SEAM_FADE_MS)
void travel(entrance.toLevelId, SEAM_FADE_MS, undefined, entrance.side)
return
}
@ -1751,8 +1838,11 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
dialogPanel.textContent = state.dialog.join('\n')
}
},
onRender: () => {
onRender: (_alpha: number, frameMs: number) => {
const renderStarted = performance.now()
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
}
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = countPages()
state.firstFrameMs = Math.round(performance.now() - started)
@ -1775,8 +1865,25 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
const drawRoofs = (list: readonly Drawable[], bounds: Float32Array): void => {
for (let index = 0; index < list.length; index += 1) {
if (!visible(bounds, index)) { culledDraws += 1; continue }
const draw = list[index]!
const page = runtime.pages[draw.page]
if (page === null || page === undefined) { skippedDraws += 1; continue }
const alpha = roofController.getAlpha(index)
if (alpha < 0.999) {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, alpha] })
} else {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
}
drawTiles(runtime.floors, floorBounds)
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
const deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16
roofController.update(engine.world.player, deltaMs, cell)
state.roofAlpha = roofController.overallAlpha
const playerDepth = cell.x + cell.y
let wallIdx = 0
@ -1915,7 +2022,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
drawUpToDepth(Infinity)
// Roofs last, in their own pass: the engine paints them after every other
// layer so they cover the floor, the walls and anything walking under them.
drawTiles(runtime.roofs, roofBounds)
drawRoofs(runtime.roofs, roofBounds)
// Last in the batch, so the panel sits over every world layer.
minimap.draw(
renderer,
@ -1961,6 +2068,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
state.quadsDrawn = renderer.quadsSubmitted
state.culledDraws = culledDraws
state.tickRate = loop.tickRate
state.simTps = loop.simTps
state.renderFps = loop.fps
state.fps = loop.fps
state.zoom = Number(camera.zoom.toFixed(2))
state.canvasWidth = canvas.width
state.canvasHeight = canvas.height
@ -1973,11 +2083,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
// reader and stops the overlay from competing with the frame it describes.
if (renderStarted >= hudNextMs) {
hudNextMs = renderStarted + HUD_INTERVAL_MS
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
const simTpsText = loop.simTps.toFixed(1)
const renderFpsText = loop.fps.toFixed(0)
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms`
+ ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
}
},
@ -2107,6 +2220,8 @@ function publish(runtime: MapRuntime, base: string): void {
state.base = base
state.act = runtime.act
state.level = runtime.level
state.variant = runtime.variant
state.variants = [...runtime.variants]
state.quadrant = runtime.quadrant
state.quadrants = [...runtime.quadrants]
state.cellsX = runtime.cellsX
@ -2117,10 +2232,12 @@ function publish(runtime: MapRuntime, base: string): void {
state.walls = runtime.walls.length
state.frames = runtime.frames
state.pagesTotal = runtime.pages.length
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
state.pagesLoaded = countLoadedPages(runtime.pages)
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}
@ -2260,18 +2377,19 @@ function populateLiveSelectors(runtime: MapRuntime): void {
option.selected = true
sceneSelect.replaceChildren(option)
sceneSelect.disabled = false
variantSelect.replaceChildren(...runtime.quadrants.map(name => {
variantSelect.replaceChildren(...runtime.variants.map(name => {
const item = document.createElement('option')
item.value = name
item.textContent = name
item.selected = name === runtime.quadrant
item.selected = name === runtime.variant
return item
}))
variantSelect.disabled = runtime.quadrants.length === 0
variantSelect.disabled = runtime.variants.length === 0
actSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', actSelect.value)
params.delete('level')
params.delete('variant')
params.delete('quadrant')
location.search = params.toString()
}
@ -2279,8 +2397,9 @@ function populateLiveSelectors(runtime: MapRuntime): void {
variantSelect.onchange = () => {
const params = new URLSearchParams(location.search)
params.set('act', String(runtime.act))
params.set(runtime.quadrantParam, variantSelect.value)
params.delete(runtime.quadrantParam === 'level' ? 'quadrant' : 'level')
params.set(runtime.variantParam, variantSelect.value)
params.delete(runtime.variantParam === 'level' ? 'variant' : 'level')
params.delete('quadrant')
location.search = params.toString()
}
}
@ -2323,7 +2442,7 @@ async function boot(): Promise<void> {
const runtime = await loadPackRuntime(packBase, act, param('level', ''), viewport, charBases)
if (runtime === null) continue
publish(runtime, packBase)
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant })
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.variant })
const renderer = new SpriteRenderer(canvas, {
onContextLost: () => {
status.textContent = 'WebGL 上下文丢失,正在尝试恢复…'
@ -2335,7 +2454,7 @@ async function boot(): Promise<void> {
})
// The first frame waits only for the spawn-area pages.
await runtime.loadPages(renderer, runtime.priorityPages, () => {
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
state.pagesLoaded = countLoadedPages(runtime.pages)
})
runScene(runtime, renderer, started)
return
@ -2357,7 +2476,8 @@ async function boot(): Promise<void> {
location.reload()
},
})
const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', ''))
const requestedVariant = param('variant', '') || param('quadrant', '')
const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant)
publish(runtime, base)
runScene(runtime, renderer, started)
return
@ -2376,4 +2496,4 @@ async function boot(): Promise<void> {
}
}
void boot()
if (typeof document !== 'undefined') void boot()

View File

@ -4,10 +4,15 @@
* Diablo's simulation runs at 25 ticks per second, and that rate is not
* cosmetic: animation frames, movement, and monster AI all advance per tick,
* so anything derived from wall-clock time would diverge from the original
* behaviour. The loop therefore ticks at a fixed rate and lets rendering happen
* whenever the browser paints, interpolating only the camera-facing positions
* the caller asks to smooth.
* behaviour.
*
* To avoid tying simulation progress to rendering performance (where frame drops
* or heavy scenes would artificially drag down simulation rate or catastrophically
* drop backlog time), simulation ticks are driven by a dedicated timer mechanism
* at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame
* handles rendering and frame metrics with proper interpolation alpha.
*/
/** Loop wiring. */
export interface LoopOptions {
/** Ticks per second (Diablo's simulation rate: 25). */
@ -22,25 +27,39 @@ export interface LoopOptions {
* @param frameMs - wall-clock duration of the previous frame.
*/
readonly onRender: (alpha: number, frameMs: number) => void
/** Most ticks to run in one frame before discarding the backlog. */
/** Most ticks to run in one timer/frame step before clamping the backlog. */
readonly maxCatchUp?: number
/** Optional custom clock source for testing. Defaults to performance.now. */
readonly clock?: () => number
}
/**
* A fixed-rate loop driven by `requestAnimationFrame`.
* A fixed-rate simulation loop decoupled from rendering.
*/
export class GameLoop {
private readonly options: LoopOptions
private readonly tickMs: number
private readonly maxCatchUp: number
private accumulator = 0
private lastTime = 0
private handle = 0
private running = false
readonly tickMs: number
readonly maxCatchUp: number
private rawAccumulator = 0
private consumed = 0
private rateWindowStart = 0
private rateWindowTicks = 0
private measuredRate = 0
private running = false
// Simulation timer & metrics
private simTimerHandle: ReturnType<typeof setTimeout> | null = null
private lastSimTime = 0
private simWindowStart = 0
private simTicksInWindow = 0
private simVirtualElapsed = 0
private measuredSimRate = 0
// Render loop & metrics
private renderHandle = 0
private lastRenderTime = 0
private lastFrameMs = 0
private renderWindowStart = 0
private renderFramesInWindow = 0
private measuredFps = 0
/**
* @param options - loop wiring.
@ -48,7 +67,12 @@ export class GameLoop {
constructor(options: LoopOptions) {
this.options = options
this.tickMs = 1000 / options.tickRate
this.maxCatchUp = options.maxCatchUp ?? 5
this.maxCatchUp = options.maxCatchUp ?? 10
}
/** Current time in ms from options.clock or performance.now. */
private now(): number {
return this.options.clock ? this.options.clock() : performance.now()
}
/** Ticks simulated since the loop started. */
@ -56,49 +80,239 @@ export class GameLoop {
return this.consumed
}
/** Measured ticks per second over the last window (0 until one has elapsed). */
/** Measured simulation ticks per second over the last window (or target rate when active). */
get tickRate(): number {
return this.measuredRate
return this.simTps
}
/** Begin ticking. Idempotent. */
/** Simulation ticks per second. */
get simTps(): number {
if (this.measuredSimRate > 0) return this.measuredSimRate
const elapsed = this.now() - this.simWindowStart
if (elapsed >= 200 && this.simTicksInWindow > 0) {
return (this.simTicksInWindow * 1000) / elapsed
}
if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) {
return (this.simTicksInWindow * 1000) / this.simVirtualElapsed
}
return this.running ? this.options.tickRate : 0
}
/** Measured render frames per second. */
get fps(): number {
return this.renderFps
}
/** Render frames per second. */
get renderFps(): number {
if (this.measuredFps > 0) return this.measuredFps
const elapsed = this.now() - this.renderWindowStart
if (elapsed >= 200 && this.renderFramesInWindow > 0) {
return (this.renderFramesInWindow * 1000) / elapsed
}
return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0
}
/** Duration of the last render frame in milliseconds. */
get frameMs(): number {
return this.lastFrameMs
}
/** Whether the loop is currently running. */
get isRunning(): boolean {
return this.running
}
/** Current simulation accumulator in milliseconds. */
get accumulator(): number {
const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0
return this.rawAccumulator + elapsedSinceLastSim
}
/** Interpolation factor in 0..1 between the current tick and the next tick. */
get alpha(): number {
return Math.min(1, Math.max(0, this.accumulator / this.tickMs))
}
/**
* Manually advance the simulation by deltaMs milliseconds.
* Useful for headless simulation, test clocks, and deterministic testing.
*
* @param deltaMs - Milliseconds to advance.
* @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited).
*/
step(deltaMs: number, maxCatchUp?: number): void {
if (deltaMs <= 0) return
this.rawAccumulator += deltaMs
this.lastSimTime = this.now()
const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0)
let steps = 0
while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) {
this.rawAccumulator -= this.tickMs
this.consumed += 1
this.simTicksInWindow += 1
this.options.onTick(this.consumed)
steps += 1
}
if (maxSteps > 0) {
const maxBacklog = this.tickMs * maxSteps
if (this.rawAccumulator > maxBacklog) {
this.rawAccumulator = maxBacklog
}
}
this.simVirtualElapsed += deltaMs
if (this.simVirtualElapsed >= 1000) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
this.simVirtualElapsed = 0
this.simTicksInWindow = 0
} else if (this.simVirtualElapsed >= 200) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
}
}
/**
* Manually trigger one render call.
*/
render(frameMs?: number): void {
const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6)
this.lastFrameMs = elapsed
this.options.onRender(this.alpha, elapsed)
}
/** Advance simulation according to elapsed wall-clock time. */
private advanceSimulation(now: number): void {
const elapsed = now - this.lastSimTime
if (elapsed <= 0) return
this.lastSimTime = now
this.rawAccumulator += elapsed
let steps = 0
while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) {
this.rawAccumulator -= this.tickMs
this.consumed += 1
this.simTicksInWindow += 1
this.options.onTick(this.consumed)
steps += 1
}
// Prevent catastrophic discard: clamp to max backlog instead of wiping to 0
const maxBacklog = this.tickMs * this.maxCatchUp
if (this.rawAccumulator > maxBacklog) {
this.rawAccumulator = maxBacklog
}
const windowElapsed = now - this.simWindowStart
if (windowElapsed >= 1000) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
this.simWindowStart = now
this.simTicksInWindow = 0
} else if (this.measuredSimRate === 0 && windowElapsed >= 200) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
}
}
/** Begin ticking and rendering. Idempotent. */
start(): void {
if (this.running) return
this.running = true
this.lastTime = performance.now()
this.rateWindowStart = this.lastTime
this.rateWindowTicks = 0
const step = (now: number): void => {
if (!this.running) return
const elapsed = now - this.lastTime
this.lastTime = now
this.accumulator += elapsed
let steps = 0
while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) {
this.accumulator -= this.tickMs
this.consumed += 1
this.rateWindowTicks += 1
this.options.onTick(this.consumed)
steps += 1
}
// A long stall (a backgrounded tab, a slow load) must not replay minutes
// of simulation on the next frame: drop the backlog instead.
if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0
if (now - this.rateWindowStart >= 1000) {
this.measuredRate = (this.rateWindowTicks * 1000) / (now - this.rateWindowStart)
this.rateWindowStart = now
this.rateWindowTicks = 0
}
this.options.onRender(this.accumulator / this.tickMs, elapsed)
this.handle = requestAnimationFrame(step)
}
this.handle = requestAnimationFrame(step)
const now = this.now()
this.lastSimTime = now
this.lastRenderTime = now
this.simWindowStart = now
this.renderWindowStart = now
this.simTicksInWindow = 0
this.renderFramesInWindow = 0
this.startSimTimer()
this.startRenderLoop()
}
/** Stop ticking. Idempotent. */
/** Start the self-correcting timer loop for simulation ticks. */
private startSimTimer(): void {
if (this.simTimerHandle !== null) return
let nextTick = this.now() + this.tickMs
const simStep = (): void => {
if (!this.running) return
const now = this.now()
this.advanceSimulation(now)
// Self-correct next tick timestamp to eliminate drift
nextTick += this.tickMs
if (nextTick < now - this.tickMs * this.maxCatchUp) {
nextTick = now + this.tickMs
} else {
while (nextTick <= now) {
nextTick += this.tickMs
}
}
const delay = Math.max(0, nextTick - this.now())
this.simTimerHandle = setTimeout(simStep, delay)
}
this.simTimerHandle = setTimeout(simStep, this.tickMs)
}
/** Start requestAnimationFrame render loop. */
private startRenderLoop(): void {
if (this.renderHandle !== 0) return
const renderStep = (): void => {
if (!this.running) return
const now = this.now()
const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6
this.lastRenderTime = now
this.lastFrameMs = frameMs
this.renderFramesInWindow += 1
const renderWindowElapsed = now - this.renderWindowStart
if (renderWindowElapsed >= 1000) {
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
this.renderWindowStart = now
this.renderFramesInWindow = 0
} else if (this.measuredFps === 0 && renderWindowElapsed >= 200) {
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
}
this.options.onRender(this.alpha, frameMs)
if (this.running) {
this.renderHandle = this.requestFrame(renderStep)
}
}
this.renderHandle = this.requestFrame(renderStep)
}
private requestFrame(cb: (time: number) => void): number {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame(cb)
}
return setTimeout(() => cb(this.now()), 16) as unknown as number
}
private cancelFrame(handle: number): void {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(handle)
return
}
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
}
/** Stop ticking and rendering. Idempotent. */
stop(): void {
this.running = false
if (this.handle !== 0) cancelAnimationFrame(this.handle)
this.handle = 0
if (this.simTimerHandle !== null) {
clearTimeout(this.simTimerHandle)
this.simTimerHandle = null
}
if (this.renderHandle !== 0) {
this.cancelFrame(this.renderHandle)
this.renderHandle = 0
}
}
}

View File

@ -0,0 +1,303 @@
import { describe, expect, test } from 'vitest'
import {
advanceFrameIndex,
computeFrameIndexFromTick,
computeFrameIndexFromTime,
DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
DEFAULT_TICKS_PER_ANIMATED_FRAME,
updateAnimatableTile,
updateAnimatableTiles,
} from '../src/game/animated-tiles.ts'
import type { AnimatableTile } from '../src/game/animated-tiles.ts'
import { decodeDt1, isAnimatedTile, subTileFlagsOf } from '../src/formats/dt1.ts'
import type { Dt1, Dt1Tile } from '../src/formats/dt1.ts'
import { buildIsoMapScene } from '../src/game/d2map.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
describe('Animated Tiles (Issue #43)', () => {
describe('DT1 Animated Tile Detection', () => {
test('isAnimatedTile correctly identifies animated flag byte', () => {
expect(isAnimatedTile({ animated: 0 })).toBe(false)
expect(isAnimatedTile({})).toBe(false)
expect(isAnimatedTile({ animated: 1 })).toBe(true)
expect(isAnimatedTile({ animated: 3 })).toBe(true)
expect(isAnimatedTile({ animated: 0x81 })).toBe(true)
})
test('decodeDt1 parses animated flag at offset +7', () => {
// Construct a minimal valid DT1 buffer with 1 tile
// DT1 header:
// 0..3: magic1 (7)
// 4..7: magic2 (6)
// 8..267: unused header bytes (260 bytes zeros)
// 268..271: tileCount (1)
// 272..275: tileOffset (276)
// Tile header at 276:
// +0: direction (i32: 1)
// +4: roofHeight (i16: 0)
// +6: materialFlags (u16: 0)
// +7: animated (u8: 1) <-- tested byte
// +8: height (i32: -80)
// +12: width (i32: 160)
// +16: unused (4 bytes)
// +20: type (i32: 0)
// +24: style (i32: 1)
// +28: sequence (i32: 2)
// +32: rarityFrameIndex (i32: 0)
// +36..+59: subTileFlags (20 bytes)
// +60..+79: unused (20 bytes)
// +80: blockHeaderPointer (i32: 0)
// +84: blockDataOffset (i32: 0)
// +88: blockCount (i32: 0)
// Total tile header size = 96 bytes.
const buffer = new Uint8Array(276 + 96)
const view = new DataView(buffer.buffer)
view.setInt32(0, 7, true)
view.setInt32(4, 6, true)
view.setInt32(268, 1, true)
view.setInt32(272, 276, true)
const tileAt = 276
view.setInt32(tileAt + 0, 1, true) // direction
view.setInt16(tileAt + 4, 0, true) // roofHeight
view.setUint16(tileAt + 6, 0, true) // materialFlags
buffer[tileAt + 7] = 1 // animated flag
view.setInt32(tileAt + 8, -80, true) // height
view.setInt32(tileAt + 12, 160, true) // width
view.setInt32(tileAt + 20, 0, true) // type
view.setInt32(tileAt + 24, 1, true) // style
view.setInt32(tileAt + 28, 2, true) // sequence
view.setInt32(tileAt + 32, 0, true) // rarityFrameIndex (frame 0)
const dt1 = decodeDt1(buffer)
expect(dt1.tiles.length).toBe(1)
const tile = dt1.tiles[0]!
expect(tile.animated).toBe(1)
expect(isAnimatedTile(tile)).toBe(true)
expect(tile.rarityFrameIndex).toBe(0)
})
})
describe('Frame Advancement & Timing', () => {
test('computeFrameIndexFromTime handles millisecond time advancement', () => {
const frameCount = 4
const duration = DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS // 100ms
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(199, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
expect(computeFrameIndexFromTime(350, frameCount, duration)).toBe(3)
})
test('computeFrameIndexFromTime supports custom duration', () => {
const frameCount = 5
const duration = 50 // 50ms per frame = 20 FPS
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(49, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(50, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(150, frameCount, duration)).toBe(3)
})
test('computeFrameIndexFromTime gracefully handles single-frame or invalid frame count', () => {
expect(computeFrameIndexFromTime(1000, 1)).toBe(0)
expect(computeFrameIndexFromTime(1000, 0)).toBe(0)
expect(computeFrameIndexFromTime(-500, 4)).toBe(0)
})
test('computeFrameIndexFromTick handles 25Hz game ticks', () => {
const frameCount = 4
const ticksPerFrame = DEFAULT_TICKS_PER_ANIMATED_FRAME // 2.5 ticks / frame
expect(computeFrameIndexFromTick(0, frameCount, ticksPerFrame)).toBe(0)
expect(computeFrameIndexFromTick(2, frameCount, ticksPerFrame)).toBe(0) // 2 / 2.5 = 0.8 -> 0
expect(computeFrameIndexFromTick(3, frameCount, ticksPerFrame)).toBe(1) // 3 / 2.5 = 1.2 -> 1
expect(computeFrameIndexFromTick(5, frameCount, ticksPerFrame)).toBe(2) // 5 / 2.5 = 2.0 -> 2
expect(computeFrameIndexFromTick(7, frameCount, ticksPerFrame)).toBe(2) // 7 / 2.5 = 2.8 -> 2
expect(computeFrameIndexFromTick(8, frameCount, ticksPerFrame)).toBe(3) // 8 / 2.5 = 3.2 -> 3
})
test('advanceFrameIndex manual frame increment and wrapping', () => {
expect(advanceFrameIndex(0, 4)).toBe(1)
expect(advanceFrameIndex(1, 4)).toBe(2)
expect(advanceFrameIndex(2, 4)).toBe(3)
expect(advanceFrameIndex(3, 4)).toBe(0) // Wrap!
expect(advanceFrameIndex(0, 4, 5)).toBe(1) // Advance 5 frames: 5 % 4 = 1
expect(advanceFrameIndex(2, 1)).toBe(0)
})
})
describe('Cycle Wrapping (Continuous Loop Playback)', () => {
test('time-based cycling loops indefinitely without drift', () => {
const frameCount = 3
const duration = 100 // ms
// Cycle 0: 0..299ms
expect(computeFrameIndexFromTime(0, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(100, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(200, frameCount, duration)).toBe(2)
// Cycle 1: 300..599ms
expect(computeFrameIndexFromTime(300, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(400, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(500, frameCount, duration)).toBe(2)
// Cycle 100: 30000ms
expect(computeFrameIndexFromTime(30000, frameCount, duration)).toBe(0)
expect(computeFrameIndexFromTime(30150, frameCount, duration)).toBe(1)
expect(computeFrameIndexFromTime(30250, frameCount, duration)).toBe(2)
})
test('tick-based cycling loops indefinitely', () => {
const frameCount = 4
const ticksPerFrame = 2.5 // total cycle = 10 ticks
// Cycle 0: ticks 0..9
expect(computeFrameIndexFromTick(0, frameCount, ticksPerFrame)).toBe(0)
expect(computeFrameIndexFromTick(3, frameCount, ticksPerFrame)).toBe(1)
expect(computeFrameIndexFromTick(5, frameCount, ticksPerFrame)).toBe(2)
expect(computeFrameIndexFromTick(8, frameCount, ticksPerFrame)).toBe(3)
// Cycle 1: ticks 10..19
expect(computeFrameIndexFromTick(10, frameCount, ticksPerFrame)).toBe(0)
expect(computeFrameIndexFromTick(13, frameCount, ticksPerFrame)).toBe(1)
expect(computeFrameIndexFromTick(15, frameCount, ticksPerFrame)).toBe(2)
expect(computeFrameIndexFromTick(18, frameCount, ticksPerFrame)).toBe(3)
})
})
describe('AnimatableTile Mutator Functions', () => {
test('updateAnimatableTile updates frame and page in-place', () => {
const frame0 = { x: 0, y: 0, width: 160, height: 80 }
const frame1 = { x: 160, y: 0, width: 160, height: 80 }
const frame2 = { x: 320, y: 0, width: 160, height: 80 }
const tile: AnimatableTile = {
frame: frame0,
page: 0,
animatedFrames: [
{ frame: frame0, page: 0 },
{ frame: frame1, page: 0 },
{ frame: frame2, page: 1 },
],
}
// At 0ms -> frame0, page 0
updateAnimatableTile(tile, 0, 'time', 100)
expect(tile.frame).toBe(frame0)
expect(tile.page).toBe(0)
// At 150ms -> frame1, page 0
updateAnimatableTile(tile, 150, 'time', 100)
expect(tile.frame).toBe(frame1)
expect(tile.page).toBe(0)
// At 250ms -> frame2, page 1
updateAnimatableTile(tile, 250, 'time', 100)
expect(tile.frame).toBe(frame2)
expect(tile.page).toBe(1)
// At 350ms -> wraps back to frame0, page 0
updateAnimatableTile(tile, 350, 'time', 100)
expect(tile.frame).toBe(frame0)
expect(tile.page).toBe(0)
})
test('updateAnimatableTiles updates an array of animatable tiles', () => {
const f0 = { x: 0, y: 0, width: 160, height: 80 }
const f1 = { x: 160, y: 0, width: 160, height: 80 }
const tileA: AnimatableTile = {
frame: f0,
page: 0,
animatedFrames: [{ frame: f0, page: 0 }, { frame: f1, page: 0 }],
}
const tileB: AnimatableTile = {
frame: f0,
page: 0,
animatedFrames: [{ frame: f0, page: 0 }, { frame: f1, page: 0 }],
}
const staticTile: AnimatableTile = {
frame: f0,
page: 0,
}
const list = [tileA, tileB, staticTile]
updateAnimatableTiles(list, 150, 'time', 100)
expect(tileA.frame).toBe(f1)
expect(tileB.frame).toBe(f1)
expect(staticTile.frame).toBe(f0) // Unchanged
})
})
describe('d2map buildIsoMapScene Animated Sequence Resolution', () => {
test('resolves multi-frame animated tiles into sequential animatedFrames', () => {
// Create 3 DT1 tiles with same style, sequence, type, with animated flag = 1
// and frame indices 0, 1, 2
const makeTile = (frameIndex: number): Dt1Tile => ({
direction: 1,
roofHeight: 0,
materialFlags: 0,
animated: 1,
height: -80,
width: 160,
type: 0, // floor
style: 5,
sequence: 1,
rarityFrameIndex: frameIndex,
subTileFlags: Array.from({ length: 25 }, () => subTileFlagsOf(0)),
blocks: [],
minBlockY: 0,
bitmapHeight: 80,
})
// Provide tiles out of order to ensure sorting by frameIndex works: [frame 2, frame 0, frame 1]
const dt1Library: Dt1 = {
versionMajor: 7,
versionMinor: 6,
tiles: [makeTile(2), makeTile(0), makeTile(1)],
warnings: [],
}
// Create a minimal 1x1 DS1 referencing style 5, sequence 1
const ds1: Ds1 = {
version: 18,
width: 1,
height: 1,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells: [[{
floors: [{ style: 5, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }],
walls: [],
shadows: [],
substitutions: [],
}]],
objects: [],
npcPathOffset: null,
}
const scene = buildIsoMapScene(ds1, [dt1Library])
expect(scene.animatedTileCount).toBe(1)
expect(scene.floors.length).toBe(1)
const floorDraw = scene.floors[0]!
// animatedFrames should exist and have 3 frames
expect(floorDraw.animatedFrames).toBeDefined()
expect(floorDraw.animatedFrames?.length).toBe(3)
// The base frame index should be the first frame (frameIndex 0)
const frameIndices = floorDraw.animatedFrames!
expect(frameIndices.length).toBe(3)
// All 3 frames should be distinct frame indices in scene.frames
expect(new Set(frameIndices).size).toBe(3)
// Base frame should be frame 0
expect(floorDraw.frameIndex).toBe(frameIndices[0])
})
})
})

231
tests/loop.test.ts Normal file
View File

@ -0,0 +1,231 @@
import { describe, it, expect, vi } from 'vitest'
import { GameLoop } from '../src/sim/loop.ts'
describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => {
it('1. Deterministic ticking at 25 tps in headless/step mode', () => {
let tickCount = 0
const ticks: number[] = []
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
onTick: (tick) => {
tickCount++
ticks.push(tick)
},
onRender: () => {}
})
expect(loop.tickMs).toBe(40)
expect(loop.tick).toBe(0)
// Advancing by less than tickMs does not produce a tick
loop.step(20)
expect(tickCount).toBe(0)
expect(loop.accumulator).toBe(20)
expect(loop.alpha).toBeCloseTo(0.5, 2)
// Advancing another 20ms completes 40ms -> exactly 1 tick
loop.step(20)
expect(tickCount).toBe(1)
expect(loop.tick).toBe(1)
expect(loop.accumulator).toBe(0)
expect(loop.alpha).toBe(0)
// Advancing by 80ms produces exactly 2 ticks
loop.step(80)
expect(tickCount).toBe(3)
expect(loop.tick).toBe(3)
expect(loop.accumulator).toBe(0)
// Advancing by 1000ms produces exactly 25 ticks
loop.step(1000)
expect(tickCount).toBe(28) // 3 + 25
expect(loop.tick).toBe(28)
expect(loop.tickRate).toBe(25.0)
expect(loop.simTps).toBe(25.0)
})
it('1b. Deterministic ticking at 25 tps with timers', async () => {
let tickCount = 0
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
onTick: () => {
tickCount++
},
onRender: () => {}
})
loop.start()
expect(loop.isRunning).toBe(true)
// Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms)
await new Promise(resolve => setTimeout(resolve, 260))
loop.stop()
expect(loop.isRunning).toBe(false)
expect(tickCount).toBeGreaterThanOrEqual(5)
expect(tickCount).toBeLessThanOrEqual(8)
})
it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => {
const world = { tick: 0 }
let renderCalls = 0
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
onTick: (tick) => {
world.tick = tick
},
onRender: () => {
renderCalls++
// Simulate heavy render frame stall of 50ms
const stallStart = performance.now()
while (performance.now() - stallStart < 50) {
// busy wait
}
}
})
loop.start()
// Run for ~220ms
// With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS)
// But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks
await new Promise(resolve => setTimeout(resolve, 220))
loop.stop()
expect(renderCalls).toBeLessThanOrEqual(4)
expect(world.tick).toBeGreaterThanOrEqual(5)
// Simulation TPS remains high despite slowed rendering
expect(loop.simTps).toBeGreaterThanOrEqual(20)
})
it('3. Accumulator recovery and no catastrophic drop on transient lag', () => {
let tickCount = 0
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
maxCatchUp: 5, // max 5 ticks (200ms) per step
onTick: () => {
tickCount++
},
onRender: () => {}
})
// Transient lag of 150ms (3.75 ticks)
loop.step(150)
// Consumed 3 ticks (120ms), 30ms remaining
expect(tickCount).toBe(3)
expect(loop.accumulator).toBe(30)
expect(loop.alpha).toBeCloseTo(30 / 40, 2)
// Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left
loop.step(20)
expect(tickCount).toBe(4)
expect(loop.accumulator).toBe(10)
expect(loop.alpha).toBeCloseTo(10 / 40, 2)
// Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms)
// Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0`
// which wiped out all remaining simulation time!
// New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0.
loop.step(500)
// 5 ticks executed in this step (tickCount 4 + 5 = 9)
expect(tickCount).toBe(9)
// Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0!
expect(loop.accumulator).toBe(200)
// Subsequent steps recover and consume the preserved backlog
loop.step(40)
// Consumes 5 ticks from the 200ms backlog + 40ms new time
expect(tickCount).toBe(14)
expect(loop.accumulator).toBe(40)
loop.step(40)
expect(tickCount).toBe(16)
expect(loop.accumulator).toBe(0)
})
it('4. Clean start / stop lifecycle and timer cleanup', async () => {
let tickCount = 0
let renderCount = 0
const loop = new GameLoop({
tickRate: 25,
onTick: () => {
tickCount++
},
onRender: () => {
renderCount++
}
})
expect(loop.isRunning).toBe(false)
// Start
loop.start()
expect(loop.isRunning).toBe(true)
// Calling start() again is idempotent
loop.start()
expect(loop.isRunning).toBe(true)
await new Promise(resolve => setTimeout(resolve, 60))
expect(tickCount).toBeGreaterThanOrEqual(1)
expect(renderCount).toBeGreaterThanOrEqual(1)
// Stop
loop.stop()
expect(loop.isRunning).toBe(false)
const stoppedTicks = tickCount
const stoppedRenders = renderCount
// Calling stop() again is idempotent
loop.stop()
expect(loop.isRunning).toBe(false)
// Wait and verify timers are cleaned up and no more callbacks fire
await new Promise(resolve => setTimeout(resolve, 100))
expect(tickCount).toBe(stoppedTicks)
expect(renderCount).toBe(stoppedRenders)
// Restart cleanly
loop.start()
expect(loop.isRunning).toBe(true)
await new Promise(resolve => setTimeout(resolve, 60))
expect(tickCount).toBeGreaterThan(stoppedTicks)
expect(renderCount).toBeGreaterThan(stoppedRenders)
loop.stop()
})
it('5. Interpolation alpha calculation and manual render stepping', () => {
let lastAlpha = -1
let lastFrameMs = -1
const loop = new GameLoop({
tickRate: 25, // 40ms
onTick: () => {},
onRender: (alpha, frameMs) => {
lastAlpha = alpha
lastFrameMs = frameMs
}
})
loop.render(16.6)
expect(lastAlpha).toBe(0)
expect(lastFrameMs).toBeCloseTo(16.6, 1)
loop.step(10) // accumulator = 10ms (10/40 = 0.25)
loop.render(16.6)
expect(lastAlpha).toBeCloseTo(0.25, 2)
loop.step(20) // accumulator = 30ms (30/40 = 0.75)
loop.render(16.6)
expect(lastAlpha).toBeCloseTo(0.75, 2)
loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms
loop.render(16.6)
expect(lastAlpha).toBe(0)
})
})

197
tests/maze-bounds.test.ts Normal file
View File

@ -0,0 +1,197 @@
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell } from '../src/formats/ds1'
import { generateMaze, type MazePiece, type MazePieceKind, type MazeRequest } from '../src/game/maze'
function makeMockDs1(width = 10, height = 10): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 0, 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: [],
npcPathOffset: null,
}
}
function makeMockPiece(name: string, kind: MazePieceKind, sides: string): MazePiece {
return {
name,
kind,
sides,
levels: [makeMockDs1(10, 10)],
}
}
function makeCavePieceSet(): MazePiece[] {
const ds1 = makeMockDs1(10, 10)
const tokens = [
'NSEW',
'NSE', 'NSW', 'NEW', 'SEW',
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = tokens.map(token => ({
name: `Act 1 - Cave ${token}`,
kind: 'room',
sides: token,
levels: [ds1],
}))
pieces.push({
name: 'Act 1 - Cave Prev NS',
kind: 'prev',
sides: 'NS',
levels: [ds1],
})
pieces.push({
name: 'Act 1 - Cave Prev EW',
kind: 'prev',
sides: 'EW',
levels: [ds1],
})
pieces.push({
name: 'Act 1 - Cave Down NS',
kind: 'down',
sides: 'NS',
levels: [ds1],
})
pieces.push({
name: 'Act 1 - Cave Down EW',
kind: 'down',
sides: 'EW',
levels: [ds1],
})
return pieces
}
describe('Maze Global Bounding Box Constraints (Issue #48)', () => {
test('generateMaze respects maxSectionsX and maxSectionsY limits', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 9,
merge: 0,
seed: 12345,
pieces,
maxSectionsX: 3,
maxSectionsY: 3,
}
const result = generateMaze(request)
expect(result.level.width).toBeLessThanOrEqual(30)
expect(result.level.height).toBeLessThanOrEqual(30)
expect(result.stats.maxCellsX).toBe(30)
expect(result.stats.maxCellsY).toBe(30)
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9)
})
test('generateMaze respects maxCellsX and maxCellsY directly', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 8,
merge: 500,
seed: 99999,
pieces,
maxCellsX: 30,
maxCellsY: 30,
}
const result = generateMaze(request)
expect(result.level.width).toBeLessThanOrEqual(30)
expect(result.level.height).toBeLessThanOrEqual(30)
expect(result.stats.maxCellsX).toBe(30)
expect(result.stats.maxCellsY).toBe(30)
})
test('forces maze to fold vertically when maxSectionsX is narrow (e.g. 2 sections wide)', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 8,
merge: 0,
seed: 42,
pieces,
maxSectionsX: 2,
maxSectionsY: 6,
}
const result = generateMaze(request)
expect(result.level.width).toBeLessThanOrEqual(20)
expect(result.level.height).toBeLessThanOrEqual(60)
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(8)
expect(Number(result.stats.boundsRejected)).toBeGreaterThanOrEqual(1)
})
test('records boundsRejected when room growth attempts cross the configured boundary', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 9,
merge: 800,
seed: 54321,
pieces,
maxSectionsX: 3,
maxSectionsY: 3,
}
const result = generateMaze(request)
expect(result.stats.boundsRejected).toBeDefined()
expect(typeof result.stats.boundsRejected).toBe('number')
expect(result.level.width).toBeLessThanOrEqual(30)
expect(result.level.height).toBeLessThanOrEqual(30)
})
test('unconstrained maze allows wide sprawl when no bounds are specified', () => {
const pieces = makeCavePieceSet()
const request: MazeRequest = {
levelId: 2,
levelName: 'Act 1 - Cave 1',
levelTypeName: 'Act 1 - Cave',
sectionSize: 10,
sectionHeight: 10,
minRooms: 9,
merge: 0,
seed: 12345,
pieces,
}
const result = generateMaze(request)
expect(result.stats.maxCellsX).toBeUndefined()
expect(result.stats.maxCellsY).toBeUndefined()
expect(result.stats.boundsRejected).toBe(0)
expect(Number(result.stats.roomsPlaced)).toBeGreaterThanOrEqual(9)
})
})

484
tests/maze-objects.test.ts Normal file
View File

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

View File

@ -0,0 +1,767 @@
/**
* Unit tests for the 11 Diablo II DRLG Maze Special Passes.
*
* Covers:
* 1. DRLGMAZE_PlaceAct2TombStuff (Tal Rasha True/False Tombs, Kaa, Cube, Leatherarm, Waypoint)
* 2. DRLGMAZE_PlaceAct2LairStuff (Maggot Lair Coldworm Tight Spot & Staff of Kings Treasure)
* 3. DRLGMAZE_PlaceAct3DungeonStuff (Flayer Dungeon Brain chest, Kurast Temples Lam Esen's Tome)
* 4. DRLGMAZE_PlaceAct3SewerStuff (Kurast Sewers Drain lever descent & Chest)
* 5. DRLGMAZE_PlaceAct3MephistoStuff (Durance of Hate 3 Mephisto Complex, Durance 2 Waypoint)
* 6. DRLGMAZE_PlaceAct5TempleStuff (Halls of Vaught Nihlathak Final Room, Halls of Pain Waypoint)
* 7. DRLGMAZE_PlaceAct5BaalStuff (Throne of Destruction Baal Entrance/ThroneRoom, Worldstone Waypoint)
* 8. DRLGMAZE_PlaceAct5IceStuff (Frozen River Anya platform, Drifter Cavern / Icy Cellar Pools, Waypoints)
* 9. DRLGMAZE_PlaceAct1Barracks (Monastery Barracks Smith Forge & Court Connect)
* 10. DRLGMAZE_PlaceAct4Lava (River of Flame Hellforge, Bridges 1 & 2, Warp, Lava X fillers)
* 11. DRLGMAZE_ScanReplaceSpecialAct2SewersPresets (Lut Gholein Sewers Radament's Lair, Dual Entrances, Waypoint)
* 12. Full generateMaze integration tests verifying specialPassesApplied and UNIMPLEMENTED_PASSES == [].
*/
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
import {
generateMaze,
classifyMazePieceName,
UNIMPLEMENTED_PASSES,
type MazePiece,
type MazePieceKind,
type MazeRequest,
} from '../src/game/maze.ts'
import {
runMazeSpecialPasses,
type SpecialPassName,
} from '../src/game/maze-special-passes.ts'
/**
* Construct a synthetic minimal Ds1 map.
*/
function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y++) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x++) {
row.push({
walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 1,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects,
npcPathOffset: null,
}
}
/**
* Construct a synthetic MazePiece.
*/
function mockPiece(
name: string,
kind: MazePieceKind,
sides = '',
objects: Ds1Object[] = [],
width = 10,
height = 10,
): MazePiece {
return {
name,
kind,
sides,
levels: [mockDs1(width, height, objects)],
}
}
/**
* Create a full standard piece set for a level type so maze generation has
* all 15 basic room shapes plus entrance and common directions.
*/
function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[] = []): MazePiece[] {
const SIDES = [
'N', 'S', 'E', 'W',
'NE', 'NW', 'SE', 'SW',
'NS', 'EW',
'NSE', 'NSW', 'NEW', 'SEW',
'NSEW',
]
const pieces: MazePiece[] = [
mockPiece(`${levelTypeName} Entrance`, 'entrance', ''),
...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }])),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }])),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Down ${s}`, 'down', s, [{ type: 2, id: 3, x: 25, y: 25, flags: 0 }])),
...['N', 'S', 'E', 'W'].map(s => mockPiece(`${levelTypeName} Theme ${s}`, 'theme', s)),
...extraPieces,
]
return pieces
}
describe('Diablo II DRLG Maze Special Passes', () => {
test('UNIMPLEMENTED_PASSES is completely empty', () => {
expect(UNIMPLEMENTED_PASSES).toEqual([])
expect(UNIMPLEMENTED_PASSES.length).toBe(0)
})
/* ----------------------------------------------------------------------- *
* 1. Act 2 - Tomb
* ----------------------------------------------------------------------- */
describe('Pass 1: DRLGMAZE_PlaceAct2TombStuff', () => {
const tombExtraPieces = [
mockPiece('Act 2 - Tomb Talrasha N', 'quest', 'N', [{ type: 2, id: 152, x: 25, y: 25, flags: 0 }]), // Horadric Staff Orifice
mockPiece('Act 2 - Tomb Chest N', 'treasure', 'N', [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]), // Golden chest
mockPiece('Act 2 - Tomb Kaa N', 'quest', 'N', [{ type: 1, id: 250, x: 25, y: 25, flags: 0 }]), // Ancient Kaa
mockPiece('Act 2 - Tomb Cube N', 'treasure', 'N', [{ type: 2, id: 354, x: 25, y: 25, flags: 0 }]), // Horadric Cube
mockPiece('Act 2 - Tomb Leatherarm N', 'quest', 'N'),
mockPiece('Act 2 - Tomb Treasure N', 'treasure', 'N'),
mockPiece('Act 2 - Tomb Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 2 - Tomb Prev NSW', 'prev', 'NSW'),
mockPiece('Act 2 - Tomb Prev NEW', 'prev', 'NEW'),
mockPiece('Act 2 - Tomb Prev NSE', 'prev', 'NSE'),
mockPiece('Act 2 - Tomb Prev SEW', 'prev', 'SEW'),
]
test('True Tomb (staffTombLevelId): places Talrasha Horadric Staff orifice', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 66,
levelName: 'Tal Rashas Tomb 1',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 12345,
pieces,
staffTombLevelId: 66,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const hasTalrasha = specials.some(s => s.kind === 'quest')
expect(hasTalrasha).toBe(true)
// Level size is tripled for staff tomb
expect(res.stats.targetRooms).toBe(15)
})
test('False Tombs (67..72): places golden chest room', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 67,
levelName: 'Tal Rashas Tomb 2',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 54321,
pieces,
staffTombLevelId: 66,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
})
test('Halls of the Dead 2 (level 59): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 59,
levelName: 'Halls of the Dead 2',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 99999,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
test('Halls of the Dead 3 (level 60): places Horadric Cube chest', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 60,
levelName: 'Halls of the Dead 3',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 88888,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest' || s.kind === 'treasure')).toBe(true)
})
test('Boss Tomb: places Ancient Kaa the Soulless', () => {
const pieces = createStandardPieceSet('Act 2 - Tomb', tombExtraPieces)
const res = generateMaze({
levelId: 68,
levelName: 'Tal Rashas Tomb 3',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 77777,
pieces,
staffTombLevelId: 66,
bossTombLevelId: 68,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2TombStuff')
expect(res.stats.targetRooms).toBe(10) // Doubled for boss tomb
})
})
/* ----------------------------------------------------------------------- *
* 2. Act 2 - Lair
* ----------------------------------------------------------------------- */
describe('Pass 2: DRLGMAZE_PlaceAct2LairStuff', () => {
const lairExtraPieces = [
mockPiece('Act 2 - Lair Tight Spot S', 'quest', 'S', [{ type: 1, id: 284, x: 25, y: 25, flags: 0 }]), // Coldworm
mockPiece('Act 2 - Lair Treasure W', 'treasure', 'W', [{ type: 2, id: 356, x: 25, y: 25, flags: 0 }]), // Staff of Kings chest
]
test('Maggot Lair 3 (level 64): places Coldworm Tight Spot and Staff of Kings chest', () => {
const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces)
const res = generateMaze({
levelId: 64,
levelName: 'Maggot Lair 3',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 42,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
expect(res.level.objects.some(o => o.type === 1 && o.id === 284)).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 356)).toBe(true)
})
test('Maggot Lair 1 (level 62): places regular next and prev', () => {
const pieces = createStandardPieceSet('Act 2 - Lair', lairExtraPieces)
const res = generateMaze({
levelId: 62,
levelName: 'Maggot Lair 1',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 101,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct2LairStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'next')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 3. Act 3 - Dungeon
* ----------------------------------------------------------------------- */
describe('Pass 3: DRLGMAZE_PlaceAct3DungeonStuff', () => {
const dungeonExtraPieces = [
mockPiece('Act 3 - Dungeon Treasure 2', 'treasure', '', [{ type: 2, id: 406, x: 25, y: 25, flags: 0 }]), // Khalim's Brain chest
mockPiece('Act 3 - Dungeon Treasure 1', 'treasure', ''),
mockPiece('Act 3 - Temple 1', 'quest', '', [{ type: 2, id: 193, x: 25, y: 25, flags: 0 }]), // Lam Esen's Tome altar
]
test("Flayer Dungeon 3 (level 91): places Khalim's Brain chest (Treasure 2)", () => {
const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces)
const res = generateMaze({
levelId: 91,
levelName: 'Flayer Dungeon 3',
levelTypeName: 'Act 3 - Dungeon',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 31415,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 406)).toBe(true)
})
test("Ruined Temple (level 94): places Lam Esen's Tome altar (Temple 1)", () => {
const pieces = createStandardPieceSet('Act 3 - Dungeon', dungeonExtraPieces)
const res = generateMaze({
levelId: 94,
levelName: 'Ruined Temple',
levelTypeName: 'Act 3 - Dungeon',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 27182,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3DungeonStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 193)).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 4. Act 3 - Sewer
* ----------------------------------------------------------------------- */
describe('Pass 4: DRLGMAZE_PlaceAct3SewerStuff', () => {
const sewerExtraPieces = [
mockPiece('Act 3 - Sewer Drain N', 'next', 'N', [{ type: 2, id: 366, x: 25, y: 25, flags: 0 }]), // Drain lever to Level 2
mockPiece('Act 3 - Sewer Chest S', 'treasure', 'S'),
mockPiece('Act 3 - Sewer Treasure X', 'treasure', ''),
mockPiece('Act 3 - Sewer NW', 'room', 'NW'),
mockPiece('Act 3 - Sewer NE', 'room', 'NE'),
mockPiece('Act 3 - Sewer SW', 'room', 'SW'),
mockPiece('Act 3 - Sewer SE', 'room', 'SE'),
mockPiece('Act 3 - Sewer Prev NW', 'prev', 'NW'),
mockPiece('Act 3 - Sewer Prev NE', 'prev', 'NE'),
mockPiece('Act 3 - Sewer Prev SW', 'prev', 'SW'),
mockPiece('Act 3 - Sewer Prev SE', 'prev', 'SE'),
]
test('Kurast Sewers 1 (level 92): places Drain lever and Chest', () => {
const pieces = createStandardPieceSet('Act 3 - Sewer', sewerExtraPieces)
const res = generateMaze({
levelId: 92,
levelName: 'Kurast Sewers 1',
sectionSize: 10,
minRooms: 18,
merge: 500,
seed: 777,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3SewerStuff')
expect(res.level.objects.some(o => o.type === 2 && o.id === 366)).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 5. Act 3 - Mephisto
* ----------------------------------------------------------------------- */
describe('Pass 5: DRLGMAZE_PlaceAct3MephistoStuff', () => {
const mephistoExtraPieces = [
mockPiece('Act 3 - Mephisto Complex', 'quest', '', [{ type: 2, id: 251, x: 50, y: 50, flags: 0 }]), // Red portal to Act 4
mockPiece('Act 3 - Mephisto Waypoint N', 'waypoint', 'N', [{ type: 2, id: 237, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 3 - Mephisto Prev N', 'prev', 'N'),
mockPiece('Act 3 - Mephisto Next N', 'next', 'N'),
]
test('Durance of Hate 3 (level 102): places Mephisto Complex', () => {
const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces)
const res = generateMaze({
levelId: 102,
levelName: 'Durance of Hate 3',
levelTypeName: 'Act 3 - Kurast',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 666,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 251)).toBe(true)
})
test('Durance of Hate 2 (level 101): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 3 - Kurast', mephistoExtraPieces)
const res = generateMaze({
levelId: 101,
levelName: 'Durance of Hate 2',
levelTypeName: 'Act 3 - Kurast',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 10101,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct3MephistoStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 6. Act 5 - Temple
* ----------------------------------------------------------------------- */
describe('Pass 6: DRLGMAZE_PlaceAct5TempleStuff', () => {
const templeExtraPieces = [
mockPiece('Act 5 - Temple Final Room', 'quest', '', [{ type: 1, id: 526, x: 25, y: 25, flags: 0 }]), // Nihlathak
mockPiece('Act 5 - Temple NE Waypoint', 'waypoint', 'NE'),
mockPiece('Act 5 - Temple NW Waypoint', 'waypoint', 'NW'),
mockPiece('Act 5 - Temple SW Waypoint', 'waypoint', 'SW'),
mockPiece('Act 5 - Temple SE Waypoint', 'waypoint', 'SE'),
mockPiece('Act 5 - Temple NE Down', 'down', 'NE'),
mockPiece('Act 5 - Temple NW Down', 'down', 'NW'),
mockPiece('Act 5 - Temple SW Down', 'down', 'SW'),
mockPiece('Act 5 - Temple SE up', 'prev', 'SE'),
mockPiece('Act 5 - Temple SE', 'room', 'SE'),
mockPiece('Act 5 - Temple NE', 'room', 'NE'),
mockPiece('Act 5 - Temple NW', 'room', 'NW'),
mockPiece('Act 5 - Temple SW', 'room', 'SW'),
]
test('Halls of Vaught (level 124): places Nihlathak Final Room altar', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const res = generateMaze({
levelId: 124,
levelName: 'Halls of Vaught',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 124124,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 1 && o.id === 526)).toBe(true)
})
test('Halls of Pain (level 123): places Waypoint and stairs', () => {
const pieces = createStandardPieceSet('Act 5 - Temple', templeExtraPieces)
const res = generateMaze({
levelId: 123,
levelName: 'Halls of Pain',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 123123,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5TempleStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 7. Act 5 - Baal
* ----------------------------------------------------------------------- */
describe('Pass 7: DRLGMAZE_PlaceAct5BaalStuff', () => {
const baalExtraPieces = [
mockPiece('Act 5 - ThroneRoom', 'quest', '', [{ type: 2, id: 563, x: 25, y: 25, flags: 0 }]), // Baal Throne
mockPiece('Act 5 - Baal Waypoint N', 'waypoint', 'N'),
mockPiece('Act 5 - Baal Prev NSW', 'prev', 'NSW'),
mockPiece('Act 5 - Baal Prev NEW', 'prev', 'NEW'),
mockPiece('Act 5 - Baal Prev NSE', 'prev', 'NSE'),
mockPiece('Act 5 - Baal Prev SEW', 'prev', 'SEW'),
]
test('Throne of Destruction (level 131): places ThroneRoom', () => {
const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces)
const res = generateMaze({
levelId: 131,
levelName: 'Throne of Destruction',
levelTypeName: 'Act 5 - Baal',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 131131,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 563)).toBe(true)
})
test('Worldstone Keep 2 (level 129): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 5 - Baal', baalExtraPieces)
const res = generateMaze({
levelId: 129,
levelName: 'Worldstone Keep 2',
levelTypeName: 'Act 5 - Baal',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 129129,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5BaalStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 8. Act 5 - Ice
* ----------------------------------------------------------------------- */
describe('Pass 8: DRLGMAZE_PlaceAct5IceStuff', () => {
const iceExtraPieces = [
mockPiece('Act 5 - Ice River A', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]), // Frozen Anya
mockPiece('Act 5 - Ice River B', 'quest', '', [{ type: 2, id: 460, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 5 - Ice Pool A', 'quest', ''),
mockPiece('Act 5 - Ice Pool B', 'quest', ''),
mockPiece('Act 5 - Ice waypoint N', 'waypoint', 'N'),
]
test('Frozen River (level 114): places Anya platform', () => {
const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces)
const res = generateMaze({
levelId: 114,
levelName: 'Frozen River',
levelTypeName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 1, // Single-room preset in profile
merge: 500,
seed: 114114,
pieces,
})
// Level 114 resolves single room preset Act 5 - Ice River A
expect(res.level.objects.some(o => o.type === 2 && o.id === 460)).toBe(true)
})
test('Glacial Trail (level 115): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 5 - Ice Caves', iceExtraPieces)
const res = generateMaze({
levelId: 115,
levelName: 'Glacial Trail',
levelTypeName: 'Act 5 - Ice Caves',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 115115,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct5IceStuff')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 9. Act 1 - Barracks
* ----------------------------------------------------------------------- */
describe('Pass 9: DRLGMAZE_PlaceAct1Barracks', () => {
const barracksExtraPieces = [
mockPiece('Act 1 - Barracks Forge N', 'quest', 'N', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]), // Charsi's Malus forge
mockPiece('Act 1 - Barracks Forge S', 'quest', 'S', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 1 - Barracks Forge E', 'quest', 'E', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 1 - Barracks Forge W', 'quest', 'W', [{ type: 2, id: 108, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 1 - Barracks Court Connect', 'prev', '', [{ type: 2, id: 1, x: 10, y: 10, flags: 0 }]), // Entrance from Cloister
]
test('Monastery Barracks (level 28): places Court Connect and Smith Forge', () => {
const pieces = createStandardPieceSet('Act 1 - Barracks', barracksExtraPieces)
const res = generateMaze({
levelId: 28,
levelName: 'Barracks',
sectionSize: 10,
sectionHeight: 14,
minRooms: 6,
merge: 500,
seed: 2828,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct1Barracks')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(specials.some(s => s.kind === 'prev')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 108)).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 10. Act 4 - Lava
* ----------------------------------------------------------------------- */
describe('Pass 10: DRLGMAZE_PlaceAct4Lava', () => {
const lavaExtraPieces = [
mockPiece('Act 4 - Lava Warp N', 'prev', 'N', [{ type: 2, id: 255, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Bridge 1', 'next', '', [{ type: 2, id: 10, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Bridge 2', 'next', '', [{ type: 2, id: 11, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Lava Forge W', 'quest', 'W', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]), // Hellforge
mockPiece('Act 4 - Lava Forge E', 'quest', 'E', [{ type: 2, id: 376, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 4 - Lava X', 'room', ''), // Filler
]
test('River of Flame (level 107): places Hellforge, Bridges, Warp and Lava X filler', () => {
const pieces = createStandardPieceSet('Act 4 - Lava', lavaExtraPieces)
const res = generateMaze({
levelId: 107,
levelName: 'River of Flame',
levelTypeName: 'Act 4 - Lava',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 107107,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_PlaceAct4Lava')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 2 && o.id === 376)).toBe(true)
// Confirm Lava X filler spaces were placed
expect(specials.some(s => s.sides === 'filler')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* 11. Act 2 - Sewers
* ----------------------------------------------------------------------- */
describe('Pass 11: DRLGMAZE_ScanReplaceSpecialAct2SewersPresets', () => {
const sewer2ExtraPieces = [
mockPiece("Act 2 - Sewer Radament's Lair N", 'quest', 'N', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]), // Radament
mockPiece("Act 2 - Sewer Radament's Lair S", 'quest', 'S', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
mockPiece("Act 2 - Sewer Radament's Lair E", 'quest', 'E', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
mockPiece("Act 2 - Sewer Radament's Lair W", 'quest', 'W', [{ type: 1, id: 229, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 2 - Sewer Prev E', 'prev', 'E'), // City entrance
mockPiece('Act 2 - Sewer Prev NS', 'prev', 'NS'), // Dock entrance
mockPiece('Act 2 - Sewer Waypoint N', 'waypoint', 'N', [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]),
mockPiece('Act 2 - Sewer Chest N', 'treasure', 'N', [{ type: 2, id: 355, x: 25, y: 25, flags: 0 }]),
]
test("Sewers Level 3 (level 49): places Radament's Lair", () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 49,
levelName: 'Sewers Level 3',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 4949,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'quest')).toBe(true)
expect(res.level.objects.some(o => o.type === 1 && o.id === 229)).toBe(true)
})
test('Sewers Level 1 (level 47): places dock and city entrances', () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 47,
levelName: 'Sewers Level 1',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 4747,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const prevs = specials.filter(s => s.kind === 'prev')
expect(prevs.length).toBeGreaterThanOrEqual(1)
})
test('Sewers Level 2 (level 48): places Waypoint', () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 48,
levelName: 'Sewers Level 2',
sectionSize: 10,
minRooms: 6,
merge: 500,
seed: 4848,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(true)
})
test('Ancient Tunnels (level 50): places golden chest', () => {
const pieces = createStandardPieceSet('Act 2 - Sewer', sewer2ExtraPieces)
const res = generateMaze({
levelId: 50,
levelName: 'Ancient Tunnels',
sectionSize: 10,
minRooms: 5,
merge: 500,
seed: 5050,
pieces,
})
expect(res.stats.specialPassesApplied).toContain('DRLGMAZE_ScanReplaceSpecialAct2SewersPresets')
const specials = res.stats.specialsApplied as { room: number; kind: string; sides: string }[]
expect(specials.some(s => s.kind === 'treasure')).toBe(true)
})
})
/* ----------------------------------------------------------------------- *
* Classification tests
* ----------------------------------------------------------------------- */
describe('Piece Classification for Special Presets', () => {
test('classifies waypoint pieces', () => {
expect(classifyMazePieceName('Act 2 - Tomb Waypoint N', 'Act 2 - Tomb')).toEqual({
kind: 'waypoint',
sides: 'N',
})
expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({
kind: 'waypoint',
sides: 'NE',
})
expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({
kind: 'waypoint',
sides: 'N',
})
})
test('classifies quest and special pieces', () => {
expect(classifyMazePieceName('Act 2 - Tomb Talrasha N', 'Act 2 - Tomb')).toEqual({
kind: 'quest',
sides: 'N',
})
expect(classifyMazePieceName("Act 2 - Sewer Radament's Lair S", 'Act 2 - Sewer')).toEqual({
kind: 'quest',
sides: 'S',
})
expect(classifyMazePieceName('Act 2 - Lair Tight Spot S', 'Act 2 - Lair')).toEqual({
kind: 'quest',
sides: 'S',
})
expect(classifyMazePieceName('Act 1 - Barracks Forge W', 'Act 1 - Barracks')).toEqual({
kind: 'quest',
sides: 'W',
})
expect(classifyMazePieceName('Act 4 - Bridge 1', 'Act 4 - Lava')).toEqual({
kind: 'next',
sides: '',
})
expect(classifyMazePieceName('Act 3 - Mephisto Complex', 'Act 3 - Kurast')).toEqual({
kind: 'quest',
sides: '',
})
expect(classifyMazePieceName('Act 5 - Temple Final Room', 'Act 5 - Temple')).toEqual({
kind: 'quest',
sides: '',
})
expect(classifyMazePieceName('Act 5 - ThroneRoom Entrance', 'Act 5 - Baal')).toEqual({
kind: 'quest',
sides: '',
})
})
})
})

528
tests/maze-waypoint.test.ts Normal file
View File

@ -0,0 +1,528 @@
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts'
import {
classifyMazePieceName,
generateMaze,
SUB_TILES_PER_TILE,
} from '../src/game/maze.ts'
import type { MazePiece, MazeRequest } from '../src/game/maze.ts'
function createMockDs1(width = 8, height = 8, objects: Ds1Object[] = []): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, 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,
npcPathOffset: null,
}
}
function createPiece(
name: string,
levelTypeName: string,
ds1Objects: Ds1Object[] = [],
width = 8,
height = 8,
): MazePiece {
const classified = classifyMazePieceName(name, levelTypeName)
if (!classified) throw new Error(`Could not classify ${name} for ${levelTypeName}`)
return {
name,
kind: classified.kind,
sides: classified.sides,
levels: [createMockDs1(width, height, ds1Objects)],
}
}
function createCatacombsPieces(wpObjects: Ds1Object[] = []): MazePiece[] {
const lt = 'Act 1 - Catacombs'
const sided = [
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = []
for (const s of sided) {
pieces.push(createPiece(`${lt} ${s}`, lt))
}
pieces.push(createPiece(`${lt} Prev EW`, lt))
pieces.push(createPiece(`${lt} Prev NS`, lt))
for (const s of ['N', 'E', 'S', 'W']) {
pieces.push(createPiece(`${lt} Next ${s}`, lt))
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects))
}
return pieces
}
function createJailPieces(wpObjects: Ds1Object[] = []): MazePiece[] {
const lt = 'Act 1 - Jail'
const sided = [
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = []
for (const s of sided) {
pieces.push(createPiece(`${lt} ${s}`, lt))
}
for (const s of ['N', 'E', 'S', 'W']) {
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
pieces.push(createPiece(`${lt} Next ${s}`, lt))
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects))
}
return pieces
}
function createKurastPieces(): MazePiece[] {
const lt = 'Act 3 - Kurast'
const sided = [
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = []
for (const s of sided) {
pieces.push(createPiece(`${lt} ${s}`, lt))
}
for (const s of ['N', 'E', 'S', 'W']) {
pieces.push(createPiece(`Act 3 - Mephisto Prev ${s}`, lt))
pieces.push(createPiece(`Act 3 - Mephisto Next ${s}`, lt))
pieces.push(createPiece(`Act 3 - Mephisto Waypoint ${s}`, lt))
}
return pieces
}
function createBaalPieces(): MazePiece[] {
const lt = 'Act 5 - Baal'
const sided = [
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = []
for (const s of sided) {
pieces.push(createPiece(`${lt} ${s}`, lt))
}
// prevChain requires Prev NSE, SEW, NSW, NEW
for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) {
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
}
for (const s of ['N', 'E', 'S', 'W']) {
pieces.push(createPiece(`${lt} Next ${s}`, lt))
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
}
return pieces
}
function createSewerPieces(): MazePiece[] {
const lt = 'Act 2 - Sewer'
const sided = [
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = []
for (const s of sided) {
pieces.push(createPiece(`${lt} ${s}`, lt))
}
for (const s of ['N', 'E', 'S', 'W']) {
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
pieces.push(createPiece(`${lt} Next ${s}`, lt))
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
}
return pieces
}
function createTombPieces(): MazePiece[] {
const lt = 'Act 2 - Tomb'
const sided = [
'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W',
]
const pieces: MazePiece[] = []
for (const s of sided) {
pieces.push(createPiece(`${lt} ${s}`, lt))
}
for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) {
pieces.push(createPiece(`${lt} Prev ${s}`, lt))
}
for (const s of ['N', 'E', 'S', 'W']) {
pieces.push(createPiece(`${lt} Next ${s}`, lt))
pieces.push(createPiece(`${lt} Waypoint ${s}`, lt))
}
return pieces
}
describe('Issue #47: Maze Waypoint preset replacement pass', () => {
describe('classifyMazePieceName', () => {
test('classifies waypoint pieces for all acts correctly', () => {
expect(classifyMazePieceName('Act 1 - Jail Waypoint W', 'Act 1 - Jail')).toEqual({
kind: 'waypoint',
sides: 'W',
})
expect(classifyMazePieceName('Act 1 - Catacombs Waypoint N', 'Act 1 - Catacombs')).toEqual({
kind: 'waypoint',
sides: 'N',
})
expect(classifyMazePieceName('Act 2 - Sewer Waypoint S', 'Act 2 - Sewer')).toEqual({
kind: 'waypoint',
sides: 'S',
})
expect(classifyMazePieceName('Act 2 - Tomb Waypoint E', 'Act 2 - Tomb')).toEqual({
kind: 'waypoint',
sides: 'E',
})
expect(classifyMazePieceName('Act 3 - Mephisto Waypoint W', 'Act 3 - Kurast')).toEqual({
kind: 'waypoint',
sides: 'W',
})
expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({
kind: 'waypoint',
sides: 'N',
})
expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({
kind: 'waypoint',
sides: 'NE',
})
expect(classifyMazePieceName('Act 5 - Temple SW Waypoint', 'Act 5 - Temple')).toEqual({
kind: 'waypoint',
sides: 'SW',
})
expect(classifyMazePieceName('Act 5 - Baal Waypoint N', 'Act 5 - Baal')).toEqual({
kind: 'waypoint',
sides: 'N',
})
})
test('preserves classification of other piece kinds', () => {
expect(classifyMazePieceName('Act 1 - Jail Prev W', 'Act 1 - Jail')).toEqual({
kind: 'prev',
sides: 'W',
})
expect(classifyMazePieceName('Act 1 - Catacombs Next S', 'Act 1 - Catacombs')).toEqual({
kind: 'next',
sides: 'S',
})
expect(classifyMazePieceName('Act 1 - Cave NSEW', 'Act 1 - Cave')).toEqual({
kind: 'room',
sides: 'NSEW',
})
})
})
describe('generateMaze waypoint placement', () => {
test('places waypoint room in Catacombs 2 (level 35) and records stats', () => {
const pieces = createCatacombsPieces()
const req: MazeRequest = {
levelId: 35,
levelName: 'Catacombs Level 2',
levelTypeName: 'Act 1 - Catacombs',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 42,
pieces,
}
const result = generateMaze(req)
const waypoints = result.stats.waypoints as unknown[]
expect(waypoints).toHaveLength(1)
const wp = result.stats.waypoint as {
room: number
x: number
y: number
width: number
height: number
centreX: number
centreY: number
entityX: number
entityY: number
}
expect(wp).not.toBeNull()
expect(result.stats.waypointTile).toEqual({
x: Math.floor(wp.entityX / SUB_TILES_PER_TILE),
y: Math.floor(wp.entityY / SUB_TILES_PER_TILE),
})
const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[]
const wpSpecial = specials.find(s => s.kind === 'waypoint')
expect(wpSpecial).toBeDefined()
expect(wpSpecial?.room).toBe(wp.room)
// Verify synthesized waypoint entity is present in result.level.objects
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 119)
expect(wpObj).toBeDefined()
expect(wpObj?.x).toBe(wp.entityX)
expect(wpObj?.y).toBe(wp.entityY)
})
test('does NOT place waypoint room in Catacombs 1 (level 34)', () => {
const pieces = createCatacombsPieces()
const req: MazeRequest = {
levelId: 34,
levelName: 'Catacombs Level 1',
levelTypeName: 'Act 1 - Catacombs',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 42,
pieces,
}
const result = generateMaze(req)
expect(result.stats.waypoints).toEqual([])
expect(result.stats.waypoint).toBeNull()
expect(result.stats.waypointTile).toBeNull()
const specials = result.stats.specialsApplied as { kind: string }[]
expect(specials.some(s => s.kind === 'waypoint')).toBe(false)
})
test('places waypoint room in Jail 1 (level 29)', () => {
const pieces = createJailPieces()
const req: MazeRequest = {
levelId: 29,
levelName: 'Jail Level 1',
levelTypeName: 'Act 1 - Jail',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 101,
pieces,
}
const result = generateMaze(req)
expect(result.stats.waypoints).toHaveLength(1)
expect(result.stats.waypoint).not.toBeNull()
})
test('honors hasWaypoint override: forces waypoint when true on level without default waypoint', () => {
const pieces = createCatacombsPieces()
const req: MazeRequest = {
levelId: 34,
levelName: 'Catacombs Level 1',
levelTypeName: 'Act 1 - Catacombs',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 42,
pieces,
hasWaypoint: true,
}
const result = generateMaze(req)
expect(result.stats.waypoints).toHaveLength(1)
expect(result.stats.waypoint).not.toBeNull()
})
test('honors hasWaypoint override: disables waypoint when false on level with default waypoint', () => {
const pieces = createCatacombsPieces()
const req: MazeRequest = {
levelId: 35,
levelName: 'Catacombs Level 2',
levelTypeName: 'Act 1 - Catacombs',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 42,
pieces,
hasWaypoint: false,
}
const result = generateMaze(req)
expect(result.stats.waypoints).toEqual([])
expect(result.stats.waypoint).toBeNull()
})
test('uses authored DS1 object entity coordinates when present', () => {
const authoredObj: Ds1Object = {
type: 2,
id: 119,
x: 18,
y: 22,
flags: 0,
}
const pieces = createCatacombsPieces([authoredObj])
const req: MazeRequest = {
levelId: 35,
levelName: 'Catacombs Level 2',
levelTypeName: 'Act 1 - Catacombs',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 88,
pieces,
}
const result = generateMaze(req)
const wp = result.stats.waypoint as {
x: number
y: number
entityX: number
entityY: number
}
expect(wp.entityX).toBe(wp.x * SUB_TILES_PER_TILE + 18)
expect(wp.entityY).toBe(wp.y * SUB_TILES_PER_TILE + 22)
const objInLevel = result.level.objects.find(o => o.type === 2 && o.id === 119)
expect(objInLevel).toBeDefined()
expect(objInLevel?.x).toBe(wp.entityX)
expect(objInLevel?.y).toBe(wp.entityY)
})
test('places waypoint in Durance of Hate 2 (101) but not Durance 1 (100)', () => {
const pieces = createKurastPieces()
const req101: MazeRequest = {
levelId: 101,
levelName: 'Durance of Hate Level 2',
levelTypeName: 'Act 3 - Kurast',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 777,
pieces,
}
const res101 = generateMaze(req101)
expect(res101.stats.waypoints).toHaveLength(1)
expect(res101.stats.waypoint).not.toBeNull()
const req100: MazeRequest = {
levelId: 100,
levelName: 'Durance of Hate Level 1',
levelTypeName: 'Act 3 - Kurast',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 777,
pieces,
}
const res100 = generateMaze(req100)
expect(res100.stats.waypoints).toEqual([])
expect(res100.stats.waypoint).toBeNull()
})
test('places waypoint in Worldstone Keep 2 (129) but not Worldstone 1 (128)', () => {
const pieces = createBaalPieces()
const req129: MazeRequest = {
levelId: 129,
levelName: 'Worldstone Keep Level 2',
levelTypeName: 'Act 5 - Baal',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 999,
pieces,
}
const res129 = generateMaze(req129)
expect(res129.stats.waypoints).toHaveLength(1)
expect(res129.stats.waypoint).not.toBeNull()
const req128: MazeRequest = {
levelId: 128,
levelName: 'Worldstone Keep Level 1',
levelTypeName: 'Act 5 - Baal',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 999,
pieces,
}
const res128 = generateMaze(req128)
expect(res128.stats.waypoints).toEqual([])
expect(res128.stats.waypoint).toBeNull()
})
test('places waypoint in Lut Gholein Sewer 2 (48) but not Sewer 1 (47)', () => {
const pieces = createSewerPieces()
const req48: MazeRequest = {
levelId: 48,
levelName: 'Sewers Level 2',
levelTypeName: 'Act 2 - Sewer',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 333,
pieces,
}
const res48 = generateMaze(req48)
expect(res48.stats.waypoints).toHaveLength(1)
expect(res48.stats.waypoint).not.toBeNull()
const req47: MazeRequest = {
levelId: 47,
levelName: 'Sewers Level 1',
levelTypeName: 'Act 2 - Sewer',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 333,
pieces,
}
const res47 = generateMaze(req47)
expect(res47.stats.waypoints).toEqual([])
expect(res47.stats.waypoint).toBeNull()
})
test('places waypoint in Halls of the Dead 2 (57) but not Tomb 1 (56)', () => {
const pieces = createTombPieces()
const req57: MazeRequest = {
levelId: 57,
levelName: 'Halls of the Dead Level 2',
levelTypeName: 'Act 2 - Tomb',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 444,
pieces,
}
const res57 = generateMaze(req57)
expect(res57.stats.waypoints).toHaveLength(1)
expect(res57.stats.waypoint).not.toBeNull()
const req56: MazeRequest = {
levelId: 56,
levelName: 'Halls of the Dead Level 1',
levelTypeName: 'Act 2 - Tomb',
sectionSize: 8,
minRooms: 10,
merge: 100,
seed: 444,
pieces,
}
const res56 = generateMaze(req56)
expect(res56.stats.waypoints).toEqual([])
expect(res56.stats.waypoint).toBeNull()
})
test('places waypoint at a distance from the entrance (isExit distance sorting)', () => {
const pieces = createCatacombsPieces()
const req: MazeRequest = {
levelId: 35,
levelName: 'Catacombs Level 2',
levelTypeName: 'Act 1 - Catacombs',
sectionSize: 8,
minRooms: 12,
merge: 0,
seed: 12345,
pieces,
}
const result = generateMaze(req)
const wp = result.stats.waypoint as { room: number }
const specials = result.stats.specialsApplied as { room: number; kind: string }[]
const prevSpecial = specials.find(s => s.kind === 'prev')
if (prevSpecial) {
// Waypoint room should not be placed in the entrance room
expect(wp.room).not.toBe(prevSpecial.room)
}
})
})
})

404
tests/monster-rooms.test.ts Normal file
View File

@ -0,0 +1,404 @@
import { describe, expect, it } from 'vitest'
import {
allocatePacksToRooms,
partitionMapIntoRooms,
spawnMonsterPacksByRooms,
spawnPacksInRoom,
PACK_RADIUS_PX,
type MapRoom,
} from '../src/game/monster-rooms.ts'
import { createWorld } from '../src/game/combat.ts'
import type { CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
/** Flat open ground everywhere. */
const OPEN: CombatTerrain = { overlap: () => 0 }
/**
* Creates a simple monster stat record.
*/
function stats(id: string, hp = 30): MonsterStats {
return {
id,
name: id,
hp,
damage: 5,
cooldownTicks: 25,
reach: 40,
aggroRadius: 200,
speed: 100,
xp: 10,
}
}
/**
* Creates a test monster pack.
*/
function pack(id: string, count: number, extra?: Partial<MonsterPack>): MonsterPack {
return {
members: Array.from({ length: count }, (_, i) => stats(id, 30 + i)),
...extra,
}
}
describe('Room-Local Monster Clustering and Placement', () => {
describe('partitionMapIntoRooms', () => {
it('partitions outdoor grid sectors into authentic ~20-30 cell rooms', () => {
// 72x48 cells partitioned with roomCellSize = 24 -> 3 x 2 = 6 rooms
const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80 })
expect(rooms).toHaveLength(6)
// All rooms should be 24x24 cells and 1920x1920 pixels
for (const room of rooms) {
expect(room.cellsX).toBe(24)
expect(room.cellsY).toBe(24)
expect(room.width).toBe(1920)
expect(room.height).toBe(1920)
expect(room.isPreset).toBe(false)
}
// Check cell grid coordinates
expect(rooms[0]?.cellX).toBe(0)
expect(rooms[0]?.cellY).toBe(0)
expect(rooms[1]?.cellX).toBe(24)
expect(rooms[1]?.cellY).toBe(0)
expect(rooms[2]?.cellX).toBe(48)
expect(rooms[2]?.cellY).toBe(0)
expect(rooms[3]?.cellX).toBe(0)
expect(rooms[3]?.cellY).toBe(24)
})
it('handles map dimensions that do not divide evenly by roomCellSize', () => {
// 50x30 cells with roomCellSize = 24 -> 3 cols (24, 24, 2) x 2 rows (24, 6) = 6 rooms
const rooms = partitionMapIntoRooms(50, 30, { roomCellSize: 24, cellPixelSize: 80 })
expect(rooms).toHaveLength(6)
const col2Row0 = rooms.find(r => r.cellX === 48 && r.cellY === 0)
expect(col2Row0).toBeDefined()
expect(col2Row0?.cellsX).toBe(2)
expect(col2Row0?.cellsY).toBe(24)
expect(col2Row0?.width).toBe(160)
const col0Row1 = rooms.find(r => r.cellX === 0 && r.cellY === 24)
expect(col0Row1).toBeDefined()
expect(col0Row1?.cellsX).toBe(24)
expect(col0Row1?.cellsY).toBe(6)
expect(col0Row1?.height).toBe(480)
})
it('maps discrete maze rooms directly into MapRooms', () => {
const mazeRooms = [
{ x: 0, y: 0, width: 24, height: 24 },
{ x: 24, y: 0, width: 24, height: 24 },
{ x: 24, y: 24, width: 12, height: 12 },
]
const rooms = partitionMapIntoRooms(48, 48, { mazeRooms, cellPixelSize: 80 })
expect(rooms).toHaveLength(3)
expect(rooms[0]?.isPreset).toBe(true)
expect(rooms[0]?.cellX).toBe(0)
expect(rooms[0]?.cellY).toBe(0)
expect(rooms[0]?.width).toBe(1920)
expect(rooms[2]?.cellsX).toBe(12)
expect(rooms[2]?.cellsY).toBe(12)
expect(rooms[2]?.width).toBe(960)
expect(rooms[2]?.height).toBe(960)
})
it('automatically flags rooms overlapping safe zones as isSafeZone', () => {
const safeZones = [
{ x: 500, y: 500, radius: 160 },
]
const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80, safeZones })
expect(rooms).toHaveLength(6)
// Room 0 covers [0, 1920] x [0, 1920], overlaps (500, 500)
expect(rooms[0]?.isSafeZone).toBe(true)
// Distant rooms should not be safe zones
const distantRoom = rooms.find(r => r.cellX === 48 && r.cellY === 24)
expect(distantRoom?.isSafeZone).toBeUndefined()
})
})
describe('allocatePacksToRooms', () => {
it('distributes normal packs to non-safe rooms and avoids safe rooms', () => {
const rooms: MapRoom[] = [
{ id: 0, x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10, isSafeZone: true },
{ id: 1, x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 },
{ id: 2, x: 2000, y: 0, width: 1000, height: 1000, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 },
]
const packs = [
pack('fallen', 3),
pack('zombie', 4),
pack('skeleton', 3),
pack('quillrat', 2),
]
const allocation = allocatePacksToRooms(packs, rooms, 12345)
// Safe room (id: 0) must receive NO normal packs
expect(allocation.get(rooms[0]!)).toHaveLength(0)
// Remaining packs must be distributed between room 1 and room 2
const room1Count = allocation.get(rooms[1]!)?.length ?? 0
const room2Count = allocation.get(rooms[2]!)?.length ?? 0
expect(room1Count + room2Count).toBe(4)
expect(room1Count).toBeGreaterThan(0)
expect(room2Count).toBeGreaterThan(0)
})
it('balances pack counts across candidate rooms based on room area', () => {
// Room Small: 10x10 cells (area 100)
// Room Large: 20x20 cells (area 400 - 4x larger)
const roomSmall: MapRoom = { id: 'small', x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }
const roomLarge: MapRoom = { id: 'large', x: 800, y: 0, width: 1600, height: 1600, cellX: 10, cellY: 0, cellsX: 20, cellsY: 20 }
const rooms = [roomSmall, roomLarge]
const packs = Array.from({ length: 10 }, (_, i) => pack(`mob_${String(i)}`, 3))
const allocation = allocatePacksToRooms(packs, rooms, 42)
const smallCount = allocation.get(roomSmall)?.length ?? 0
const largeCount = allocation.get(roomLarge)?.length ?? 0
expect(smallCount + largeCount).toBe(10)
// Large room has 4x area, so it should receive significantly more packs without overcrowding small room
expect(largeCount).toBeGreaterThan(smallCount * 2)
})
it('routes landmark SuperUnique packs with landmarkTile to covering room', () => {
const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1920, height: 1920, cellX: 0, cellY: 0, cellsX: 24, cellsY: 24 }
const roomB: MapRoom = { id: 'B', x: 1920, y: 0, width: 1920, height: 1920, cellX: 24, cellY: 0, cellsX: 24, cellsY: 24 }
const rooms = [roomA, roomB]
// Bishibosh at landmark tile (tileX: 30, tileY: 10) falls inside room B (cellX in [24, 48))
const bishiboshPack = pack('bishibosh', 5, {
superUniqueId: 'Bishibosh',
landmarkTile: { tileX: 30, tileY: 10 },
})
const normalPack = pack('fallen', 3)
const allocation = allocatePacksToRooms([bishiboshPack, normalPack], rooms, 100)
const roomBPacks = allocation.get(roomB) ?? []
expect(roomBPacks).toContain(bishiboshPack)
})
it('routes landmark SuperUnique packs with fixedPosition to covering room', () => {
const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }
const roomB: MapRoom = { id: 'B', x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 }
const rooms = [roomA, roomB]
// Rakanishu fixed at world pixel coordinates (1500, 500) inside room B
const rakanishuPack = pack('rakanishu', 4, {
superUniqueId: 'Rakanishu',
fixedPosition: { x: 1500, y: 500 },
})
const allocation = allocatePacksToRooms([rakanishuPack], rooms)
expect(allocation.get(roomB)).toContain(rakanishuPack)
expect(allocation.get(roomA)).toHaveLength(0)
})
it('is deterministic with a given seed', () => {
const rooms: MapRoom[] = [
{ id: 1, x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 },
{ id: 2, x: 800, y: 0, width: 800, height: 800, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 },
{ id: 3, x: 1600, y: 0, width: 800, height: 800, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 },
]
const packs = Array.from({ length: 9 }, (_, i) => pack(`mob_${String(i)}`, 2))
const run1 = allocatePacksToRooms(packs, rooms, 9999)
const run2 = allocatePacksToRooms(packs, rooms, 9999)
for (const room of rooms) {
const p1 = run1.get(room)!.map(p => p.members[0]?.id)
const p2 = run2.get(room)!.map(p => p.members[0]?.id)
expect(p1).toEqual(p2)
}
})
})
describe('spawnPacksInRoom', () => {
it('places monster pack strictly within room boundaries', () => {
const world = createWorld(0, 0)
const room: MapRoom = {
id: 'test_room',
x: 1000,
y: 1000,
width: 400,
height: 400,
cellX: 10,
cellY: 10,
cellsX: 5,
cellsY: 5,
}
const testPack = pack('fallen', 6)
const placed = spawnPacksInRoom(world, testPack, room, OPEN)
expect(placed).toBe(6)
expect(world.monsters).toHaveLength(6)
// Verify every monster is strictly inside room bounds
for (const monster of world.monsters) {
expect(monster.x).toBeGreaterThanOrEqual(room.x)
expect(monster.x).toBeLessThanOrEqual(room.x + room.width)
expect(monster.y).toBeGreaterThanOrEqual(room.y)
expect(monster.y).toBeLessThanOrEqual(room.y + room.height)
}
})
it('respects terrain collisions and does not spill through walls', () => {
const world = createWorld(0, 0)
const room: MapRoom = {
id: 'walled_room',
x: 500,
y: 500,
width: 400,
height: 400,
cellX: 5,
cellY: 5,
cellsX: 5,
cellsY: 5,
}
// Vertical solid wall slicing through x in [680, 750]
const walledTerrain: CombatTerrain = {
overlap: (x: number) => (x >= 680 && x <= 750 ? 1 : 0),
}
const testPack = pack('zombie', 8)
const placed = spawnPacksInRoom(world, testPack, room, walledTerrain)
expect(placed).toBeGreaterThan(0)
for (const monster of world.monsters) {
// Must never land on solid wall
expect(walledTerrain.overlap(monster.x, monster.y)).toBe(0)
expect(monster.x < 680 || monster.x > 750).toBe(true)
// Must stay inside room
expect(monster.x).toBeGreaterThanOrEqual(room.x)
expect(monster.x).toBeLessThanOrEqual(room.x + room.width)
expect(monster.y).toBeGreaterThanOrEqual(room.y)
expect(monster.y).toBeLessThanOrEqual(room.y + room.height)
}
})
it('clusters minions tightly around leader within PACK_RADIUS_PX', () => {
const world = createWorld(0, 0)
const room: MapRoom = {
id: 'cluster_room',
x: 2000,
y: 2000,
width: 800,
height: 800,
cellX: 20,
cellY: 20,
cellsX: 10,
cellsY: 10,
}
const testPack = pack('fallen', 7)
spawnPacksInRoom(world, testPack, room, OPEN)
expect(world.monsters).toHaveLength(7)
const leader = world.monsters[0]!
const minions = world.monsters.slice(1)
for (const minion of minions) {
const dist = Math.hypot(minion.x - leader.x, minion.y - leader.y)
expect(dist).toBeLessThanOrEqual(PACK_RADIUS_PX)
}
})
it('positions leader near interior center of room on clear ground', () => {
const world = createWorld(0, 0)
const room: MapRoom = {
id: 'center_test',
x: 1000,
y: 1000,
width: 400,
height: 400,
cellX: 10,
cellY: 10,
cellsX: 5,
cellsY: 5,
}
const testPack = pack('boss', 1)
spawnPacksInRoom(world, testPack, room, OPEN)
expect(world.monsters).toHaveLength(1)
const leader = world.monsters[0]!
const roomCenterX = room.x + room.width / 2 // 1200
const roomCenterY = room.y + room.height / 2 // 1200
// Leader should land right at or very close to center
expect(Math.abs(leader.x - roomCenterX)).toBeLessThan(16)
expect(Math.abs(leader.y - roomCenterY)).toBeLessThan(16)
})
it('places fixedPosition SuperUnique at or near designated landmark coordinates', () => {
const world = createWorld(0, 0)
const room: MapRoom = {
id: 'landmark_room',
x: 0,
y: 0,
width: 1000,
height: 1000,
cellX: 0,
cellY: 0,
cellsX: 10,
cellsY: 10,
}
const testPack = pack('treehead', 1, {
superUniqueId: 'Treehead WoodFist',
fixedPosition: { x: 350, y: 450 },
})
spawnPacksInRoom(world, testPack, room, OPEN)
expect(world.monsters).toHaveLength(1)
const leader = world.monsters[0]!
expect(leader.x).toBe(350)
expect(leader.y).toBe(450)
})
})
describe('spawnMonsterPacksByRooms', () => {
it('populates world with all packs distributed across partitioned rooms', () => {
const world = createWorld(0, 0)
const rooms = partitionMapIntoRooms(48, 48, { roomCellSize: 24, cellPixelSize: 80 })
const packs = [
pack('fallen', 4),
pack('zombie', 3),
pack('skeleton', 5),
]
const placed = spawnMonsterPacksByRooms(world, packs, rooms, OPEN, { seed: 101 })
expect(placed).toBe(12)
expect(world.monsters).toHaveLength(12)
// Verify every monster lies within at least one room's bounding box
for (const m of world.monsters) {
const inAnyRoom = rooms.some(
r => m.x >= r.x && m.x <= r.x + r.width && m.y >= r.y && m.y <= r.y + r.height,
)
expect(inAnyRoom).toBe(true)
}
})
it('maintains backwards compatibility when fallback around coordinates are passed', () => {
const world = createWorld(0, 0)
const packs = [pack('fallen', 3)]
const placed = spawnMonsterPacksByRooms(
world,
packs,
{ x: 500, y: 500 },
OPEN,
{ spread: 300 },
)
expect(placed).toBe(3)
expect(world.monsters).toHaveLength(3)
})
})
})

View File

@ -0,0 +1,571 @@
import { describe, expect, it, vi } from 'vitest'
import { createWorld, damageMonster, tickCombat } from '../src/game/combat.ts'
import type { CombatOptions, CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
import { GameEngine } from '../src/game/engine.ts'
import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine.ts'
import { MonsterStreamingManager, distanceToRoom } from '../src/game/monster-streaming.ts'
import type { StreamingRoomDef, StreamingRoomState } from '../src/game/monster-streaming.ts'
/** Open ground everywhere. */
const OPEN: CombatTerrain = { overlap: () => 0 }
/** Minimal map provider for engine testing. */
const OPEN_MAP: WorldMapProvider = {
widthPx: 10000,
heightPx: 10000,
overlap: () => 0,
}
/** Helper to construct test monster stats. */
function stats(id: string, hp = 30, damage = 5): MonsterStats {
return {
id,
name: id,
hp,
damage,
cooldownTicks: 25,
reach: 40,
aggroRadius: 200,
speed: 100,
xp: 10,
}
}
/** Helper to construct a test monster pack. */
function pack(id: string, count: number, hp = 30): MonsterPack {
return {
members: Array.from({ length: count }, (_, i) => stats(id, hp + i)),
}
}
/** Default combat options for simulation. */
const DEFAULT_COMBAT_OPTIONS: CombatOptions = {
playerSpeed: 100,
playerReach: 40,
playerCooldownTicks: 25,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 100,
}
describe('distanceToRoom', () => {
const bounds = { minX: 1000, minY: 1000, maxX: 2000, maxY: 2000 }
it('returns 0 when point is inside room bounds', () => {
expect(distanceToRoom(1500, 1500, bounds)).toBe(0)
expect(distanceToRoom(1000, 1000, bounds)).toBe(0)
expect(distanceToRoom(2000, 2000, bounds)).toBe(0)
})
it('calculates orthogonal distance correctly', () => {
expect(distanceToRoom(500, 1500, bounds)).toBe(500)
expect(distanceToRoom(2600, 1500, bounds)).toBe(600)
expect(distanceToRoom(1500, 300, bounds)).toBe(700)
expect(distanceToRoom(1500, 2800, bounds)).toBe(800)
})
it('calculates diagonal distance to closest corner', () => {
// Distance from (700, 600) to (1000, 1000) is hypot(300, 400) = 500
expect(distanceToRoom(700, 600, bounds)).toBe(500)
})
})
describe('MonsterStreamingManager - Lazy Population (bPopulated)', () => {
it('populates only spawn neighborhood rooms on initialization, leaving distant rooms unpopulated', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'spawn_room',
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
packs: [pack('fallen1', 3)],
},
{
id: 'adjacent_room',
bounds: { minX: 600, minY: 0, maxX: 1000, maxY: 400 }, // distance = 500 <= 1600
packs: [pack('zombie1', 2)],
},
{
id: 'distant_room_1',
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 }, // distance ~4100 > 1600
packs: [pack('quillrat1', 4)],
},
{
id: 'distant_room_2',
bounds: { minX: 6000, minY: 6000, maxX: 6500, maxY: 6500 }, // distance ~8300 > 1600
packs: [pack('skeleton1', 3)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
// Only spawn_room (3) and adjacent_room (2) are populated
expect(world.monsters).toHaveLength(5)
const [spawnRoom, adjRoom, distRoom1, distRoom2] = mgr.rooms as [
StreamingRoomState,
StreamingRoomState,
StreamingRoomState,
StreamingRoomState,
]
expect(spawnRoom.bPopulated).toBe(true)
expect(spawnRoom.active).toBe(true)
expect(spawnRoom.monsterIndices).toEqual([0, 1, 2])
expect(adjRoom.bPopulated).toBe(true)
expect(adjRoom.active).toBe(true)
expect(adjRoom.monsterIndices).toEqual([3, 4])
// Distant rooms must NOT be populated and must have 0 monster indices
expect(distRoom1.bPopulated).toBe(false)
expect(distRoom1.active).toBe(false)
expect(distRoom1.monsterIndices).toHaveLength(0)
expect(distRoom2.bPopulated).toBe(false)
expect(distRoom2.active).toBe(false)
expect(distRoom2.monsterIndices).toHaveLength(0)
// Active monsters are awake
expect(world.monsters.every(m => !m.sleeping)).toBe(true)
})
it('triggers room activation and population when player approaches', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'spawn_room',
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
packs: [pack('fallen1', 2)],
},
{
id: 'distant_room',
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 },
packs: [pack('zombie1', 3)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
expect(world.monsters).toHaveLength(2)
expect(mgr.rooms[1]!.bPopulated).toBe(false)
// Move player close to distant room (distance to [3000, 3000] is ~282px <= 1600)
world.player.x = 2800
world.player.y = 2800
mgr.update(world.player.x, world.player.y)
const distantRoom = mgr.rooms[1]!
expect(distantRoom.bPopulated).toBe(true)
expect(distantRoom.active).toBe(true)
expect(distantRoom.monsterIndices).toEqual([2, 3, 4])
expect(world.monsters).toHaveLength(5)
// Distant monsters are active and not sleeping
for (const idx of distantRoom.monsterIndices) {
expect(world.monsters[idx]!.sleeping).toBe(false)
}
})
it('invokes onSpawnRoom callback exactly once when a room is populated', () => {
const world = createWorld(100, 100)
const onSpawnRoom = vi.fn()
const rooms: StreamingRoomDef[] = [
{
id: 'room_a',
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
packs: [pack('fallen1', 2)],
},
{
id: 'room_b',
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
packs: [pack('zombie1', 2)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
onSpawnRoom,
})
// room_a spawned on init
expect(onSpawnRoom).toHaveBeenCalledTimes(1)
expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_a', bPopulated: true }))
// Move closer to room_b
mgr.update(3000, 3000)
expect(onSpawnRoom).toHaveBeenCalledTimes(2)
expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_b', bPopulated: true }))
// Repeated updates do NOT trigger onSpawnRoom again
mgr.update(3000, 3000)
mgr.update(0, 0)
mgr.update(3000, 3000)
expect(onSpawnRoom).toHaveBeenCalledTimes(2)
})
})
describe('MonsterStreamingManager - Active / Sleeping Room Streaming', () => {
it('marks living monsters in distant rooms as sleeping and skips their AI in tickMonsters', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'room_spawn',
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
packs: [pack('fallen1', 2)],
},
{
id: 'room_far',
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
packs: [pack('zombie1', 2)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
// Populate room_far
mgr.update(3100, 3100)
expect(mgr.rooms[0]!.active).toBe(false)
expect(mgr.rooms[1]!.active).toBe(true)
// Monsters in room_spawn (indices 0, 1) are now sleeping
const sleepingMonster = world.monsters[0]!
expect(sleepingMonster.sleeping).toBe(true)
// Set cooldown and hitFlash on sleeping monster to verify tickMonsters skips it completely
sleepingMonster.cooldown = 15
sleepingMonster.hitFlash = 3
const initialMonsterX = sleepingMonster.x
const initialMonsterY = sleepingMonster.y
// Advance simulation tick: sleeping monster must skip all AI and timers
tickCombat(
world,
{ movement: { x: 0, y: 0 }, attack: false },
DEFAULT_COMBAT_OPTIONS,
OPEN,
[0, 100, 200],
)
expect(sleepingMonster.cooldown).toBe(15) // Cooldown preserved without decrementing
expect(sleepingMonster.hitFlash).toBe(3) // hitFlash preserved without decrementing
expect(sleepingMonster.x).toBe(initialMonsterX)
expect(sleepingMonster.y).toBe(initialMonsterY)
// Active monsters in room_far (e.g. index 2) are not sleeping
const activeMonster = world.monsters[2]!
expect(activeMonster.sleeping).toBe(false)
})
it('wakes sleeping monsters when player re-enters activationRadius', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'room_spawn',
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
packs: [pack('fallen1', 2)],
},
{
id: 'room_far',
bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 },
packs: [pack('zombie1', 2)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
// Move player far away: room_spawn deactivates
mgr.update(4000, 4000)
expect(mgr.rooms[0]!.active).toBe(false)
expect(world.monsters[0]!.sleeping).toBe(true)
// Move player back: room_spawn wakes up
mgr.update(100, 100)
expect(mgr.rooms[0]!.active).toBe(true)
expect(world.monsters[0]!.sleeping).toBe(false)
expect(world.monsters[1]!.sleeping).toBe(false)
})
it('preserves monster damage, cooldowns, and positions across sleeping and wake cycles', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'room_spawn',
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
packs: [pack('fallen1', 2, 50)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
const target = world.monsters[0]!
const initialHp = target.hp
damageMonster(world, 0, 18) // HP becomes initialHp - 18
target.cooldown = 14
const preservedX = target.x
const preservedY = target.y
// Deactivate room
mgr.update(4000, 4000)
expect(mgr.rooms[0]!.active).toBe(false)
expect(target.sleeping).toBe(true)
// Simulate several ticks while monster is sleeping
for (let i = 0; i < 5; i += 1) {
tickCombat(
world,
{ movement: { x: 0, y: 0 }, attack: false },
DEFAULT_COMBAT_OPTIONS,
OPEN,
[0, 100, 200],
)
}
// Wake room back up
mgr.update(100, 100)
expect(mgr.rooms[0]!.active).toBe(true)
expect(target.sleeping).toBe(false)
expect(target.hp).toBe(initialHp - 18)
expect(target.cooldown).toBe(14)
expect(target.x).toBe(preservedX)
expect(target.y).toBe(preservedY)
})
it('respects hysteresis between activationRadius and deactivationRadius', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'room_spawn',
bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 },
packs: [pack('fallen1', 1)],
},
{
id: 'room_mid',
bounds: { minX: 2000, minY: 0, maxX: 2200, maxY: 300 }, // distance from (100, 100) is 1900
packs: [pack('zombie1', 1)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
const roomSpawn = mgr.rooms[0]!
const roomMid = mgr.rooms[1]!
// Initial state: player at 100, 100
// roomSpawn is active (dist = 0 <= 1600)
// roomMid is at dist 1900 (between 1600 and 2400), so it stays unpopulated & inactive
expect(roomSpawn.active).toBe(true)
expect(roomMid.bPopulated).toBe(false)
expect(roomMid.active).toBe(false)
// Player moves to dist 2000 from roomSpawn:
// roomSpawn was already active, so it STAYS active (dist 2000 <= 2400)
mgr.update(2100, 100)
// Now player is inside roomMid! (x: 2100 is between 2000 and 2200)
expect(roomMid.bPopulated).toBe(true)
expect(roomMid.active).toBe(true)
// roomSpawn distance is 2100 - 300 = 1800 <= 2400, so it remains active
expect(roomSpawn.active).toBe(true)
// Player moves farther to x: 2800 (distance from roomSpawn: 2500 > 2400)
mgr.update(2800, 100)
expect(roomSpawn.active).toBe(false)
// Player steps back to x: 2300 (distance from roomSpawn: 2000, between 1600 and 2400)
// roomSpawn was sleeping, so it STAYS sleeping until <= 1600!
mgr.update(2300, 100)
expect(roomSpawn.active).toBe(false)
// Player steps closer to x: 1800 (distance from roomSpawn: 1500 <= 1600)
// roomSpawn wakes up!
mgr.update(1800, 100)
expect(roomSpawn.active).toBe(true)
})
})
describe('MonsterStreamingManager - Idempotency & Non-Respawn', () => {
it('never re-populates a room once bPopulated is true: dead monsters stay dead when returning', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'room_a',
bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 },
packs: [pack('fallen1', 3)],
},
{
id: 'room_b',
bounds: { minX: 3000, minY: 3000, maxX: 3400, maxY: 3400 },
packs: [pack('zombie1', 3)],
},
]
const mgr = new MonsterStreamingManager(world, OPEN, rooms, {
activationRadius: 1600,
deactivationRadius: 2400,
})
expect(world.monsters).toHaveLength(3)
// Approach room_b to populate it
mgr.update(3200, 3200)
expect(world.monsters).toHaveLength(6)
const roomB = mgr.rooms[1]!
expect(roomB.bPopulated).toBe(true)
// Kill all monsters in room_b
for (const idx of roomB.monsterIndices) {
damageMonster(world, idx, 9999)
expect(world.monsters[idx]!.state).toBe('dead')
}
const totalMonstersBeforeLeave = world.monsters.length
expect(totalMonstersBeforeLeave).toBe(6)
// Player leaves room_b far away
mgr.update(100, 100)
expect(roomB.active).toBe(false)
// Player returns to room_b
mgr.update(3200, 3200)
expect(roomB.active).toBe(true)
expect(roomB.bPopulated).toBe(true)
// Verification: NO new monsters are spawned
expect(world.monsters).toHaveLength(totalMonstersBeforeLeave)
for (const idx of roomB.monsterIndices) {
expect(world.monsters[idx]!.state).toBe('dead')
}
})
})
describe('GameEngine Integration with MonsterStreamingManager', () => {
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [stats('fallen1'), stats('zombie1')],
xpTable: [0, 0, 10, 30, 60],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: DEFAULT_COMBAT_OPTIONS,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 0,
}
it('automatically initializes MonsterStreamingManager when roomPacks are provided', () => {
const rooms: StreamingRoomDef[] = [
{
id: 'spawn_room',
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
packs: [pack('fallen1', 2)],
},
{
id: 'distant_room',
bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 },
packs: [pack('zombie1', 3)],
},
]
const engine = new GameEngine(OPEN_MAP, {
...dummyOpts,
roomPacks: rooms,
})
expect(engine.streamingManager).toBeDefined()
expect(engine.streamingManager?.rooms).toHaveLength(2)
// Only spawn room monsters are created initially
expect(engine.world.monsters).toHaveLength(2)
expect(engine.streamingManager?.rooms[0]!.bPopulated).toBe(true)
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false)
})
it('calls streamingManager.update in GameEngine.tick to stream rooms as player moves', () => {
const rooms: StreamingRoomDef[] = [
{
id: 'spawn_room',
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
packs: [pack('fallen1', 2)],
},
{
id: 'distant_room',
bounds: { minX: 1200, minY: 100, maxX: 1500, maxY: 500 }, // distance ~700 from (500, 100)
packs: [pack('zombie1', 3)],
},
]
const engine = new GameEngine(OPEN_MAP, {
...dummyOpts,
roomPacks: rooms,
streamingOptions: {
activationRadius: 800,
deactivationRadius: 1400,
},
})
// Initially at (100, 100), distance to distant_room is 1100 > 800 -> unpopulated
expect(engine.world.monsters).toHaveLength(2)
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false)
// Teleport player closer to distant room
engine.world.player.x = 800
engine.world.player.y = 100
// Next engine.tick should update streaming manager and populate distant room
engine.tick({
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
})
expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(true)
expect(engine.world.monsters).toHaveLength(5)
})
it('accepts pre-constructed streamingManager in GameEngineOptions', () => {
const world = createWorld(100, 100)
const rooms: StreamingRoomDef[] = [
{
id: 'room_1',
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
packs: [pack('fallen1', 2)],
},
]
const streamingManager = new MonsterStreamingManager(world, OPEN, rooms)
const engine = new GameEngine(OPEN_MAP, {
...dummyOpts,
streamingManager,
})
expect(engine.streamingManager).toBe(streamingManager)
expect(engine.world).toBe(world)
expect(engine.world.monsters).toHaveLength(2)
})
})

237
tests/preset.test.ts Normal file
View File

@ -0,0 +1,237 @@
import { describe, expect, test } from "vitest"
import {
generatePreset,
generateChaosSanctuary,
CHAOS_SANCTUARY_LEVEL_ID,
CHAOS_SANCTUARY_SIZE_X,
CHAOS_SANCTUARY_SIZE_Y,
CHAOS_SANCTUARY_PLACEMENTS,
} from "../src/game/preset.ts"
import type { PresetPiece } from "../src/game/preset.ts"
import { generateWilderness } from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, style = 1): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 2, sequence: 0, style, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style, unknown1: 0, unknown2: 0, hidden: false }],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 4,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
describe("Preset Map Pipeline (DrlgType 2)", () => {
test("generatePreset generates Level 108 Chaos Sanctuary stitching 7 cross-wing presets on 120x120 canvas", () => {
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces: PresetPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(24, 24, 10 + idx)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, levels: [ds1] }
})
const result = generatePreset({
levelId: CHAOS_SANCTUARY_LEVEL_ID,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: CHAOS_SANCTUARY_SIZE_X,
sizeY: CHAOS_SANCTUARY_SIZE_Y,
seed: 108,
pieces,
})
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
expect(result.level.act).toBe(4)
expect(result.stats.substitutedClusters).toBe(7)
expect(result.stats.substitutions.length).toBe(7)
expect(result.level.objects.length).toBe(7)
expect(result.stats.groundCells).toBeGreaterThan(0)
// Verify object placement offsets (5 sub-tiles per cell)
// Arm N at (48, 0): object at (48*5 + 5, 0*5 + 5) = (245, 5)
const armNObj = result.level.objects.find(o => o.id === 200)
expect(armNObj).toBeDefined()
expect(armNObj?.x).toBe(48 * 5 + 5)
expect(armNObj?.y).toBe(0 * 5 + 5)
// Arm W at (24, 24): object at (24*5 + 5, 24*5 + 5) = (125, 125)
const armWObj = result.level.objects.find(o => o.id === 201)
expect(armWObj).toBeDefined()
expect(armWObj?.x).toBe(24 * 5 + 5)
expect(armWObj?.y).toBe(24 * 5 + 5)
// Heart at (48, 24): object at (48*5 + 5, 24*5 + 5) = (245, 125)
const heartObj = result.level.objects.find(o => o.id === 202)
expect(heartObj).toBeDefined()
expect(heartObj?.x).toBe(48 * 5 + 5)
expect(heartObj?.y).toBe(24 * 5 + 5)
// Bridge 2 at (48, 96): object at (48*5 + 5, 96*5 + 5) = (245, 485)
const bridgeObj = result.level.objects.find(o => o.id === 206)
expect(bridgeObj).toBeDefined()
expect(bridgeObj?.x).toBe(48 * 5 + 5)
expect(bridgeObj?.y).toBe(96 * 5 + 5)
// Verify tile cell content stamped at origins
expect(result.level.cells[0]?.[48]?.floors[0]?.style).toBe(10) // Arm N style
expect(result.level.cells[24]?.[24]?.floors[0]?.style).toBe(11) // Arm W style
expect(result.level.cells[24]?.[48]?.floors[0]?.style).toBe(12) // Heart style
expect(result.level.cells[96]?.[48]?.floors[0]?.style).toBe(16) // Bridge style
})
test("generatePreset dispatches by levelName when levelId is not 108", () => {
const pieces: PresetPiece[] = CHAOS_SANCTUARY_PLACEMENTS.map((item, idx) => ({
name: item.name,
levels: [makeMockDs1(24, 24, 5)],
}))
const result = generatePreset({
levelId: 0,
levelName: "Act 4 - Chaos Sanctuary",
pieces,
})
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
expect(result.stats.substitutedClusters).toBe(7)
})
test("generateChaosSanctuary matches piece names flexibly (tokens, stripped prefixes, filenames)", () => {
// Pieces with alternative naming styles (e.g. without 'Act 4 - ', or file paths)
const pieces: PresetPiece[] = [
{ name: "Diablo Arm N", levels: [makeMockDs1(24, 24, 1)] },
{ name: "data\\global\\tiles\\act4\\diablo\\ArmW.ds1", levels: [makeMockDs1(24, 24, 2)] },
{ name: "Act 4 - Diablo Heart", levels: [makeMockDs1(24, 24, 3)] },
{ name: "Diablo Arm E", levels: [makeMockDs1(24, 24, 4)] },
{ name: "Arm S", levels: [makeMockDs1(24, 24, 5)] },
{ name: "Diablo Entry", levels: [makeMockDs1(24, 24, 6)] },
{ name: "Bridge 2", levels: [makeMockDs1(24, 24, 7)] },
]
const result = generateChaosSanctuary({
levelId: 108,
pieces,
})
expect(result.stats.substitutedClusters).toBe(7)
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
})
test("generatePreset handles standalone single-DS1 preset levels", () => {
const baseDs1 = makeMockDs1(40, 40, 7)
const mockDs1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 99, x: 10, y: 10, flags: 0 }],
}
const pieces: PresetPiece[] = [
{
name: "Tristram",
levels: [mockDs1],
},
]
const result = generatePreset({
levelId: 38,
levelName: "Tristram",
sizeX: 40,
sizeY: 40,
pieces,
})
expect(result.level.width).toBe(40)
expect(result.level.height).toBe(40)
expect(result.level.objects.length).toBe(1)
expect(result.stats.substitutedClusters).toBe(1)
})
test("generatePreset supports custom piece placements", () => {
const pieces: PresetPiece[] = [
{ name: "RoomA", levels: [makeMockDs1(20, 20, 1)] },
{ name: "RoomB", levels: [makeMockDs1(20, 20, 2)] },
]
const result = generatePreset({
levelId: 999,
sizeX: 60,
sizeY: 60,
pieces,
placements: [
{ name: "RoomA", x: 0, y: 0 },
{ name: "RoomB", x: 30, y: 30 },
],
})
expect(result.level.width).toBe(60)
expect(result.level.height).toBe(60)
expect(result.stats.substitutedClusters).toBe(2)
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(1)
expect(result.level.cells[30]?.[30]?.floors[0]?.style).toBe(2)
})
test("wilderness generator no longer hardcodes Level 108 Chaos Sanctuary special case", () => {
// Wilderness generator does not hijack Level 108 anymore; without special preset routing,
// it will not produce the 7-cluster Chaos Sanctuary layout.
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces = names.map(name => ({
name,
border: false,
levels: [makeMockDs1(24, 24)],
}))
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
// Previously this returned substitutedClusters: 7 via stampAct4ChaosSanctuary.
// Now wilderness treats it as regular wilderness, not the 7-preset Chaos Sanctuary.
expect(result.stats.substitutedClusters).not.toBe(7)
})
})

View File

@ -0,0 +1,556 @@
import { describe, test, expect } from 'vitest'
import { SpriteRenderer, RendererError, type AtlasHandle } from '../src/render/renderer.ts'
import { buildBounds, intersects, viewportRect, CULL_MARGIN } from '../src/render/cull.ts'
import { countLoadedPages } from '../src/scene/act-scene.ts'
interface MockResource {
id: number
type: string
}
function createMockCanvas(): {
canvas: any
stats: {
createdTextures: Set<MockResource>
deletedTextures: Set<MockResource>
createdBuffers: Set<MockResource>
deletedBuffers: Set<MockResource>
createdVAOs: Set<MockResource>
deletedVAOs: Set<MockResource>
createdPrograms: Set<MockResource>
deletedPrograms: Set<MockResource>
createdShaders: Set<MockResource>
deletedShaders: Set<MockResource>
eventListeners: Map<string, Set<EventListener>>
glDrawCalls: number
shaderSources: string[]
}
setContextLost: (lost: boolean) => void
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
} {
let nextId = 1
let contextLost = false
const stats = {
createdTextures: new Set<MockResource>(),
deletedTextures: new Set<MockResource>(),
createdBuffers: new Set<MockResource>(),
deletedBuffers: new Set<MockResource>(),
createdVAOs: new Set<MockResource>(),
deletedVAOs: new Set<MockResource>(),
createdPrograms: new Set<MockResource>(),
deletedPrograms: new Set<MockResource>(),
createdShaders: new Set<MockResource>(),
deletedShaders: new Set<MockResource>(),
eventListeners: new Map<string, Set<EventListener>>(),
glDrawCalls: 0,
shaderSources: [] as string[],
}
const gl: any = {
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
MAX_TEXTURE_SIZE: 3379,
MAX_TEXTURE_IMAGE_UNITS: 34930,
TEXTURE_2D: 3553,
RGBA: 6408,
R8: 33321,
RED: 6403,
UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125,
FLOAT: 5126,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
DYNAMIC_DRAW: 35048,
STATIC_DRAW: 35044,
TRIANGLES: 4,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
CLAMP_TO_EDGE: 33071,
NEAREST: 9728,
UNPACK_ALIGNMENT: 3317,
BLEND: 3042,
SRC_ALPHA: 770,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 1280,
drawingBufferHeight: 720,
isContextLost: () => contextLost,
createProgram: () => {
const res: MockResource = { id: nextId++, type: 'program' }
stats.createdPrograms.add(res)
return res
},
deleteProgram: (p: MockResource) => { stats.deletedPrograms.add(p) },
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: (type: number) => {
const res: MockResource = { id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }
stats.createdShaders.add(res)
return res
},
shaderSource: (_shader: MockResource, source: string) => { stats.shaderSources.push(source) },
compileShader: () => {},
getShaderParameter: () => true,
getShaderInfoLog: () => '',
deleteShader: (s: MockResource) => { stats.deletedShaders.add(s) },
createVertexArray: () => {
const res: MockResource = { id: nextId++, type: 'vao' }
stats.createdVAOs.add(res)
return res
},
deleteVertexArray: (vao: MockResource) => { stats.deletedVAOs.add(vao) },
bindVertexArray: () => {},
createBuffer: () => {
const res: MockResource = { id: nextId++, type: 'buffer' }
stats.createdBuffers.add(res)
return res
},
deleteBuffer: (b: MockResource) => { stats.deletedBuffers.add(b) },
bindBuffer: () => {},
bufferData: () => {},
bufferSubData: () => {},
getAttribLocation: (_p: any, name: string) => (
name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : name === 'a_unit' ? 3 : 4
),
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
createTexture: () => {
const res: MockResource = { id: nextId++, type: 'texture' }
stats.createdTextures.add(res)
return res
},
deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) },
bindTexture: () => {},
texParameteri: () => {},
texImage2D: () => {},
pixelStorei: () => {},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: () => {},
uniform1f: () => {},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: () => {},
viewport: () => {},
clearColor: () => {},
clear: () => {},
activeTexture: () => {},
drawArrays: () => { stats.glDrawCalls += 1 },
drawElements: () => { stats.glDrawCalls += 1 },
}
const eventListeners = stats.eventListeners
const canvas: any = {
getContext: (type: string) => (type === 'webgl2' ? gl : null),
addEventListener: (type: string, listener: EventListener) => {
if (!eventListeners.has(type)) eventListeners.set(type, new Set())
eventListeners.get(type)!.add(listener)
},
removeEventListener: (type: string, listener: EventListener) => {
eventListeners.get(type)?.delete(listener)
},
width: 1280,
height: 720,
drawingBufferWidth: 1280,
drawingBufferHeight: 720,
}
return {
canvas,
stats,
setContextLost: (lost: boolean) => { contextLost = lost },
dispatchMockEvent: (type: string) => {
let defaultPrevented = false
const event: any = {
type,
preventDefault: () => { defaultPrevented = true },
}
const set = eventListeners.get(type)
if (set) {
for (const listener of set) {
listener(event)
}
}
return { defaultPrevented }
},
}
}
function createAtlasPage(renderer: SpriteRenderer, width = 32, height = 32): AtlasHandle {
return renderer.addAtlas({ pixels: new Uint8ClampedArray(width * height * 4), width, height })
}
describe('多纹理合批与 Draw Call 预算 (方案 B / Issue #16)', () => {
test('多图集页关卡在画家顺序重度交错下维持 Draw Call <= 1', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
// 典型多图集关卡构成:
// 地面(2页)+ 墙体(2页)+ 屋顶(1页)+ 物件(1页)+ 角色(1页)+ 怪物(2页)= 9页
const pages = Array.from({ length: 9 }, () => createAtlasPage(renderer))
const frame = { x: 0, y: 0, width: 32, height: 32 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const initialCalls = mock.stats.glDrawCalls
// 1. 地面层(400 个四边形在 page 0 和 page 1 交替)
for (let i = 0; i < 400; i += 1) {
renderer.draw(frame, i * 16, 0, { atlas: pages[i % 2] })
}
// 2. 墙体与物件深度归并层(600 个四边形在 pages 2, 3, 5 频繁交替)
for (let i = 0; i < 600; i += 1) {
const p = i % 3 === 0 ? 5 : i % 3 === 1 ? 2 : 3
renderer.draw(frame, i * 16, 50, { atlas: pages[p] })
}
// 3. 实体层:玩家与怪物(150 个四边形在 pages 6, 7, 8 频繁交替)
for (let i = 0; i < 150; i += 1) {
renderer.draw(frame, i * 20, 100, { atlas: pages[6 + (i % 3)] })
}
// 4. 屋顶层(200 个四边形在 page 4)
for (let i = 0; i < 200; i += 1) {
renderer.draw(frame, i * 16, 150, { atlas: pages[4] })
}
renderer.flush()
expect(renderer.quadsSubmitted).toBe(1350)
expect(mock.stats.glDrawCalls - initialCalls).toBe(1)
expect(renderer.drawCalls).toBe(1)
renderer.dispose()
})
test('精灵纹理与纯色矩形混合绘制不打断合批(单次 Draw Call)', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const pageA = createAtlasPage(renderer)
const pageB = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 16, height: 16 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const initialCalls = mock.stats.glDrawCalls
for (let i = 0; i < 50; i += 1) {
renderer.draw(frame, i * 8, 0, { atlas: i % 2 === 0 ? pageA : pageB })
renderer.drawSolid(i * 8, 20, 10, 10, [1, 0.5, 0, 1])
}
renderer.flush()
expect(renderer.quadsSubmitted).toBe(100)
expect(mock.stats.glDrawCalls - initialCalls).toBe(1)
expect(renderer.drawCalls).toBe(1)
renderer.dispose()
})
test('超出 GPU 纹理单元上限时严格按需断批,保持最小 Draw Call 开销', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const units = renderer.textureUnits
expect(units).toBeGreaterThanOrEqual(8)
// 创建 units + 3 个不同纹理页
const totalPages = units + 3
const pages = Array.from({ length: totalPages }, () => createAtlasPage(renderer))
const frame = { x: 0, y: 0, width: 8, height: 8 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
const initialCalls = mock.stats.glDrawCalls
// 前 units 个页在 batch 1
for (let i = 0; i < units; i += 1) {
renderer.draw(frame, i * 10, 0, { atlas: pages[i] })
}
// 接下来 3 个页触发断批,进入 batch 2
for (let i = units; i < totalPages; i += 1) {
renderer.draw(frame, i * 10, 0, { atlas: pages[i] })
}
renderer.flush()
const issuedCalls = mock.stats.glDrawCalls - initialCalls
expect(issuedCalls).toBe(2)
expect(renderer.drawCalls).toBe(2)
expect(renderer.drawCalls).toBeLessThanOrEqual(Math.ceil(totalPages / units))
renderer.dispose()
})
test('begin() 重置每帧计数器,防止跨帧累加污染性能监控', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 10, height: 10 }
renderer.begin({ x: 0, y: 0, zoom: 1 })
renderer.draw(frame, 0, 0, { atlas: page })
renderer.flush()
expect(renderer.drawCalls).toBe(1)
expect(renderer.quadsSubmitted).toBe(1)
renderer.begin({ x: 0, y: 0, zoom: 1 })
expect(renderer.drawCalls).toBe(0)
expect(renderer.quadsSubmitted).toBe(0)
renderer.dispose()
})
})
describe('视口剔除与剔除率守卫 (方案 A / Issue #16)', () => {
test('大关卡 (80x80 = 6400 四边形) 视口剔除率稳定超过 80%', () => {
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
const cols = 80
const rows = 80
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < cols; x += 1) {
quads.push({
x: x * 80,
y: y * 40,
frame: { width: 80, height: 40 },
})
}
}
expect(quads.length).toBe(6400)
const bounds = buildBounds(quads)
// 相机放置于地图中央 (3200, 1600),视口 1280x720,缩放 1.0
const view = viewportRect(3200, 1600, 1280, 720, 1.0)
let visible = 0
let culled = 0
const visibleIndices: number[] = []
for (let i = 0; i < quads.length; i += 1) {
if (intersects(bounds, i, view)) {
visible += 1
visibleIndices.push(i)
} else {
culled += 1
}
}
const cullingRatio = culled / quads.length
expect(visible + culled).toBe(quads.length)
// 80x80 关卡在 1280x720 视口下实测剔除率约为 94%
expect(cullingRatio).toBeGreaterThan(0.80)
expect(cullingRatio).toBeGreaterThanOrEqual(0.88)
// 严格断言画家算法顺序:保留的四边形相对下标必须单调递增,不能错乱
for (let i = 1; i < visibleIndices.length; i += 1) {
expect(visibleIndices[i]).toBeGreaterThan(visibleIndices[i - 1]!)
}
})
test('放大 (zoom = 2.0) 时视口变小,剔除率进一步提高', () => {
const quads: { x: number; y: number; frame: { width: number; height: number } }[] = []
for (let y = 0; y < 60; y += 1) {
for (let x = 0; x < 60; x += 1) {
quads.push({ x: x * 80, y: y * 40, frame: { width: 80, height: 40 } })
}
}
const bounds = buildBounds(quads)
const view1x = viewportRect(2400, 1200, 1280, 720, 1.0)
const view2x = viewportRect(2400, 1200, 1280, 720, 2.0)
let culled1x = 0
let culled2x = 0
for (let i = 0; i < quads.length; i += 1) {
if (!intersects(bounds, i, view1x)) culled1x += 1
if (!intersects(bounds, i, view2x)) culled2x += 1
}
const ratio1x = culled1x / quads.length
const ratio2x = culled2x / quads.length
expect(ratio1x).toBeGreaterThan(0.80)
expect(ratio2x).toBeGreaterThan(ratio1x)
})
test('边界临界条件:搭界不剔除、屏外大精灵不剔除', () => {
const view = viewportRect(1000, 1000, 800, 600, 1)
// view: left: 1000 - 400 - 32 = 568, right: 1432, top: 1000 - 300 - 32 = 668, bottom: 1332
// 1. 完全屏外
const farAway = buildBounds([{ x: 0, y: 0, frame: { width: 10, height: 10 } }])
expect(intersects(farAway, 0, view)).toBe(false)
// 2. 恰好搭在边界边缘(利用 CULL_MARGIN 余量)
const borderQuad = buildBounds([{ x: 570, y: 700, frame: { width: 10, height: 10 } }])
expect(intersects(borderQuad, 0, view)).toBe(true)
// 3. 锚点在屏外上方 (x: 1000, y: 0),但精灵尺寸达 200x800,覆盖到底部
const tallSprite = buildBounds([{ x: 1000, y: 0, frame: { width: 200, height: 800 } }])
expect(intersects(tallSprite, 0, view)).toBe(true)
})
})
describe('HUD 动态度量与真实性能指标', () => {
test('HUD 数据源自渲染器实时测量值,而非硬编码假值', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 16, height: 16 }
// Frame 1 模拟:提交 150 个四边形,剔除 1850 个,耗时真实记录
const start1 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 150; i += 1) {
renderer.draw(frame, i * 4, 0, { atlas: page })
}
renderer.flush()
const renderMs1 = performance.now() - start1
const state1 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 1850,
renderMs: renderMs1,
}
const hudText1 = `${String(state1.quadsDrawn)} 图元 / ${String(state1.drawCalls)} 批次(剔除 ${String(state1.culledDraws)})· ${state1.renderMs.toFixed(1)} ms`
expect(state1.drawCalls).toBe(1)
expect(state1.quadsDrawn).toBe(150)
expect(state1.culledDraws).toBe(1850)
expect(state1.renderMs).toBeGreaterThanOrEqual(0)
expect(hudText1).toContain('150 图元 / 1 批次(剔除 1850)')
// Frame 2 模拟:多图集导致 2 次 Draw Call,提交 300 个四边形,剔除 3700 个
const overPages = Array.from({ length: renderer.textureUnits + 1 }, () => createAtlasPage(renderer))
const start2 = performance.now()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 250; i += 1) {
renderer.draw(frame, i * 4, 0, { atlas: overPages[i % renderer.textureUnits] })
}
for (let i = 0; i < 50; i += 1) {
renderer.draw(frame, i * 4, 50, { atlas: overPages[renderer.textureUnits] })
}
renderer.flush()
const renderMs2 = performance.now() - start2
const state2 = {
drawCalls: renderer.drawCalls,
quadsDrawn: renderer.quadsSubmitted,
culledDraws: 3700,
renderMs: renderMs2,
}
const hudText2 = `${String(state2.quadsDrawn)} 图元 / ${String(state2.drawCalls)} 批次(剔除 ${String(state2.culledDraws)})· ${state2.renderMs.toFixed(1)} ms`
// 验证不是硬编码的常量
expect(state2.drawCalls).toBe(2)
expect(state2.quadsDrawn).toBe(300)
expect(state2.culledDraws).toBe(3700)
expect(hudText2).toContain('300 图元 / 2 批次(剔除 3700)')
expect(state2.drawCalls).not.toBe(state1.drawCalls)
expect(state2.quadsDrawn).not.toBe(state1.quadsDrawn)
renderer.dispose()
})
})
describe('零分配顶点写入与 GPU 资源生命周期审计 (方案 C & 方案 D/E/G 护栏)', () => {
test('动态顶点缓冲容量自动翻倍,并保全已有几何数据', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page = createAtlasPage(renderer)
const frame = { x: 0, y: 0, width: 8, height: 8 }
// 初始容量 8192,这里提交 10,000 个四边形触发 grow()
renderer.begin({ x: 0, y: 0, zoom: 1 })
for (let i = 0; i < 10000; i += 1) {
renderer.draw(frame, i % 100, i % 100, { atlas: page })
}
expect(renderer.quadsSubmitted).toBe(10000)
renderer.flush()
expect(renderer.drawCalls).toBe(1)
renderer.dispose()
})
test('countLoadedPages 统计加载页数零临时数组分配', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
const page1 = createAtlasPage(renderer)
const page2 = createAtlasPage(renderer)
const pages = [page1, null, page2, undefined, null]
expect(countLoadedPages(pages as any)).toBe(2)
const allNull = [null, null, null]
expect(countLoadedPages(allNull)).toBe(0)
const empty: any[] = []
expect(countLoadedPages(empty)).toBe(0)
renderer.dispose()
})
test('WebGL 着色器与纹理生命周期完整释放,无显存泄漏', () => {
const mock = createMockCanvas()
const renderer = new SpriteRenderer(mock.canvas)
expect(mock.stats.createdShaders.size).toBe(2)
expect(mock.stats.deletedShaders.size).toBe(2)
expect(mock.stats.createdPrograms.size).toBe(1)
expect(mock.stats.createdTextures.size).toBe(3) // atlas + white + palette
const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
expect(mock.stats.createdTextures.size).toBe(4)
// 删除单个 atlas
renderer.deleteAtlas(extra)
expect(mock.stats.deletedTextures.has(extra.texture as any)).toBe(true)
// 释放整个渲染器
renderer.dispose()
expect(renderer.isDisposed).toBe(true)
expect(mock.stats.deletedTextures.size).toBe(4)
expect(mock.stats.deletedBuffers.size).toBe(2) // vertex + index buffer
expect(mock.stats.deletedVAOs.size).toBe(1)
expect(mock.stats.deletedPrograms.size).toBe(1)
})
test('上下文丢失时安全降级与清理', () => {
const mock = createMockCanvas()
let lostTriggered = false
const renderer = new SpriteRenderer(mock.canvas, {
onContextLost: () => { lostTriggered = true },
})
const eventResult = mock.dispatchMockEvent('webglcontextlost')
expect(eventResult.defaultPrevented).toBe(true)
expect(lostTriggered).toBe(true)
mock.setContextLost(true)
// 在上下文已丢失状态下 dispose 不应报错
expect(() => { renderer.dispose() }).not.toThrow()
expect(renderer.isDisposed).toBe(true)
})
})

View File

@ -0,0 +1,428 @@
import { describe, test, expect } from 'vitest'
import {
SpriteRenderer,
vertexShaderSource,
fragmentShaderSource,
type AtlasHandle,
} from '../src/render/renderer.ts'
interface MockResource {
id: number
type: string
}
function createMockCanvas(hasTexStorage = true): {
canvas: any
gl: any
stats: {
createdTextures: Set<MockResource>
deletedTextures: Set<MockResource>
texStorage2DCalls: Array<{ target: number; levels: number; internalformat: number; width: number; height: number }>
texSubImage2DCalls: Array<{ target: number; level: number; xoffset: number; yoffset: number; width: number; height: number; format: number; type: number; data: any }>
texImage2DCalls: Array<any>
uniformCameraCalls: number
uniformViewportCalls: number
uniformZoomCalls: number
uniformUploadCalls: number
shaderSources: string[]
}
} {
let nextId = 1
const stats = {
createdTextures: new Set<MockResource>(),
deletedTextures: new Set<MockResource>(),
texStorage2DCalls: [] as Array<{ target: number; levels: number; internalformat: number; width: number; height: number }>,
texSubImage2DCalls: [] as Array<{ target: number; level: number; xoffset: number; yoffset: number; width: number; height: number; format: number; type: number; data: any }>,
texImage2DCalls: [] as Array<any>,
uniformCameraCalls: 0,
uniformViewportCalls: 0,
uniformZoomCalls: 0,
uniformUploadCalls: 0,
shaderSources: [] as string[],
}
const gl: any = {
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
MAX_TEXTURE_SIZE: 3379,
MAX_TEXTURE_IMAGE_UNITS: 34930,
TEXTURE_2D: 3553,
RGBA: 6408,
RGBA8: 32856,
R8: 33321,
RED: 6403,
UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125,
FLOAT: 5126,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
DYNAMIC_DRAW: 35048,
STATIC_DRAW: 35044,
TRIANGLES: 4,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
CLAMP_TO_EDGE: 33071,
NEAREST: 9728,
UNPACK_ALIGNMENT: 3317,
BLEND: 3042,
SRC_ALPHA: 770,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 800,
drawingBufferHeight: 600,
isContextLost: () => false,
createProgram: () => ({ id: nextId++, type: 'program' }),
deleteProgram: () => {},
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vs' : 'fs' }),
shaderSource: (_shader: any, source: string) => { stats.shaderSources.push(source) },
compileShader: () => {},
getShaderParameter: () => true,
getShaderInfoLog: () => '',
deleteShader: () => {},
createVertexArray: () => ({ id: nextId++, type: 'vao' }),
deleteVertexArray: () => {},
bindVertexArray: () => {},
createBuffer: () => ({ id: nextId++, type: 'buffer' }),
deleteBuffer: () => {},
bindBuffer: () => {},
bufferData: () => {},
bufferSubData: () => {},
getAttribLocation: (_p: any, name: string) => (
name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : name === 'a_unit' ? 3 : 4
),
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
createTexture: () => {
const res: MockResource = { id: nextId++, type: 'texture' }
stats.createdTextures.add(res)
return res
},
deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) },
bindTexture: () => {},
texParameteri: () => {},
pixelStorei: () => {},
texImage2D: (...args: any[]) => {
stats.texImage2DCalls.push(args)
},
texSubImage2D: (
target: number,
level: number,
xoffset: number,
yoffset: number,
width: number,
height: number,
format: number,
type: number,
data: any,
) => {
stats.texSubImage2DCalls.push({ target, level, xoffset, yoffset, width, height, format, type, data })
},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: (loc: any, x: number, y: number) => {
stats.uniformUploadCalls += 1
if (loc?.name === 'u_camera') stats.uniformCameraCalls += 1
if (loc?.name === 'u_viewport') stats.uniformViewportCalls += 1
},
uniform1f: (loc: any, x: number) => {
stats.uniformUploadCalls += 1
if (loc?.name === 'u_zoom') stats.uniformZoomCalls += 1
},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: () => {},
viewport: () => {},
clearColor: () => {},
clear: () => {},
activeTexture: () => {},
drawArrays: () => {},
drawElements: () => {},
}
if (hasTexStorage) {
gl.texStorage2D = (target: number, levels: number, internalformat: number, width: number, height: number) => {
stats.texStorage2DCalls.push({ target, levels, internalformat, width, height })
}
}
const canvas: any = {
getContext: (type: string) => (type === 'webgl2' ? gl : null),
addEventListener: () => {},
removeEventListener: () => {},
drawingBufferWidth: 800,
drawingBufferHeight: 600,
}
return { canvas, gl, stats }
}
describe('WebGL2 SpriteRenderer Performance Tuning (Issue #20)', () => {
describe('Immutable Texture Storage (texStorage2D + texSubImage2D)', () => {
test('allocates whiteTexture and paletteTexture with texStorage2D in constructor', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
expect(stats.texStorage2DCalls.length).toBeGreaterThanOrEqual(2)
// whiteTexture: 1x1 RGBA8
const whiteStorage = stats.texStorage2DCalls.find(call => call.width === 1 && call.height === 1)
expect(whiteStorage).toBeDefined()
expect(whiteStorage?.internalformat).toBe(32856) // gl.RGBA8
expect(whiteStorage?.levels).toBe(1)
// paletteTexture: 256x5 RGBA8
const palStorage = stats.texStorage2DCalls.find(call => call.width === 256 && call.height === 5)
expect(palStorage).toBeDefined()
expect(palStorage?.internalformat).toBe(32856) // gl.RGBA8
// Both should have data uploaded via texSubImage2D
expect(stats.texSubImage2DCalls.length).toBeGreaterThanOrEqual(2)
renderer.dispose()
})
test('setAtlas allocates immutable storage via texStorage2D and uploads via texSubImage2D', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
const storageCallsBefore = stats.texStorage2DCalls.length
const subCallsBefore = stats.texSubImage2DCalls.length
const atlas = {
pixels: new Uint8ClampedArray(128 * 64 * 4),
width: 128,
height: 64,
groups: [],
}
renderer.setAtlas(atlas)
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
const lastStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
expect(lastStorage?.width).toBe(128)
expect(lastStorage?.height).toBe(64)
expect(lastStorage?.internalformat).toBe(32856) // gl.RGBA8
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
const lastSub = stats.texSubImage2DCalls[stats.texSubImage2DCalls.length - 1]
expect(lastSub?.width).toBe(128)
expect(lastSub?.height).toBe(64)
// Re-uploading atlas with SAME dimensions reuses the immutable storage
renderer.setAtlas(atlas)
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1) // no extra texStorage2D
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 2) // extra texSubImage2D
// Uploading atlas with DIFFERENT dimensions deletes old texture and allocates new storage
const resizedAtlas = {
pixels: new Uint8ClampedArray(256 * 256 * 4),
width: 256,
height: 256,
groups: [],
}
renderer.setAtlas(resizedAtlas)
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 2)
const resizedStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
expect(resizedStorage?.width).toBe(256)
expect(resizedStorage?.height).toBe(256)
renderer.dispose()
})
test('addAtlas and createAtlas allocate immutable storage via texStorage2D', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
const storageCallsBefore = stats.texStorage2DCalls.length
const subCallsBefore = stats.texSubImage2DCalls.length
const handle1 = renderer.addAtlas({
pixels: new Uint8ClampedArray(32 * 32 * 4),
width: 32,
height: 32,
})
expect(handle1.width).toBe(32)
expect(handle1.height).toBe(32)
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
const handle2 = renderer.createAtlas({
pixels: new Uint8ClampedArray(64 * 32 * 4),
width: 64,
height: 32,
})
expect(handle2.width).toBe(64)
expect(handle2.height).toBe(32)
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 2)
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 2)
renderer.dispose()
})
test('addIndexedAtlas allocates R8 single-channel texture via texStorage2D', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
const storageCallsBefore = stats.texStorage2DCalls.length
const subCallsBefore = stats.texSubImage2DCalls.length
const handle = renderer.addIndexedAtlas(new Uint8Array(64 * 64), 64, 64)
expect(handle.indexed).toBe(true)
expect(handle.width).toBe(64)
expect(handle.height).toBe(64)
expect(stats.texStorage2DCalls.length).toBe(storageCallsBefore + 1)
const lastStorage = stats.texStorage2DCalls[stats.texStorage2DCalls.length - 1]
expect(lastStorage?.width).toBe(64)
expect(lastStorage?.height).toBe(64)
expect(lastStorage?.internalformat).toBe(33321) // gl.R8
expect(stats.texSubImage2DCalls.length).toBe(subCallsBefore + 1)
const lastSub = stats.texSubImage2DCalls[stats.texSubImage2DCalls.length - 1]
expect(lastSub?.format).toBe(6403) // gl.RED
renderer.dispose()
})
test('gracefully falls back to texImage2D when texStorage2D is unavailable', () => {
const { canvas, stats } = createMockCanvas(false) // without texStorage2D
const renderer = new SpriteRenderer(canvas)
expect(stats.texImage2DCalls.length).toBeGreaterThan(0)
const atlas = {
pixels: new Uint8ClampedArray(16 * 16 * 4),
width: 16,
height: 16,
groups: [],
}
renderer.setAtlas(atlas)
renderer.addAtlas({ pixels: new Uint8ClampedArray(16 * 16 * 4), width: 16, height: 16 })
renderer.addIndexedAtlas(new Uint8Array(16 * 16), 16, 16)
expect(stats.texImage2DCalls.length).toBeGreaterThanOrEqual(4)
renderer.dispose()
})
})
describe('Uniform Caching & Dirty Checking', () => {
test('only issues uniform uploads when camera, viewport, or zoom values change', () => {
const { canvas, gl, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
const frame = { x: 0, y: 0, width: 8, height: 8 }
// Frame 1: Initial flush
renderer.begin({ x: 50, y: 100, zoom: 1.0 })
renderer.draw(frame, 0, 0)
renderer.flush()
expect(stats.uniformCameraCalls).toBe(1)
expect(stats.uniformViewportCalls).toBe(1)
expect(stats.uniformZoomCalls).toBe(1)
// Frame 2: Same camera, same viewport, same zoom -> 0 uniform uploads
renderer.begin({ x: 50, y: 100, zoom: 1.0 })
renderer.draw(frame, 0, 0)
renderer.flush()
expect(stats.uniformCameraCalls).toBe(1)
expect(stats.uniformViewportCalls).toBe(1)
expect(stats.uniformZoomCalls).toBe(1)
// Frame 3: Multiple flushes within the same frame -> 0 redundant uploads
renderer.draw(frame, 8, 8)
renderer.flush()
renderer.draw(frame, 16, 16)
renderer.flush()
expect(stats.uniformCameraCalls).toBe(1)
expect(stats.uniformViewportCalls).toBe(1)
expect(stats.uniformZoomCalls).toBe(1)
// Frame 4: Camera changes -> only camera uniform uploaded
renderer.begin({ x: 60, y: 100, zoom: 1.0 })
renderer.draw(frame, 0, 0)
renderer.flush()
expect(stats.uniformCameraCalls).toBe(2)
expect(stats.uniformViewportCalls).toBe(1)
expect(stats.uniformZoomCalls).toBe(1)
// Frame 5: Zoom changes -> only zoom uniform uploaded
renderer.begin({ x: 60, y: 100, zoom: 2.0 })
renderer.draw(frame, 0, 0)
renderer.flush()
expect(stats.uniformCameraCalls).toBe(2)
expect(stats.uniformViewportCalls).toBe(1)
expect(stats.uniformZoomCalls).toBe(2)
// Frame 6: Viewport changes -> only viewport uniform uploaded
gl.drawingBufferWidth = 1024
gl.drawingBufferHeight = 768
renderer.begin({ x: 60, y: 100, zoom: 2.0 })
renderer.draw(frame, 0, 0)
renderer.flush()
expect(stats.uniformCameraCalls).toBe(2)
expect(stats.uniformViewportCalls).toBe(2)
expect(stats.uniformZoomCalls).toBe(2)
renderer.dispose()
})
})
describe('Configurable Shader Discard / Alpha Optimization', () => {
test('fragmentShaderSource generates discard when alphaDiscard is true', () => {
const src = fragmentShaderSource(8, { alphaDiscard: true })
expect(src).toContain('if (idx == 0) discard;')
expect(src).toContain('if (texel.a == 0.0) discard;')
})
test('fragmentShaderSource omits discard when alphaDiscard is false', () => {
const src = fragmentShaderSource(8, { alphaDiscard: false })
expect(src).not.toContain('discard')
expect(src).toContain('texel = idx == 0 ? vec4(0.0) : texelFetch(u_palette, ivec2(idx, v_palette), 0);')
expect(src).toContain('outColor = texel * v_tint;')
})
test('SpriteRenderer defaults to alphaDiscard = true', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas)
expect(renderer.alphaDiscard).toBe(true)
const fs = stats.shaderSources.find(s => s.includes('outColor')) ?? ''
expect(fs).toContain('discard;')
renderer.dispose()
})
test('SpriteRenderer respects alphaDiscard = false option', () => {
const { canvas, stats } = createMockCanvas()
const renderer = new SpriteRenderer(canvas, { alphaDiscard: false })
expect(renderer.alphaDiscard).toBe(false)
const fs = stats.shaderSources.find(s => s.includes('outColor')) ?? ''
expect(fs).not.toContain('discard')
expect(fs).toContain('outColor = texel * v_tint;')
renderer.dispose()
})
})
})

368
tests/roofs.test.ts Normal file
View File

@ -0,0 +1,368 @@
import { describe, expect, it } from 'vitest'
import {
getRoofBounds,
getCombinedRoofBounds,
isPointInRoofBounds,
boundsOverlap,
getPlayerBounds,
isPlayerOverlappingRoofBounds,
isPlayerInRoofCell,
isPlayerInRoofSubTile,
isPlayerUnderRoof,
stepRoofAlpha,
clusterRoofs,
RoofFadeController,
DEFAULT_MIN_ROOF_ALPHA,
DEFAULT_MAX_ROOF_ALPHA,
DEFAULT_ROOF_FADE_DURATION_MS,
type RoofDrawable,
} from '../src/render/roofs.ts'
/** Helper to create a minimal roof tile. */
function makeRoof(
x: number,
y: number,
width: number,
height: number,
cellX?: number,
cellY?: number,
): RoofDrawable {
return {
x,
y,
frame: { width, height },
...(cellX !== undefined ? { cellX } : {}),
...(cellY !== undefined ? { cellY } : {}),
}
}
describe('Roof Bounding Detection', () => {
it('computes correct bounds for a single roof tile', () => {
const roof = makeRoof(100, 200, 80, 60)
const bounds = getRoofBounds(roof)
expect(bounds).toEqual({
minX: 100,
minY: 200,
maxX: 180,
maxY: 260,
})
})
it('computes correct bounds with negative coordinates and offsets', () => {
const roof = makeRoof(-50, -30, 100, 70)
const bounds = getRoofBounds(roof)
expect(bounds).toEqual({
minX: -50,
minY: -30,
maxX: 50,
maxY: 40,
})
})
it('returns null for combined bounds of empty roof list', () => {
expect(getCombinedRoofBounds([])).toBeNull()
})
it('computes combined bounding box enclosing multiple roof tiles', () => {
const roofs = [
makeRoof(100, 150, 40, 40),
makeRoof(130, 180, 50, 30),
makeRoof(80, 200, 30, 60),
]
const combined = getCombinedRoofBounds(roofs)
expect(combined).toEqual({
minX: 80,
minY: 150,
maxX: 180, // 130 + 50
maxY: 260, // 80..110, 200 + 60
})
})
it('detects whether a 2D point is inside roof bounds', () => {
const bounds = { minX: 50, minY: 100, maxX: 150, maxY: 200 }
// Inside
expect(isPointInRoofBounds(100, 150, bounds)).toBe(true)
// Boundaries (inclusive)
expect(isPointInRoofBounds(50, 100, bounds)).toBe(true)
expect(isPointInRoofBounds(150, 200, bounds)).toBe(true)
// Outside
expect(isPointInRoofBounds(49, 150, bounds)).toBe(false)
expect(isPointInRoofBounds(151, 150, bounds)).toBe(false)
expect(isPointInRoofBounds(100, 99, bounds)).toBe(false)
expect(isPointInRoofBounds(100, 201, bounds)).toBe(false)
})
it('detects bounding box overlap and non-overlap', () => {
const boxA = { minX: 0, minY: 0, maxX: 100, maxY: 100 }
// Fully overlapping
expect(boundsOverlap(boxA, { minX: 20, minY: 20, maxX: 80, maxY: 80 })).toBe(true)
// Partially overlapping
expect(boundsOverlap(boxA, { minX: 50, minY: 50, maxX: 150, maxY: 150 })).toBe(true)
// Touching edge
expect(boundsOverlap(boxA, { minX: 100, minY: 0, maxX: 200, maxY: 100 })).toBe(true)
// Separated in X
expect(boundsOverlap(boxA, { minX: 101, minY: 0, maxX: 200, maxY: 100 })).toBe(false)
// Separated in Y
expect(boundsOverlap(boxA, { minX: 0, minY: 101, maxX: 100, maxY: 200 })).toBe(false)
})
})
describe('Player-Under-Roof Condition', () => {
const roof = makeRoof(100, 100, 80, 80, 5, 10)
describe('bounding box overlap', () => {
it('detects player point inside roof bounds', () => {
expect(isPlayerOverlappingRoofBounds({ x: 140, y: 140 }, roof)).toBe(true)
})
it('detects player sprite overlapping lower edge of roof', () => {
// Player feet at y=190, sprite extends upward to y=142, overlapping roof [100..180]
expect(isPlayerOverlappingRoofBounds({ x: 140, y: 190 }, roof)).toBe(true)
})
it('returns false when player is far away from roof', () => {
expect(isPlayerOverlappingRoofBounds({ x: 500, y: 500 }, roof)).toBe(false)
expect(isPlayerOverlappingRoofBounds({ x: 20, y: 50 }, roof)).toBe(false)
})
it('supports explicit player bounding box', () => {
expect(isPlayerOverlappingRoofBounds({ left: 110, top: 120, right: 130, bottom: 160 }, roof)).toBe(true)
expect(isPlayerOverlappingRoofBounds({ left: 300, top: 300, right: 320, bottom: 340 }, roof)).toBe(false)
})
})
describe('cell and sub-tile coverage', () => {
it('matches player cell coordinates with roof tile cell', () => {
expect(isPlayerInRoofCell({ x: 5, y: 10 }, roof)).toBe(true)
expect(isPlayerInRoofCell({ x: 4, y: 10 }, roof)).toBe(false)
expect(isPlayerInRoofCell({ x: 5, y: 9 }, roof)).toBe(false)
})
it('matches player cell against an array of roof tiles', () => {
const roofTiles = [
makeRoof(0, 0, 80, 40, 5, 10),
makeRoof(0, 0, 80, 40, 5, 11),
makeRoof(0, 0, 80, 40, 6, 10),
]
expect(isPlayerInRoofCell({ x: 5, y: 11 }, roofTiles)).toBe(true)
expect(isPlayerInRoofCell({ x: 6, y: 10 }, roofTiles)).toBe(true)
expect(isPlayerInRoofCell({ x: 7, y: 12 }, roofTiles)).toBe(false)
})
it('detects player sub-tile coverage (5x5 sub-tiles per cell)', () => {
// Cell (5, 10) corresponds to sub-tiles [25..29, 50..54]
expect(isPlayerInRoofSubTile({ x: 25, y: 50 }, roof)).toBe(true)
expect(isPlayerInRoofSubTile({ x: 29, y: 54 }, roof)).toBe(true)
expect(isPlayerInRoofSubTile({ x: 27, y: 52 }, roof)).toBe(true)
// Adjacent sub-tile in cell (4, 10) or (5, 9)
expect(isPlayerInRoofSubTile({ x: 24, y: 50 }, roof)).toBe(false)
expect(isPlayerInRoofSubTile({ x: 25, y: 49 }, roof)).toBe(false)
})
it('combines cell coverage and bounding box in isPlayerUnderRoof', () => {
// Cell matches even if screen point is outside
expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 5, y: 10 })).toBe(true)
// Bounding box matches even if cell is different
expect(isPlayerUnderRoof({ x: 140, y: 140 }, roof, { x: 99, y: 99 })).toBe(true)
// Neither matches
expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 99, y: 99 })).toBe(false)
})
})
describe('shelter clustering', () => {
it('groups adjacent roof tiles into a single shelter cluster', () => {
const akaraTent = [
makeRoof(100, 100, 80, 80, 10, 10),
makeRoof(140, 120, 80, 80, 10, 11),
makeRoof(120, 140, 80, 80, 11, 10),
]
const clusters = clusterRoofs(akaraTent)
expect(clusters).toHaveLength(1)
expect(clusters[0]!.roofs).toHaveLength(3)
expect(clusters[0]!.cells.has('10,10')).toBe(true)
expect(clusters[0]!.cells.has('10,11')).toBe(true)
expect(clusters[0]!.cells.has('11,10')).toBe(true)
})
it('separates distant roof groups into distinct shelters', () => {
const akaraTent = [
makeRoof(100, 100, 80, 80, 10, 10),
makeRoof(140, 120, 80, 80, 10, 11),
]
const charsiForge = [
makeRoof(800, 800, 80, 80, 50, 50),
makeRoof(840, 820, 80, 80, 50, 51),
]
const clusters = clusterRoofs([...akaraTent, ...charsiForge])
expect(clusters).toHaveLength(2)
const clusterA = clusters.find(c => c.cells.has('10,10'))!
const clusterB = clusters.find(c => c.cells.has('50,50'))!
expect(clusterA.roofs).toHaveLength(2)
expect(clusterB.roofs).toHaveLength(2)
})
})
})
describe('Roof Opacity Fade', () => {
describe('stepRoofAlpha', () => {
it('fades down towards minAlpha over durationMs', () => {
// 200ms total duration, from 1.0 to 0.25
const start = 1.0
const target = 0.25
// After 50ms (25% progress) -> 1.0 - 0.75 * 0.25 = 0.8125
const step1 = stepRoofAlpha(start, target, 50, 200, 0.25, 1.0)
expect(step1).toBeCloseTo(0.8125, 4)
// After 100ms (50% progress) -> 1.0 - 0.75 * 0.5 = 0.625
const step2 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0)
expect(step2).toBeCloseTo(0.625, 4)
// After 200ms (100% progress) -> exactly 0.25
const step3 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0)
expect(step3).toBe(0.25)
// Clamps and does not overshoot below minAlpha
const step4 = stepRoofAlpha(0.25, target, 100, 200, 0.25, 1.0)
expect(step4).toBe(0.25)
})
it('fades up towards maxAlpha when exiting shelter', () => {
const start = 0.25
const target = 1.0
// After 100ms -> halfway back to 1.0 (0.625)
const step1 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0)
expect(step1).toBeCloseTo(0.625, 4)
// After 200ms -> full 1.0
const step2 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0)
expect(step2).toBe(1.0)
// Clamps at maxAlpha
const step3 = stepRoofAlpha(1.0, target, 50, 200, 0.25, 1.0)
expect(step3).toBe(1.0)
})
it('smoothly reverses direction mid-fade without discontinuities', () => {
// Player enters shelter, fades down partially to 0.70
let alpha = 1.0
alpha = stepRoofAlpha(alpha, 0.25, 80, 200, 0.25, 1.0)
expect(alpha).toBeCloseTo(0.70, 4)
// Player immediately steps back out: smoothly fades back up towards 1.0
alpha = stepRoofAlpha(alpha, 1.0, 40, 200, 0.25, 1.0)
expect(alpha).toBeGreaterThan(0.70)
expect(alpha).toBeCloseTo(0.85, 4)
// Reaches 1.0
alpha = stepRoofAlpha(alpha, 1.0, 100, 200, 0.25, 1.0)
expect(alpha).toBe(1.0)
})
it('returns currentAlpha when deltaMs is zero or negative', () => {
expect(stepRoofAlpha(0.5, 0.25, 0, 200)).toBe(0.5)
expect(stepRoofAlpha(0.5, 0.25, -10, 200)).toBe(0.5)
})
it('snaps immediately to target when durationMs is zero', () => {
expect(stepRoofAlpha(1.0, 0.25, 16, 0)).toBe(0.25)
})
})
describe('RoofFadeController', () => {
const akaraTent = [
makeRoof(100, 100, 80, 80, 10, 10),
makeRoof(140, 120, 80, 80, 10, 11),
]
const charsiForge = [
makeRoof(800, 800, 80, 80, 50, 50),
]
it('initializes all roofs to full opacity (1.0)', () => {
const controller = new RoofFadeController([...akaraTent, ...charsiForge])
expect(controller.getAlpha(0)).toBe(1.0)
expect(controller.getAlpha(1)).toBe(1.0)
expect(controller.getAlpha(2)).toBe(1.0)
expect(controller.overallAlpha).toBe(1.0)
expect(controller.isPlayerUnderAnyRoof).toBe(false)
})
it('fades only the shelter the player is under', () => {
const controller = new RoofFadeController([...akaraTent, ...charsiForge], {
fadeDurationMs: 200,
minAlpha: 0.25,
})
// Player stands inside Akara's tent at cell (10, 10)
const playerPos = { x: 140, y: 140 }
const playerCell = { x: 10, y: 10 }
// Advance by 100ms (halfway)
controller.update(playerPos, 100, playerCell)
expect(controller.isPlayerUnderAnyRoof).toBe(true)
// Akara's tent tiles (indices 0 and 1) fade in unison
expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4)
expect(controller.getAlpha(1)).toBeCloseTo(0.625, 4)
// Charsi's forge (index 2) remains fully opaque
expect(controller.getAlpha(2)).toBe(1.0)
// Advance by another 100ms (reaches minAlpha 0.25)
controller.update(playerPos, 100, playerCell)
expect(controller.getAlpha(0)).toBe(0.25)
expect(controller.getAlpha(1)).toBe(0.25)
expect(controller.getAlpha(2)).toBe(1.0)
expect(controller.overallAlpha).toBe(0.25)
})
it('smoothly restores full opacity when player exits shelter', () => {
const controller = new RoofFadeController(akaraTent, {
fadeDurationMs: 200,
minAlpha: 0.25,
})
// Player inside -> fully faded
controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 })
expect(controller.getAlpha(0)).toBe(0.25)
// Player exits shelter to town square
const outsidePos = { x: 500, y: 500 }
const outsideCell = { x: 25, y: 25 }
// 100ms elapsed
controller.update(outsidePos, 100, outsideCell)
expect(controller.isPlayerUnderAnyRoof).toBe(false)
expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4)
// 200ms elapsed -> fully restored
controller.update(outsidePos, 100, outsideCell)
expect(controller.getAlpha(0)).toBe(1.0)
expect(controller.overallAlpha).toBe(1.0)
})
it('handles empty roof list safely', () => {
const controller = new RoofFadeController([])
controller.update({ x: 100, y: 100 }, 16)
expect(controller.getAlpha(0)).toBe(1.0)
expect(controller.overallAlpha).toBe(1.0)
expect(controller.isPlayerUnderAnyRoof).toBe(false)
})
it('allows manual alpha override via setAlpha', () => {
const controller = new RoofFadeController(akaraTent)
controller.setAlpha(0.2)
expect(controller.getAlpha(0)).toBe(0.2)
expect(controller.getAlpha(1)).toBe(0.2)
})
})
})

80
tests/variants.test.ts Normal file
View File

@ -0,0 +1,80 @@
import { describe, expect, test } from 'vitest'
import {
findMatchingVariant,
getDefaultTownVariant,
oppositeSide,
variantOrientation,
} from '../src/game/variants.ts'
describe('固定地图变体与朝向匹配 (Issue #42)', () => {
test('解析变体方向标识符', () => {
expect(variantOrientation({ label: '1-act-1-town-townn1' })).toBe('north')
expect(variantOrientation({ label: '1-act-1-town-towne1' })).toBe('east')
expect(variantOrientation({ label: '1-act-1-town-towns1' })).toBe('south')
expect(variantOrientation({ label: '1-act-1-town-townw1' })).toBe('west')
expect(variantOrientation({ ds1: 'data\\global\\tiles\\Act2\\Town\\LutN.ds1' })).toBe('north')
expect(variantOrientation({ ds1: 'data\\global\\tiles\\Act2\\Town\\LutW.ds1' })).toBe('west')
expect(variantOrientation({ label: '27-act-1-courtyard-1-courte' })).toBe('east')
expect(variantOrientation({ label: '27-act-1-courtyard-1-courtn' })).toBe('north')
expect(variantOrientation({ label: '27-act-1-courtyard-1-courtw' })).toBe('west')
expect(variantOrientation({ label: '8-act-1-cave-1-var1' })).toBeUndefined()
})
test('朝向互逆函数计算', () => {
expect(oppositeSide('north')).toBe('south')
expect(oppositeSide('south')).toBe('north')
expect(oppositeSide('east')).toBe('west')
expect(oppositeSide('west')).toBe('east')
})
test('依据相邻区域出口朝向自动匹配互逆的城镇变体', () => {
const act1TownVariants = [
{ label: '1-act-1-town-townn1', levelId: 1 },
{ label: '1-act-1-town-towne1', levelId: 1 },
{ label: '1-act-1-town-towns1', levelId: 1 },
{ label: '1-act-1-town-townw1', levelId: 1 },
]
// 鲜血荒地(Level 2)在北侧大门离开(fromSide: 'north'),进入城镇要求城镇出口在南侧('south')
const matchedSouth = findMatchingVariant(act1TownVariants, oppositeSide('north'))
expect(matchedSouth?.label).toBe('1-act-1-town-towns1')
// 若鲜血荒地在西侧大门离开(fromSide: 'west'),进入城镇要求城镇出口在东侧('east')
const matchedEast = findMatchingVariant(act1TownVariants, oppositeSide('west'))
expect(matchedEast?.label).toBe('1-act-1-town-towne1')
// 鲁高因(Level 40)变体匹配
const act2TownVariants = [
{ label: '40-act-2-town-lutw', ds1: 'LutW.ds1', levelId: 40 },
{ label: '40-act-2-town-lutn', ds1: 'LutN.ds1', levelId: 40 },
]
// 碎石荒地在南侧离开(fromSide: 'south'),进入鲁高因要求北门('north')
const matchedLutN = findMatchingVariant(act2TownVariants, oppositeSide('south'))
expect(matchedLutN?.label).toBe('40-act-2-town-lutn')
})
test('默认城镇变体与生成预置缝合对齐', () => {
const act1TownVariants = [
{ label: '1-act-1-town-townn1', slug: '1-act-1-town', act: 1 },
{ label: '1-act-1-town-towne1', slug: '1-act-1-town', act: 1 },
{ label: '1-act-1-town-towns1', slug: '1-act-1-town', act: 1 },
{ label: '1-act-1-town-townw1', slug: '1-act-1-town', act: 1 },
]
// Act 1 鲜血荒地默认采用 Town S Transition(在荒地北边界),因此默认城镇应选择 TownS1
const defaultAct1 = getDefaultTownVariant(act1TownVariants, 1)
expect(defaultAct1?.label).toBe('1-act-1-town-towns1')
const act2TownVariants = [
{ label: '40-act-2-town-lutw', slug: '40-act-2-town', act: 2 },
{ label: '40-act-2-town-lutn', slug: '40-act-2-town', act: 2 },
]
// Act 2 碎石荒地默认在南边界衔接城镇,因此默认鲁高因应选择 LutN
const defaultAct2 = getDefaultTownVariant(act2TownVariants, 2)
expect(defaultAct2?.label).toBe('40-act-2-town-lutn')
})
})

View File

@ -0,0 +1,417 @@
import { describe, expect, test } from 'vitest'
import {
generateWilderness,
getPerimeterOpenings,
UNIMPLEMENTED_PASSES,
} from '../src/game/wilderness.ts'
import type { WildernessPiece } from '../src/game/wilderness.ts'
import type {
Act2TopographyStats,
Act3TopographyStats,
Act4TopographyStats,
Act5TopographyStats,
} from '../src/game/wilderness-acts.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number, hasGapInWalls: boolean = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const isGap = hasGapInWalls && x === Math.floor(width / 2)
row.push({
walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, 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: [],
npcPathOffset: null,
}
}
function makeMockBorderPieces(actPrefix: string): WildernessPiece[] {
const borderDs1 = makeMockDs1(8, 8, false)
const openBorderDs1 = makeMockDs1(8, 8, true)
return [
{ name: `${actPrefix} - Border 1`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 2`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 3`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 4`, border: true, levels: [borderDs1, borderDs1, borderDs1, openBorderDs1] },
{ name: `${actPrefix} - Border 5`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 6`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 7`, border: true, levels: [borderDs1] },
{ name: `${actPrefix} - Border 8`, border: true, levels: [borderDs1] },
]
}
describe('Wilderness Outdoor Initializers for Acts 2-5 (Issue #51)', () => {
test('UNIMPLEMENTED_PASSES has 5 outdoor passes removed', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDESR_InitAct2OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTPLACE_InitAct3OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTDOORS_InitAct4OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLGOUTSIEGE_InitAct5OutdoorLevel')
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
expect(UNIMPLEMENTED_PASSES).toEqual([
'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDOORS_SpawnAct3Mephisto',
])
})
describe('Act 2: DRLGOUTDESR_InitAct2OutdoorLevel', () => {
test('Rocky Waste (41) generates sand caravan trails and canyon ridge walls', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 41001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.sandTrailCells).toBeGreaterThan(50)
expect(topo.canyonWalls).toBeGreaterThan(20)
let foundSandTrail = false
let foundCanyonWall = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundSandTrail = true
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundCanyonWall = true
}
}
expect(foundSandTrail).toBe(true)
expect(foundCanyonWall).toBe(true)
})
test('Far Oasis (43) generates lush water oasis pools', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 43,
levelName: 'Far Oasis',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 43001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.oasisPoolCells).toBeGreaterThan(15)
let foundOasisWater = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundOasisWater = true
}
}
expect(foundOasisWater).toBe(true)
})
test('Valley of Snakes (45) creates narrow canyon gorge', () => {
const pieces = makeMockBorderPieces('Act 2')
const result = generateWilderness({
levelId: 45,
levelName: 'Valley of Snakes',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 45001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.canyonWalls).toBeGreaterThan(30)
})
test('Canyon of the Magi (46) places 7 Tal Rasha tombs, central dais, and has 0 perimeter openings', () => {
const pieces = makeMockBorderPieces('Act 2')
expect(getPerimeterOpenings(46, 10, 10).size).toBe(0)
const result = generateWilderness({
levelId: 46,
levelName: 'Canyon of the Magi',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 46001,
pieces,
substitutions: [],
})
expect(result.stats.act2Topography).toBeDefined()
const topo = result.stats.act2Topography as Act2TopographyStats
expect(topo.tombEntrances).toBe(7)
// Verify central Arcane Sanctuary warp platform (portal/dais)
let foundCentralDais = false
for (let y = 35; y <= 45; y += 1) {
for (let x = 35; x <= 45; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 3)) foundCentralDais = true
}
}
expect(foundCentralDais).toBe(true)
})
})
describe('Act 3: DRLGOUTPLACE_InitAct3OutdoorLevel & DRLG_GenerateJungles', () => {
test('Jungles (76 Spider Forest) generate river channel, wooden river bridges, and jungle paths', () => {
const tailDs1 = makeMockDs1(64, 32)
const headDs1 = makeMockDs1(64, 32)
const clearingDs1 = makeMockDs1(32, 32)
const riverDs1 = makeMockDs1(32, 32)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Jungle Tail', border: false, levels: [tailDs1] },
{ name: 'Act 3 - Jungle Head', border: false, levels: [headDs1] },
{ name: 'Act 3 - Clearing Webby E', border: false, levels: [clearingDs1] },
{ name: 'Act 3 - Jungle NS W', border: false, levels: [riverDs1] },
]
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
seed: 76001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.riverBridges).toBeGreaterThanOrEqual(2)
expect(topo.junglePathCells).toBeGreaterThan(20)
// Verify wooden river bridge planks (style 4, prop1 2) spanning across East channel
let foundBridgePlank = false
for (let y = 70; y <= 90; y += 1) {
for (let x = 32; x < 60; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) foundBridgePlank = true
}
}
expect(foundBridgePlank).toBe(true)
})
test('Kurast Causeway (82) stamps causeway with central walkway, balustrades, and canal water', () => {
const bridgeDs1 = makeMockDs1(48, 16)
const pieces: WildernessPiece[] = [
{ name: 'Act 3 - Bridge', border: false, levels: [bridgeDs1] },
]
const result = generateWilderness({
levelId: 82,
levelName: 'Kurast Causeway',
levelTypeName: 'Act 3 - Kurast',
sizeX: 48,
sizeY: 16,
subType: 0,
subTheme: 0,
seed: 82001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.causewayCells).toBeGreaterThan(50)
expect(topo.canalCells).toBeGreaterThan(100)
// Verify central walkway (style 5), stone balustrade walls (style 1), and canal water (style 2)
let foundWalkway = false
let foundBalustrade = false
let foundCanalWater = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundWalkway = true
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) foundBalustrade = true
if (cell.floors.some(f => f.style === 2 && f.prop1 === 2)) foundCanalWater = true
}
}
expect(foundWalkway).toBe(true)
expect(foundBalustrade).toBe(true)
expect(foundCanalWater).toBe(true)
})
test('Kurast Bazaar (79) generates paved stone avenues, canals, and stone bridges', () => {
const pieces = makeMockBorderPieces('Act 3')
const result = generateWilderness({
levelId: 79,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 79001,
pieces,
substitutions: [],
})
expect(result.stats.act3Topography).toBeDefined()
const topo = result.stats.act3Topography as Act3TopographyStats
expect(topo.causewayCells).toBeGreaterThan(50)
expect(topo.canalCells).toBeGreaterThan(30)
expect(topo.riverBridges).toBeGreaterThanOrEqual(1)
})
})
describe('Act 4: DRLGOUTDOORS_InitAct4OutdoorLevel', () => {
test('Outer Steppes (104) generates lava chasms, basalt lips, and rocky bridge causeways', () => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId: 104,
levelName: 'Outer Steppes',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 104001,
pieces,
substitutions: [],
})
expect(result.stats.act4Topography).toBeDefined()
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.lavaChasmCells).toBeGreaterThan(100)
expect(topo.rockyBridges).toBeGreaterThan(5)
// Verify boiling molten lava (style 20, sequence 3, prop1 2) and rocky bridges (style 10, sequence 0, prop1 2)
let foundLava = false
let foundRockyBridge = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 20 && f.sequence === 3 && f.prop1 === 2)) foundLava = true
if (cell.floors.some(f => f.style === 10 && f.sequence === 0 && f.prop1 === 2)) foundRockyBridge = true
}
}
expect(foundLava).toBe(true)
expect(foundRockyBridge).toBe(true)
})
test('Plains of Despair (105) creates Izual prison plateau', () => {
const pieces = makeMockBorderPieces('Act 4')
const result = generateWilderness({
levelId: 105,
levelName: 'Plains of Despair',
levelTypeName: 'Act 4 - Mesa',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 105001,
pieces,
substitutions: [],
})
expect(result.stats.act4Topography).toBeDefined()
const topo = result.stats.act4Topography as Act4TopographyStats
expect(topo.plateauCells).toBeGreaterThan(20)
})
})
describe('Act 5: DRLGOUTSIEGE_InitAct5OutdoorLevel', () => {
test('Frigid Highlands (111) generates transverse siege barricades, choke-point gates, and trenches', () => {
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 111001,
pieces,
substitutions: [],
})
expect(result.stats.act5Topography).toBeDefined()
const topo = result.stats.act5Topography as Act5TopographyStats
expect(topo.barricadeWalls).toBeGreaterThan(40)
expect(topo.trenchCells).toBeGreaterThan(30)
// Verify spiked palisade walls (style 2, prop1 129) and trench floors (style 5, prop1 194)
let foundBarricadeWall = false
let foundTrench = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) foundBarricadeWall = true
if (cell.floors.some(f => f.style === 5 && f.prop1 === 194)) foundTrench = true
}
}
expect(foundBarricadeWall).toBe(true)
expect(foundTrench).toBe(true)
})
test('Arreat Plateau (112) generates snow crater frozen lakes with ice floors', () => {
const pieces = makeMockBorderPieces('Act 5')
const result = generateWilderness({
levelId: 112,
levelName: 'Arreat Plateau',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 112001,
pieces,
substitutions: [],
})
expect(result.stats.act5Topography).toBeDefined()
const topo = result.stats.act5Topography as Act5TopographyStats
expect(topo.frozenLakeCells).toBeGreaterThan(20)
let foundIceFloor = false
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 2 && f.sequence === 0 && f.prop1 === 2)) foundIceFloor = true
}
}
expect(foundIceFloor).toBe(true)
})
})
})

View File

@ -244,50 +244,14 @@ describe("Wilderness Features & Border Rules", () => {
expect(result.stats.substitutedClusters).toBe(15)
expect(result.level.width).toBe(240)
expect(result.level.height).toBe(48)
expect(result.level.objects.length).toBe(15)
expect(result.level.objects.filter(o => o.type === 2).length).toBe(15)
expect(result.level.objects.length).toBe(17)
// Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
expect(result.level.cells[0][224].floors[0].sequence).toBe(24)
})
test("Act 4 Chaos Sanctuary (Level 108) stamps 7 cross-wing presets on 120x120 canvas", () => {
const names = [
"Act 4 - Diablo Arm N",
"Act 4 - Diablo Arm W",
"Act 4 - Diablo Heart",
"Act 4 - Diablo Arm E",
"Act 4 - Diablo Arm S",
"Act 4 - Diablo Entry",
"Act 4 - Bridge 2",
]
const pieces: WildernessPiece[] = names.map((name, idx) => {
const baseDs1 = makeMockDs1(25, 25, false)
const ds1: Ds1 = {
...baseDs1,
objects: [{ type: 2, id: 200 + idx, x: 5, y: 5, flags: 0 }],
}
return { name, border: false, levels: [ds1] }
})
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(120)
expect(result.level.height).toBe(120)
expect(result.stats.substitutedClusters).toBe(7)
expect(result.level.objects.length).toBe(7)
})
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
const names = [

View File

@ -0,0 +1,512 @@
import { describe, expect, test } from "vitest"
import {
fillGround,
sampleGroundTile,
deriveGroundTilesFromDt1Libraries,
deriveGroundTiles,
deriveGroundTile,
cellHash,
smoothNoise2D,
createCanvas,
generateWilderness,
CANONICAL_GROUND_POOLS,
} from "../src/game/wilderness.ts"
import type {
WeightedGroundTile,
WildernessPiece,
} from "../src/game/wilderness.ts"
import type { Dt1, Dt1Tile } from "../src/formats/dt1.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(
width: number,
height: number,
floorStyle = 0,
floorSequence = 0,
customFloor?: (x: number, y: number) => { readonly style: number; readonly sequence: number } | undefined,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
const custom = customFloor?.(x, y)
const style = custom ? custom.style : floorStyle
const sequence = custom ? custom.sequence : floorSequence
row.push({
walls: [],
floors: [{ prop1: 2, sequence, style, 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: [],
npcPathOffset: null,
}
}
function makeMockDt1(tiles: Partial<Dt1Tile>[]): Dt1 {
return {
warnings: [],
versionMajor: 7,
versionMinor: 6,
tiles: tiles.map(t => ({
direction: 0,
height: -80,
width: 160,
type: t.type ?? 0,
style: t.style ?? 0,
sequence: t.sequence ?? 0,
materialFlags: 0,
roofHeight: 0,
animated: t.animated ?? 0,
rarityFrameIndex: t.rarityFrameIndex ?? 1,
subTileFlags: [],
minBlockY: 0,
bitmapHeight: 80,
blocks: [],
...t,
})),
}
}
function makeTestPieces(): WildernessPiece[] {
return [
{ name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
{ name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, 0, 0)] },
]
}
describe("Wilderness Ground Tile Blending (Issue #52)", () => {
describe("PRNG & Noise Primitives", () => {
test("cellHash produces deterministic values in [0, 1)", () => {
const v1 = cellHash(10, 20, 12345)
const v2 = cellHash(10, 20, 12345)
const v3 = cellHash(10, 20, 99999)
const v4 = cellHash(11, 20, 12345)
expect(v1).toBe(v2)
expect(v1).toBeGreaterThanOrEqual(0)
expect(v1).toBeLessThan(1)
expect(v1).not.toBe(v3)
expect(v1).not.toBe(v4)
})
test("smoothNoise2D produces values in [0, 1] with spatial smoothness", () => {
const n0 = smoothNoise2D(10, 10, 8, 42)
const n1 = smoothNoise2D(10.1, 10.1, 8, 42)
const nFar = smoothNoise2D(50, 50, 8, 42)
expect(n0).toBeGreaterThanOrEqual(0)
expect(n0).toBeLessThanOrEqual(1)
// Small step produces small difference
expect(Math.abs(n1 - n0)).toBeLessThan(0.1)
expect(nFar).toBeGreaterThanOrEqual(0)
expect(nFar).toBeLessThanOrEqual(1)
})
})
describe("Single-Tile Fallback (Backward Compatibility)", () => {
test("fillGround with a single tile object fills entire area uniformly", () => {
const canvas = createCanvas(20, 20, 1, 1, 0)
const written = fillGround(canvas, { style: 3, sequence: 7 }, 0, 0, 20, 20)
expect(written).toBe(400)
for (let y = 0; y < 20; y += 1) {
for (let x = 0; x < 20; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style).toBe(3)
expect(floor.sequence).toBe(7)
expect(floor.hidden).toBe(false)
}
}
})
test("fillGround with a 1-element array fills entire area uniformly", () => {
const canvas = createCanvas(15, 15, 1, 1, 0)
const written = fillGround(canvas, [{ style: 1, sequence: 4, weight: 100 }], 0, 0, 15, 15)
expect(written).toBe(225)
for (let y = 0; y < 15; y += 1) {
for (let x = 0; x < 15; x += 1) {
const floor = canvas.cells[y]![x]!.floors[0]!
expect(floor.style).toBe(1)
expect(floor.sequence).toBe(4)
}
}
})
test("sampleGroundTile with a single tile returns it directly", () => {
const single = { style: 2, sequence: 5 }
expect(sampleGroundTile(single, 10, 10, 123)).toEqual(single)
const singleArr = [{ style: 4, sequence: 9, weight: 1 }]
expect(sampleGroundTile(singleArr, 10, 10, 123)).toEqual(singleArr[0])
})
})
describe("Multi-Tile Ground Blending & Weight Adherence", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 70 },
{ style: 0, sequence: 1, weight: 20 },
{ style: 0, sequence: 2, weight: 10 },
]
test("fillGround in spatial mode generates all candidate tiles and respects weight ratios", () => {
const width = 60
const height = 60
const totalCells = width * height
const canvas = createCanvas(width, height, 1, 1, 0)
fillGround(canvas, candidates, 0, 0, width, height, {
seed: 424242,
mode: "spatial",
spatialScale: 6,
spatialCoherence: 0.6,
})
const counts = new Map<number, number>()
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
counts.set(seq, (counts.get(seq) ?? 0) + 1)
}
}
// All candidate sequences must be present
expect(counts.has(0)).toBe(true)
expect(counts.has(1)).toBe(true)
expect(counts.has(2)).toBe(true)
const c0 = counts.get(0)! / totalCells
const c1 = counts.get(1)! / totalCells
const c2 = counts.get(2)! / totalCells
// Expected: ~70% sequence 0, ~20% sequence 1, ~10% sequence 2
// Spatial noise percentile ranking guarantees global proportions very close to exact weights
expect(c0).toBeGreaterThanOrEqual(0.60)
expect(c0).toBeLessThanOrEqual(0.80)
expect(c1).toBeGreaterThanOrEqual(0.12)
expect(c1).toBeLessThanOrEqual(0.28)
expect(c2).toBeGreaterThanOrEqual(0.04)
expect(c2).toBeLessThanOrEqual(0.18)
})
test("fillGround in CDF mode generates all candidate tiles and respects weight ratios", () => {
const width = 60
const height = 60
const totalCells = width * height
const canvas = createCanvas(width, height, 1, 1, 0)
fillGround(canvas, candidates, 0, 0, width, height, {
seed: 88888,
mode: "cdf",
})
const counts = new Map<number, number>()
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const seq = canvas.cells[y]![x]!.floors[0]!.sequence
counts.set(seq, (counts.get(seq) ?? 0) + 1)
}
}
expect(counts.has(0)).toBe(true)
expect(counts.has(1)).toBe(true)
expect(counts.has(2)).toBe(true)
const c0 = counts.get(0)! / totalCells
const c1 = counts.get(1)! / totalCells
const c2 = counts.get(2)! / totalCells
// Law of large numbers for 3600 samples: within +-8% of true probabilities
expect(c0).toBeGreaterThanOrEqual(0.62)
expect(c0).toBeLessThanOrEqual(0.78)
expect(c1).toBeGreaterThanOrEqual(0.14)
expect(c1).toBeLessThanOrEqual(0.26)
expect(c2).toBeGreaterThanOrEqual(0.05)
expect(c2).toBeLessThanOrEqual(0.16)
})
test("spatial mode creates contiguous natural patches (higher neighborhood correlation than random)", () => {
const width = 40
const height = 40
const canvasSpatial = createCanvas(width, height, 1, 1, 0)
const canvasCdf = createCanvas(width, height, 1, 1, 0)
fillGround(canvasSpatial, candidates, 0, 0, width, height, { seed: 100, mode: "spatial", spatialScale: 6 })
fillGround(canvasCdf, candidates, 0, 0, width, height, { seed: 100, mode: "cdf" })
// Count horizontal neighbor matches: how often cell(x, y) has the same sequence as cell(x+1, y)
let spatialMatches = 0
let cdfMatches = 0
let totalPairs = 0
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width - 1; x += 1) {
totalPairs += 1
if (canvasSpatial.cells[y]![x]!.floors[0]!.sequence === canvasSpatial.cells[y]![x + 1]!.floors[0]!.sequence) {
spatialMatches += 1
}
if (canvasCdf.cells[y]![x]!.floors[0]!.sequence === canvasCdf.cells[y]![x + 1]!.floors[0]!.sequence) {
cdfMatches += 1
}
}
}
// Spatial coherent noise should have significantly higher neighbor continuity than independent CDF noise
const spatialCorr = spatialMatches / totalPairs
const cdfCorr = cdfMatches / totalPairs
expect(spatialCorr).toBeGreaterThan(cdfCorr)
expect(spatialCorr).toBeGreaterThan(0.60)
})
})
describe("Deterministic PRNG Consistency", () => {
test("same seed produces byte-for-byte identical tile layouts", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 60 },
{ style: 0, sequence: 1, weight: 30 },
{ style: 0, sequence: 2, weight: 10 },
]
const canvasA = createCanvas(25, 25, 1, 1, 0)
const canvasB = createCanvas(25, 25, 1, 1, 0)
fillGround(canvasA, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
fillGround(canvasB, candidates, 0, 0, 25, 25, { seed: 0xfeedbeef })
for (let y = 0; y < 25; y += 1) {
for (let x = 0; x < 25; x += 1) {
const floorA = canvasA.cells[y]![x]!.floors[0]!
const floorB = canvasB.cells[y]![x]!.floors[0]!
expect(floorA.style).toBe(floorB.style)
expect(floorA.sequence).toBe(floorB.sequence)
}
}
})
test("different seeds produce different ground layouts", () => {
const candidates: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 50 },
{ style: 0, sequence: 1, weight: 50 },
]
const canvasA = createCanvas(20, 20, 1, 1, 0)
const canvasB = createCanvas(20, 20, 1, 1, 0)
fillGround(canvasA, candidates, 0, 0, 20, 20, { seed: 111 })
fillGround(canvasB, candidates, 0, 0, 20, 20, { seed: 999 })
let differences = 0
for (let y = 0; y < 20; y += 1) {
for (let x = 0; x < 20; x += 1) {
if (canvasA.cells[y]![x]!.floors[0]!.sequence !== canvasB.cells[y]![x]!.floors[0]!.sequence) {
differences += 1
}
}
}
expect(differences).toBeGreaterThan(50)
})
})
describe("DT1 Library Derivation (deriveGroundTilesFromDt1Libraries)", () => {
test("filters libraries strictly according to dwDt1Mask", () => {
const lib0 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 50 },
])
const lib1 = makeMockDt1([
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const lib2 = makeMockDt1([
{ type: 0, style: 0, sequence: 2, rarityFrameIndex: 10 },
])
const libraries = [lib0, lib1, lib2]
// Mask 0b001 = 1 (only lib 0)
const onlyLib0 = deriveGroundTilesFromDt1Libraries(libraries, 1)
expect(onlyLib0).toHaveLength(1)
expect(onlyLib0[0]).toMatchObject({ style: 0, sequence: 0, weight: 50 })
// Mask 0b101 = 5 (lib 0 and lib 2, skipping lib 1)
const lib0and2 = deriveGroundTilesFromDt1Libraries(libraries, 5)
expect(lib0and2).toHaveLength(2)
expect(lib0and2.map(t => t.sequence)).toEqual([0, 2])
// Mask 0b000 = 0 (no libraries active)
const empty = deriveGroundTilesFromDt1Libraries(libraries, 0)
expect(empty).toHaveLength(0)
})
test("filters out non-floor tiles (type !== 0) and weights by rarityFrameIndex", () => {
const dt1 = makeMockDt1([
// Floor tiles (type === 0)
{ type: 0, style: 1, sequence: 0, rarityFrameIndex: 60 },
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 40 },
// Same style/seq variant in library: weights should accumulate
{ type: 0, style: 1, sequence: 1, rarityFrameIndex: 10 },
// Wall tile (type === 1) - must be excluded
{ type: 1, style: 1, sequence: 0, rarityFrameIndex: 99 },
// Roof tile (type === 2) - must be excluded
{ type: 2, style: 1, sequence: 0, rarityFrameIndex: 99 },
])
const result = deriveGroundTilesFromDt1Libraries([dt1], 1)
expect(result).toHaveLength(2)
// Expected ordering: highest weight first
// seq 0 has 60, seq 1 has 40 + 10 = 50
expect(result[0]).toMatchObject({ style: 1, sequence: 0, weight: 60 })
expect(result[1]).toMatchObject({ style: 1, sequence: 1, weight: 50 })
})
test("handles empty libraries or libraries with no floor tiles gracefully", () => {
expect(deriveGroundTilesFromDt1Libraries([], 0xff)).toEqual([])
const noFloors = makeMockDt1([{ type: 1, style: 0, sequence: 0 }])
expect(deriveGroundTilesFromDt1Libraries([noFloors], 1)).toEqual([])
})
})
describe("Piece Floor Derivation (deriveGroundTiles & deriveGroundTile)", () => {
test("derives candidate ground tiles with frequencies from interior floor cells", () => {
// Create a piece with interior floor tiles:
// 8x8 piece has interior from x=1..6, y=1..6 (36 interior cells)
// Set cells x=2..4, y=2..4 to sequence 1 (9 cells)
const level = makeMockDs1(8, 8, 0, 0, (x, y) => {
if (x >= 2 && x <= 4 && y >= 2 && y <= 4) {
return { style: 0, sequence: 1 }
}
return undefined
})
const pieces: WildernessPiece[] = [{ name: "Test Piece", border: true, levels: [level] }]
const tiles = deriveGroundTiles(pieces)
expect(tiles.length).toBe(2)
// Sequence 0 is the majority, sequence 1 has 9 cells
expect(tiles[0]!.sequence).toBe(0)
expect(tiles[1]!.sequence).toBe(1)
expect(tiles[1]!.weight).toBe(9)
// deriveGroundTile should return the most frequent interior tile
const primary = deriveGroundTile(pieces)
expect(primary).toEqual({ style: 0, sequence: 0 })
})
})
describe("generateWilderness Integration", () => {
test("generates wilderness with explicit groundTiles blend", () => {
const pieces = makeTestPieces()
const candidateTiles: readonly WeightedGroundTile[] = [
{ style: 0, sequence: 0, weight: 70 },
{ style: 0, sequence: 1, weight: 30 },
]
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Blend Test",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 123456,
pieces,
substitutions: [],
groundTiles: candidateTiles,
})
expect(result.stats.groundTiles).toBeDefined()
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(statsTiles).toHaveLength(2)
// Verify level cells contain both sequences
let seq0Count = 0
let seq1Count = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const floor = result.level.cells[y]![x]!.floors[0]
if (floor && !floor.hidden) {
if (floor.sequence === 0) seq0Count += 1
if (floor.sequence === 1) seq1Count += 1
}
}
}
expect(seq0Count).toBeGreaterThan(0)
expect(seq1Count).toBeGreaterThan(0)
expect(seq0Count).toBeGreaterThan(seq1Count)
})
test("generates wilderness with single groundTile maintaining pure single-tile fallback", () => {
const pieces = makeTestPieces()
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor Single Tile",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 654321,
pieces,
substitutions: [],
groundTile: { style: 0, sequence: 0 },
})
expect(result.stats.groundTile).toEqual({ style: 0, sequence: 0 })
})
test("generates wilderness with dt1Libraries deriving ground tiles automatically", () => {
const pieces = makeTestPieces()
const dt1 = makeMockDt1([
{ type: 0, style: 0, sequence: 0, rarityFrameIndex: 80 },
{ type: 0, style: 0, sequence: 1, rarityFrameIndex: 20 },
])
const result = generateWilderness({
levelId: 2,
levelName: "Blood Moor DT1 Test",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 777777,
pieces,
substitutions: [],
dt1Libraries: [dt1],
})
expect(result.stats.groundTiles).toBeDefined()
const statsTiles = result.stats.groundTiles as readonly WeightedGroundTile[]
expect(statsTiles).toHaveLength(2)
expect(statsTiles[0]!.sequence).toBe(0)
expect(statsTiles[1]!.sequence).toBe(1)
})
})
})

View File

@ -0,0 +1,401 @@
import { describe, expect, test } from 'vitest'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
buildJungleTopology,
DRLG_GenerateJungles,
type WildernessPiece,
type WildernessRequest,
} from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
/**
* Creates a mock DS1 piece of arbitrary size (e.g. 32x32 for jungle macro-cells, 64x32 for head/tail).
*/
function makeMockDs1(width: number, height: number, isWater = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [
{
prop1: isWater ? 194 : 2,
sequence: 0,
style: isWater ? 1 : 0,
unknown1: 0,
unknown2: 0,
hidden: false,
},
],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 3,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
/**
* Creates a complete mock suite of Act 3 Jungle pieces.
*/
function makeMockJunglePieces(): WildernessPiece[] {
const pieces: WildernessPiece[] = []
// 1. Head (TransL.ds1, 64x32) and Tail (TravL.ds1, 64x32)
pieces.push({
name: 'Act 3 - Jungle Head',
border: false,
levels: [makeMockDs1(64, 32)],
})
pieces.push({
name: 'Act 3 - Jungle Tail',
border: false,
levels: [makeMockDs1(64, 32)],
})
// 2. Wooden Bridge (Act 3 - Bridge, 32x32)
pieces.push({
name: 'Act 3 - Bridge',
border: false,
levels: [makeMockDs1(32, 32)],
})
// 3. Directional River Presets (32x32)
const riverTokens = [
'NS', 'EW', 'NE', 'NW', 'SE', 'SW',
'NSE', 'NSW', 'NEW', 'SEW', 'NSEW',
'NS W', 'NS E', 'EW N', 'EW S',
]
for (const token of riverTokens) {
pieces.push({
name: `Act 3 - Jungle ${token}`,
border: false,
levels: [makeMockDs1(32, 32, true)],
})
}
// 4. Clearings for each of the 3 jungle areas (32x32)
const clearingThemes = ['Webby', 'Boggy', 'Pygmy']
const openings = ['', ' E', ' W', ' EW', ' NE', ' NW', ' SE', ' SW']
for (const theme of clearingThemes) {
for (const opening of openings) {
pieces.push({
name: `Act 3 - Clearing ${theme}${opening}`,
border: false,
levels: [makeMockDs1(32, 32)],
})
}
}
return pieces
}
describe('Act 3 Jungle Branching Topology (buildJungleTopology)', () => {
test('guarantees Flayer Jungle (78) is always reachable from Spider Forest (76)', () => {
// Test across 100 random seeds
for (let seed = 1; seed <= 100; seed += 1) {
const topology = buildJungleTopology(seed)
expect(topology.zones).toHaveLength(3)
const spider = topology.zones.find(z => z.levelId === 76)!
const marsh = topology.zones.find(z => z.levelId === 77)!
const flayer = topology.zones.find(z => z.levelId === 78)!
expect(spider.name).toBe('Spider Forest')
expect(marsh.name).toBe('Great Marsh')
expect(flayer.name).toBe('Flayer Jungle')
// Check graph reachability: BFS from 76 to 78
const visited = new Set<number>([76])
const queue = [76]
while (queue.length > 0) {
const curr = queue.shift()!
const outgoing = topology.connections.filter(c => c.fromLevelId === curr)
for (const conn of outgoing) {
if (!visited.has(conn.toLevelId)) {
visited.add(conn.toLevelId)
queue.push(conn.toLevelId)
}
}
}
expect(visited.has(78)).toBe(true)
}
})
test('exhibits both Great Marsh skip active and inactive across different seeds', () => {
let skipsSeen = 0
let noSkipsSeen = 0
let marshDeadEndsSeen = 0
let marshThroughwaysSeen = 0
for (let seed = 1; seed <= 100; seed += 1) {
const topology = buildJungleTopology(seed)
if (topology.hasMarshSkip) {
skipsSeen += 1
const directSkipConn = topology.connections.find(
c => c.fromLevelId === 76 && c.toLevelId === 78,
)
expect(directSkipConn).toBeDefined()
expect(directSkipConn!.connectionType).toBe('skip')
} else {
noSkipsSeen += 1
// Without skip, Great Marsh must connect forward to Flayer Jungle
const marshToFlayer = topology.connections.find(
c => c.fromLevelId === 77 && c.toLevelId === 78,
)
expect(marshToFlayer).toBeDefined()
expect(topology.marshIsDeadEnd).toBe(false)
}
if (topology.marshIsDeadEnd) {
marshDeadEndsSeen += 1
expect(topology.hasMarshSkip).toBe(true)
} else {
marshThroughwaysSeen += 1
}
}
expect(skipsSeen).toBeGreaterThan(0)
expect(noSkipsSeen).toBeGreaterThan(0)
expect(marshDeadEndsSeen).toBeGreaterThan(0)
expect(marshThroughwaysSeen).toBeGreaterThan(0)
})
})
describe('DRLG_GenerateJungles River Network Generation', () => {
test('generates meandering river paths that wander across columns', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 42,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout).toBeDefined()
expect(layout.riverNetwork).toBeDefined()
const { meanderingPath, meanderCount } = layout.riverNetwork
expect(meanderingPath.length).toBeGreaterThanOrEqual(6)
expect(meanderCount).toBeGreaterThanOrEqual(1)
// Verify that the river visits multiple columns
const columnsVisited = new Set(meanderingPath.map((p: any) => p.col))
expect(columnsVisited.size).toBeGreaterThanOrEqual(2)
})
test('places river forks / tributary junctions with branching presets', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 77,
levelName: 'Great Marsh',
levelTypeName: 'Act 3 - Jungle',
seed: 12345,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.forks.length).toBeGreaterThan(0)
const fork = layout.riverNetwork.forks[0]!
expect(fork.row).toBeGreaterThanOrEqual(1)
expect(fork.row).toBeLessThanOrEqual(4)
expect(result.stats.riverForks).toBe(layout.riverNetwork.forks.length)
})
test('identifies and creates river island clearings flanked by river channels', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 999,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.islands.length).toBeGreaterThan(0)
const island = layout.riverNetwork.islands[0]!
expect(island.col).toBeGreaterThanOrEqual(0)
expect(island.row).toBeGreaterThanOrEqual(1)
expect(result.stats.riverIslands).toBe(layout.riverNetwork.islands.length)
// Check that clearings array flags the island
const islandClearing = layout.riverNetwork.clearings.find(
(c: any) => c.col === island.col && c.row === island.row,
)
expect(islandClearing).toBeDefined()
expect(islandClearing.isIsland).toBe(true)
})
test('places wooden bridges crossing river channels to connect islands and trails', () => {
const pieces = makeMockJunglePieces()
const request: WildernessRequest = {
levelId: 78,
levelName: 'Flayer Jungle',
levelTypeName: 'Act 3 - Jungle',
seed: 777,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
}
const result = generateWilderness(request)
const layout = result.stats.jungleLayout as any
expect(layout.riverNetwork.bridges.length).toBeGreaterThan(0)
// Bridge recorded in stats.substitutions and special presets
const bridgeSub = (result.stats.substitutions as any[]).find(
s => s.name === 'Act 3 - Bridge',
)
expect(bridgeSub).toBeDefined()
expect(bridgeSub.enabled).toBe(true)
expect(result.stats.bridgesPlaced).toBe(layout.riverNetwork.bridges.length)
// Bridge coordinates must align to grid cells
for (const bridge of layout.riverNetwork.bridges) {
expect(bridge.x).toBe(bridge.col * 32)
expect(bridge.y).toBe(bridge.row * 32)
expect(bridge.pieceName).toBe('Act 3 - Bridge')
}
})
})
describe('End-to-End generateWilderness Integration for Act 3 Jungles', () => {
test('DRLG_GenerateJungles is removed from UNIMPLEMENTED_PASSES', () => {
expect(UNIMPLEMENTED_PASSES).not.toContain('DRLG_GenerateJungles')
})
test('generates Spider Forest (76) with Webby clearings and complete jungle metadata', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 555,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(64)
expect(result.level.height).toBe(192)
expect(result.level.act).toBe(3)
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Webby'))).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Jungle Tail')).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Jungle Head')).toBe(true)
expect(subs.some(s => s.name === 'Act 3 - Bridge')).toBe(true)
expect(result.stats.jungleLayout).toBeDefined()
expect(result.stats.jungleTopology).toBeDefined()
})
test('generates Great Marsh (77) with Boggy clearings', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 77,
levelName: 'Great Marsh',
levelTypeName: 'Act 3 - Jungle',
seed: 888,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Boggy'))).toBe(true)
})
test('generates Flayer Jungle (78) with Pygmy clearings', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 78,
levelName: 'Flayer Jungle',
levelTypeName: 'Act 3 - Jungle',
seed: 999,
sizeX: 64,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
const subs = result.stats.substitutions as any[]
expect(subs.some(s => s.name.startsWith('Act 3 - Clearing Pygmy'))).toBe(true)
})
test('supports wider 3-column (96x192) jungle layouts', () => {
const pieces = makeMockJunglePieces()
const result = generateWilderness({
levelId: 76,
levelName: 'Spider Forest',
levelTypeName: 'Act 3 - Jungle',
seed: 1234,
sizeX: 96,
sizeY: 192,
subType: 0,
subTheme: 0,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(96)
expect(result.level.height).toBe(192)
const layout = result.stats.jungleLayout as any
expect(layout.gridColumns).toBe(3)
expect(layout.gridRows).toBe(6)
expect(layout.riverNetwork.meanderCount).toBeGreaterThanOrEqual(1)
})
})

View File

@ -0,0 +1,416 @@
import { describe, it, expect } from 'vitest'
import {
createCanvas,
stampAct1DirtRoads,
stampAct1InteriorStoneWalls,
stampAct2DesertRoads,
stampAct2SandstoneBerms,
stampAct3KurastAvenues,
stampAct3KurastBalustrades,
stampAct5SnowRoads,
stampAct5FrozenTrailDivides,
stampOutdoorRoads,
stampOutdoorDividers,
generateWilderness,
TILES_PER_BLOCK,
} from '../src/game/wilderness.ts'
import type { WildernessPiece, PlacedPresetRect, Canvas } from '../src/game/wilderness.ts'
import { Rng } from '../src/game/rng.ts'
import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts'
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }],
floors: [{ prop1: 2, sequence: 0, style: 1, 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: [],
npcPathOffset: null,
}
}
function countFloorTiles(canvas: Canvas, style: number, prop1: number): number {
let count = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell && cell.floors.some(f => f.style === style && f.prop1 === prop1)) {
count += 1
}
}
}
return count
}
function countWallTiles(canvas: Canvas, style: number, prop1: number): number {
let count = 0
for (let y = 0; y < canvas.height; y += 1) {
const row = canvas.cells[y]
if (!row) continue
for (let x = 0; x < canvas.width; x += 1) {
const cell = row[x]
if (cell && cell.walls.some(w => w.style === style && w.prop1 === prop1)) {
count += 1
}
}
}
return count
}
describe('Multi-Act Outdoor Roads & Boundary Divider Networks', () => {
const gridW = 10
const gridH = 10
const width = gridW * TILES_PER_BLOCK
const height = gridH * TILES_PER_BLOCK
const inX = Math.floor(gridW / 2)
const outY = Math.floor(gridH / 2)
describe('Act 2 Desert Roads & Sandstone Berms', () => {
it('stampAct2DesertRoads paints caravan routes with style: 1, prop1: 194 and connects spurs', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 2 - Desert Tomb 1', x: 24, y: 24, w: 16, h: 16 },
]
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
const roadFloors = countFloorTiles(canvas, 1, 194)
expect(roadFloors).toBeGreaterThan(50)
// Verify the main highway corridor has caravan road floors
const highwayTile = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
expect(highwayTile).toBeDefined()
expect(highwayTile?.floors.some(f => f.style === 1 && f.prop1 === 194)).toBe(true)
// Verify spur path connected towards the Tomb preset
let spurFound = false
for (let y = 30; y <= 34; y += 1) {
for (let x = 38; x <= 44; x += 1) {
const c = canvas.cells[y]?.[x]
if (c?.floors.some(f => f.style === 1 && f.prop1 === 194)) {
spurFound = true
break
}
}
}
expect(spurFound).toBe(true)
})
it('stampAct2SandstoneBerms paints sandstone berms with style: 1, prop1: 129, corner joins, and road archway breaks', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
const placedRects: PlacedPresetRect[] = []
// First stamp roads so the divider can create 3-tile archway breaks across them
stampAct2DesertRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct2SandstoneBerms(canvas, gridW, gridH, inX, outY, placedRects, rng)
const bermWalls = countWallTiles(canvas, 1, 129)
expect(bermWalls).toBeGreaterThan(20)
// Berms should not block the road where the road crosses the divider
// Highway crosses dividing lines at (inX * 8 + 4, outY * 8 + 4)
const crossRoad = canvas.cells[outY * TILES_PER_BLOCK + 4]?.[inX * TILES_PER_BLOCK + 4]
expect(crossRoad?.walls.some(w => w.style === 1 && w.prop1 === 129)).toBe(false)
})
})
describe('Act 3 Kurast Avenues & Balustrades', () => {
it('stampAct3KurastAvenues paints flagstone avenues with style: 1, prop1: 194', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(100)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 3 - Burbs Temple', x: 20, y: 20, w: 16, h: 16 },
]
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
const avenueFloors = countFloorTiles(canvas, 1, 194)
expect(avenueFloors).toBeGreaterThan(50)
})
it('stampAct3KurastBalustrades paints balustrades with style: 1, prop1: 129', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(100)
const placedRects: PlacedPresetRect[] = []
stampAct3KurastAvenues(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct3KurastBalustrades(canvas, gridW, gridH, inX, outY, placedRects, rng)
const balustradeWalls = countWallTiles(canvas, 1, 129)
expect(balustradeWalls).toBeGreaterThan(20)
})
})
describe('Act 5 Snow Roads & Frozen Trail Divides', () => {
it('stampAct5SnowRoads paints snow march roads with style: 1, prop1: 194', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(555)
const placedRects: PlacedPresetRect[] = [
{ name: 'Act 5 - Barricade Building', x: 20, y: 20, w: 16, h: 16 },
]
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
const snowRoadFloors = countFloorTiles(canvas, 1, 194)
expect(snowRoadFloors).toBeGreaterThan(50)
})
it('stampAct5FrozenTrailDivides paints frozen trail divides with style: 2, prop1: 129', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(555)
const placedRects: PlacedPresetRect[] = []
stampAct5SnowRoads(canvas, gridW, gridH, inX, outY, placedRects, rng)
stampAct5FrozenTrailDivides(canvas, gridW, gridH, inX, outY, placedRects, rng)
const frozenDivides = countWallTiles(canvas, 2, 129)
expect(frozenDivides).toBeGreaterThan(20)
})
})
describe('Unified Dispatchers: stampOutdoorRoads & stampOutdoorDividers', () => {
it('dispatches Act 2 Desert based on levelId 41', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 41, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15)
})
it('dispatches Act 3 Kurast based on levelId 80', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 80, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 1, 129)).toBeGreaterThan(15)
})
it('dispatches Act 5 Snow based on levelId 111', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 111, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15)
})
it('dispatches Act 1 Dirt Roads based on levelId 3', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(123)
stampOutdoorRoads(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
stampOutdoorDividers(canvas, 1, 3, gridW, gridH, inX, outY, [], rng)
expect(countFloorTiles(canvas, 5, 194)).toBeGreaterThan(40)
expect(countWallTiles(canvas, 2, 129)).toBeGreaterThan(15)
})
it('dispatches based on levelTypeName when levelId is generic', () => {
const c2 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
stampOutdoorDividers(c2, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 2 - Desert')
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15)
const c3 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
stampOutdoorDividers(c3, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 3 - Kurast')
expect(countFloorTiles(c3, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c3, 1, 129)).toBeGreaterThan(15)
const c5 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
stampOutdoorDividers(c5, 1, 999, gridW, gridH, inX, outY, [], new Rng(1), 'Act 5 - Barricade')
expect(countFloorTiles(c5, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c5, 2, 129)).toBeGreaterThan(15)
})
it('dispatches based on act number fallback', () => {
const c2 = createCanvas(width, height, 1, 1, 0)
stampOutdoorRoads(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
stampOutdoorDividers(c2, 2, 999, gridW, gridH, inX, outY, [], new Rng(1))
expect(countFloorTiles(c2, 1, 194)).toBeGreaterThan(40)
expect(countWallTiles(c2, 1, 129)).toBeGreaterThan(15)
})
it('strictly skips Cow Level (levelId 39)', () => {
const canvas = createCanvas(width, height, 1, 1, 0)
const rng = new Rng(42)
stampOutdoorRoads(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
stampOutdoorDividers(canvas, 1, 39, gridW, gridH, inX, outY, [], rng, 'Act 1 - Wilderness')
expect(countFloorTiles(canvas, 1, 194)).toBe(0)
expect(countFloorTiles(canvas, 5, 194)).toBe(0)
expect(countWallTiles(canvas, 1, 129)).toBe(0)
expect(countWallTiles(canvas, 2, 129)).toBe(0)
})
})
describe('generateWilderness Integration for Acts 2, 3, 5', () => {
const borderDs1 = makeMockDs1(8, 8)
const borderPiece: WildernessPiece = {
name: 'Act 2 - Desert Border 1',
border: true,
levels: [borderDs1],
}
it('generates Act 2 Rocky Waste (Level 41) with caravan routes and sandstone berms', () => {
const result = generateWilderness({
levelId: 41,
levelName: 'Rocky Waste',
levelTypeName: 'Act 2 - Desert',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 77777,
pieces: [borderPiece],
substitutions: [],
})
expect(result.level.act).toBe(2)
let caravanRoads = 0
let sandstoneBerms = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) caravanRoads += 1
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) sandstoneBerms += 1
}
}
expect(caravanRoads).toBeGreaterThan(30)
expect(sandstoneBerms).toBeGreaterThan(15)
})
it('generates Act 3 Kurast Bazaar (Level 80) with Kurast avenues and balustrades', () => {
const act3Border: WildernessPiece = {
name: 'Act 3 - Kurast Border',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 80,
levelName: 'Kurast Bazaar',
levelTypeName: 'Act 3 - Kurast',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 88888,
pieces: [act3Border],
substitutions: [],
})
expect(result.level.act).toBe(3)
let avenues = 0
let balustrades = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) avenues += 1
if (cell.walls.some(w => w.style === 1 && w.prop1 === 129)) balustrades += 1
}
}
expect(avenues).toBeGreaterThan(30)
expect(balustrades).toBeGreaterThan(15)
})
it('generates Act 5 Frigid Highlands (Level 111) with snow roads and frozen trail divides', () => {
const act5Border: WildernessPiece = {
name: 'Act 5 - Barricade Border',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 111,
levelName: 'Frigid Highlands',
levelTypeName: 'Act 5 - Barricade',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 99999,
pieces: [act5Border],
substitutions: [],
})
expect(result.level.act).toBe(5)
let snowRoads = 0
let frozenDivides = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => f.style === 1 && f.prop1 === 194)) snowRoads += 1
if (cell.walls.some(w => w.style === 2 && w.prop1 === 129)) frozenDivides += 1
}
}
expect(snowRoads).toBeGreaterThan(30)
expect(frozenDivides).toBeGreaterThan(15)
})
it('generates Cow Level (Level 39) without roads or stone divider networks', () => {
const cowBorder: WildernessPiece = {
name: 'Act 1 - Wild Border 1',
border: true,
levels: [borderDs1],
}
const result = generateWilderness({
levelId: 39,
levelName: 'Moo Moo Farm',
levelTypeName: 'Act 1 - Wilderness',
sizeX: 80,
sizeY: 80,
subType: 0,
subTheme: 0,
seed: 12345,
pieces: [cowBorder],
substitutions: [],
})
let roadFloors = 0
let dividerWalls = 0
for (let y = 8; y < 72; y += 1) {
for (let x = 8; x < 72; x += 1) {
const cell = result.level.cells[y]?.[x]
if (!cell) continue
if (cell.floors.some(f => (f.style === 1 || f.style === 5) && f.prop1 === 194)) roadFloors += 1
if (cell.walls.some(w => (w.style === 1 || w.style === 2) && w.prop1 === 129)) dividerWalls += 1
}
}
expect(roadFloors).toBe(0)
expect(dividerWalls).toBe(0)
})
})
})

View File

@ -0,0 +1,418 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
UNIMPLEMENTED_PASSES,
} from "../src/game/wilderness.ts"
import type { WildernessPiece } from "../src/game/wilderness.ts"
import {
ACT5_SIEGE_STRIP_NAMES,
type SiegeTopographyStats,
} from "../src/game/wilderness-siege.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockStripDs1(
width: number,
height: number,
tagSequence: number,
): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [
{
prop1: 2,
sequence: x === 0 && y === 0 ? tagSequence : 0,
style: 0,
unknown1: 0,
unknown2: 0,
hidden: false,
},
],
shadows: [],
substitutions: [],
})
}
cells.push(row)
}
return {
version: 18,
width,
height,
act: 5,
substitutionType: 0,
wallLayers: 1,
floorLayers: 1,
cells,
objects: [],
npcPathOffset: null,
}
}
function createMockSiegePieces(): WildernessPiece[] {
return ACT5_SIEGE_STRIP_NAMES.map((name, idx) => ({
name,
border: false,
levels: [makeMockStripDs1(16, 48, 100 + idx)],
}))
}
describe("Act 5 Bloody Foothills Dynamic Siege Generation (DRLGOUTSIEGE)", () => {
test("DRLGOUTSIEGE_InitAct5OutdoorLevel is removed from UNIMPLEMENTED_PASSES", () => {
expect(UNIMPLEMENTED_PASSES).not.toContain(
"DRLGOUTSIEGE_InitAct5OutdoorLevel",
)
})
test("generates 240x48 Bloody Foothills canvas with 15 sequential strips from West to East", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 12345,
pieces,
substitutions: [],
})
expect(result.level.width).toBe(240)
expect(result.level.height).toBe(48)
expect(result.stats.blockGrid).toEqual({ width: 30, height: 6 })
expect(result.stats.substitutedClusters).toBe(15)
// Strip 0 (Siege To Barricade) is at x=0, Strip 14 (Siege To Town) is at x=224
expect(result.level.cells[0]![0]!.floors[0]!.sequence).toBe(100)
expect(result.level.cells[0]![224]!.floors[0]!.sequence).toBe(114)
})
test("supports dynamic strip preset level variants selected by seed", () => {
const pieces: WildernessPiece[] = ACT5_SIEGE_STRIP_NAMES.map((name, idx) => {
// Provide 3 different DS1 variants with distinct floor tags at (0,1)
const varA = makeMockStripDs1(16, 48, 100 + idx)
const varB = makeMockStripDs1(16, 48, 200 + idx)
const varC = makeMockStripDs1(16, 48, 300 + idx)
;(varA.cells[1]![0]!.floors as any)[0].sequence = 1
;(varB.cells[1]![0]!.floors as any)[0].sequence = 2
;(varC.cells[1]![0]!.floors as any)[0].sequence = 3
return {
name,
border: false,
levels: [varA, varB, varC],
}
})
const runA = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const runB = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 999999,
pieces,
substitutions: [],
})
// Compare variant selections across strips
const tagsA = [16, 32, 48, 64, 80, 96, 112].map(
x => runA.level.cells[1]![x]!.floors[0]!.sequence,
)
const tagsB = [16, 32, 48, 64, 80, 96, 112].map(
x => runB.level.cells[1]![x]!.floors[0]!.sequence,
)
// At least one strip variant differs between the two seeds
expect(tagsA).not.toEqual(tagsB)
})
test("supports dynamic strip selection with named variant pieces", () => {
const pieces = createMockSiegePieces()
// Replace Strip 5 with a named variant piece
const strip5Idx = pieces.findIndex(p => p.name === "Act 5 - Siege Strip 5")
pieces[strip5Idx] = {
name: "Act 5 - Siege Strip 5 Variant Bravo",
border: false,
levels: [makeMockStripDs1(16, 48, 777)],
}
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 777,
pieces,
substitutions: [],
})
// Strip 5 is at slot index 9 (x = 9 * 16 = 144)
expect(result.level.cells[0]![144]!.floors[0]!.sequence).toBe(777)
})
test("generates battlefield trenches with cleared wall channels", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats).toBeDefined()
expect(stats.trenchCells).toBeGreaterThan(60)
// Scan level for trench floor cells (style: 5, prop1: 194)
let foundTrenchCells = 0
let wallsClearedInTrenches = true
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
const hasTrenchFloor = cell.floors.some(
f => f.style === 5 && f.prop1 === 194,
)
if (hasTrenchFloor) {
foundTrenchCells += 1
// Trenches must be cleared of blocking walls
if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) {
wallsClearedInTrenches = false
}
}
}
}
expect(foundTrenchCells).toBe(stats.trenchCells)
expect(wallsClearedInTrenches).toBe(true)
})
test("generates breached wooden barricades with splintered end-caps and open breach gaps", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats.barricadeWalls).toBeGreaterThan(30)
// Check for barricade walls (style: 2, prop1: 129)
let foundBarricadeWalls = 0
let foundSplinteredEndCaps = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
const barricadeWall = cell.walls.find(
w => !w.hidden && w.style === 2 && w.prop1 === 129,
)
if (barricadeWall) {
foundBarricadeWalls += 1
if (barricadeWall.sequence === 1 || barricadeWall.sequence === 2) {
foundSplinteredEndCaps += 1
}
}
}
}
expect(foundBarricadeWalls).toBe(stats.barricadeWalls)
// Splintered end-caps (seq: 1/2) must flank the breaches
expect(foundSplinteredEndCaps).toBeGreaterThan(0)
})
test("generates catapult defense platforms with parapets", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats.catapultPlatforms).toBe(3)
// Scan for catapult platform floors (style: 4, prop1: 2)
let platformFloorCount = 0
let platformParapetCount = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 4 && f.prop1 === 2)) {
platformFloorCount += 1
}
if (cell.walls.some(w => !w.hidden && w.style === 4 && w.prop1 === 2)) {
platformParapetCount += 1
}
}
}
expect(platformFloorCount).toBeGreaterThan(30)
expect(platformParapetCount).toBeGreaterThan(15)
})
test("generates fort ruins interspersed between siege strips", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
expect(stats.fortRuins).toBe(4)
// Scan for fort ruin flagstone floors (style: 1, prop1: 2) and stone walls (style: 1, prop1: 129)
let ruinFloorCount = 0
let ruinWallCount = 0
for (let y = 0; y < result.level.height; y += 1) {
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[y]![x]!
if (cell.floors.some(f => f.style === 1 && f.prop1 === 2)) {
ruinFloorCount += 1
}
if (cell.walls.some(w => !w.hidden && w.style === 1 && w.prop1 === 129)) {
ruinWallCount += 1
}
}
}
expect(ruinFloorCount).toBeGreaterThan(40)
expect(ruinWallCount).toBeGreaterThan(15)
})
test("enforces progressive frontline fortification density from East (Harrogath) to West (Barricade)", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const stats = result.stats.siegeTopography as SiegeTopographyStats
const { westFrontlineObstacles, midFieldObstacles, eastRearObstacles } =
stats.frontlineDensity
// West Frontline (Shenk / Arreat Barricade) > Mid Field > East Rear (Harrogath)
expect(westFrontlineObstacles).toBeGreaterThan(midFieldObstacles)
expect(midFieldObstacles).toBeGreaterThanOrEqual(eastRearObstacles)
expect(eastRearObstacles).toBeGreaterThan(0)
expect(westFrontlineObstacles).toBe(10)
expect(midFieldObstacles).toBe(6)
expect(eastRearObstacles).toBe(3)
})
test("preserves traversable central thoroughfare along y=24 from Harrogath exit to Barricade entrance", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
// Walk horizontally across y = 24 from East (x = 239) to West (x = 0)
const yCorridor = 24
let blockedCells = 0
for (let x = 0; x < result.level.width; x += 1) {
const cell = result.level.cells[yCorridor]![x]!
const hasBlockingWall = cell.walls.some(
w => !w.hidden && w.prop1 !== 0,
)
if (hasBlockingWall) {
blockedCells += 1
}
}
// Central line along y = 24 should have 0 blocking walls
expect(blockedCells).toBe(0)
})
test("records notes and telemetry in result.stats", () => {
const pieces = createMockSiegePieces()
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 42,
pieces,
substitutions: [],
})
const notes = result.stats.notes as string[]
expect(notes).toContain("generated dynamic siege trenches and fortifications")
expect(result.stats.siegeTopography).toBeDefined()
})
})

View File

@ -0,0 +1,397 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
buildLandmarkEntitiesFromTables,
spawnLandmarkEntities,
CANONICAL_LANDMARK_ENTITIES,
type LandmarkEntityDef,
type WildernessPiece,
} from "../src/game/wilderness.ts"
import {
planLevelMonsters,
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
CANONICAL_ELITE_MODIFIERS,
} from "../src/game/monsters.ts"
import { parseTable } from "../src/game/acts.ts"
import type { D2Table } from "../src/game/acts.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function mockTable(tsv: string): D2Table {
const encoder = new TextEncoder()
return parseTable(encoder.encode(tsv.trim()))
}
function makeMockDs1(width: number, height: number): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: [],
floors: [{ prop1: 1, sequence: 0, style: 1, 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: [],
npcPathOffset: null,
}
}
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
test("contains all 25 canonical landmark entities across Acts 1-5", () => {
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(25)
// Act 1 (6 entities: 5 SuperUniques + 1 NPC)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Flavie" && e.levelIds.includes(3))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bishibosh" && e.levelIds.includes(3))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Rakanishu" && e.levelIds.includes(4))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Treehead WoodFist" && e.levelIds.includes(5))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Blood Raven" && e.levelIds.includes(17))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "The Countess" && e.levelIds.includes(25))).toBe(true)
// Act 2 (5 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dark Elder" && e.levelIds.includes(44))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Creeping Feature" && e.levelIds.includes(59))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bloodwitch the Wild" && e.levelIds.includes(60))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Fangskin" && e.levelIds.includes(61))).toBe(true)
// Act 3 (4 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Stormtree" && e.levelIds.includes(78))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Sszark the Burning" && e.levelIds.includes(84))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Witch Doctor Endugu" && e.levelIds.includes(91))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Battlemaid Sarina" && e.levelIds.includes(94))).toBe(true)
// Act 4 (3 entities: 2 SuperUniques + 1 NPC)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Izual" && e.levelIds.includes(105))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hephasto the Armorer" && e.levelIds.includes(107))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hadriel" && e.levelIds.includes(108))).toBe(true)
// Act 5 (7 entities)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dac Farren" && e.levelIds.includes(110))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Shenk the Overseer" && e.levelIds.includes(110))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Eyeback the Unleashed" && e.levelIds.includes(111))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Thresh Socket" && e.levelIds.includes(112))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Frozenstein" && e.levelIds.includes(114))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bonesaw Breaker" && e.levelIds.includes(115))).toBe(true)
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Nihlathak" && e.levelIds.includes(124))).toBe(true)
})
})
describe("buildLandmarkEntitiesFromTables", () => {
test("returns canonical definitions when no tables are supplied", () => {
const entities = buildLandmarkEntitiesFromTables()
expect(entities.length).toBe(CANONICAL_LANDMARK_ENTITIES.length)
expect(entities[0].name).toBe(CANONICAL_LANDMARK_ENTITIES[0].name)
})
test("overrides boss modifiers and properties from SuperUniques.txt", () => {
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
const superUniquesTsv = [
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
"Bishibosh\tfallen2\t8\t9\t0\t2\t5\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
"Rakanishu\tfallen3\t17\t6\t0\t3\t6\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
].join("\n")
const suTable = mockTable(superUniquesTsv)
const entities = buildLandmarkEntitiesFromTables(suTable)
const bishibosh = entities.find(e => e.id === "Bishibosh")
expect(bishibosh).toBeDefined()
expect(bishibosh?.monsterId).toBe("fallen2")
expect(bishibosh?.modifiers).toEqual(["magicresistant", "fireenchant"])
expect(bishibosh?.minionCount).toEqual([2, 5])
const rakanishu = entities.find(e => e.id === "Rakanishu")
expect(rakanishu).toBeDefined()
expect(rakanishu?.monsterId).toBe("fallen3")
expect(rakanishu?.modifiers).toEqual(["lightenchant", "fast"])
expect(rakanishu?.minionCount).toEqual([3, 6])
})
test("resolves objectId from MonPreset.txt", () => {
const monPresetTsv = [
"Act\tPlace\tComponent\tObjectId",
"1\tCairn Stones\t1\t105",
"4\tChaos Sanctuary\t1\t350",
].join("\n")
const mpTable = mockTable(monPresetTsv)
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
const rakanishu = entities.find(e => e.name === "Rakanishu")
expect(rakanishu?.objectId).toBe(105)
const hadriel = entities.find(e => e.name === "Hadriel")
expect(hadriel?.objectId).toBe(350)
})
})
describe("spawnLandmarkEntities in Wilderness Generator", () => {
test("places Rakanishu centered on Cairn Stones preset piece", () => {
const cairnDs1 = makeMockDs1(16, 16)
const borderDs1 = makeMockDs1(8, 8)
const pieces: WildernessPiece[] = [
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] },
{ name: "Act 1 - Cairn Stones", border: false, levels: [cairnDs1] },
]
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 42,
pieces,
substitutions: [],
specialPresets: ["Act 1 - Cairn Stones"],
})
expect(result.stats.landmarkEntities).toBeDefined()
const rakanishu = result.stats.landmarkEntities?.find(e => e.name === "Rakanishu")
expect(rakanishu).toBeDefined()
expect(rakanishu?.levelId).toBe(4)
// Verify subtile coordinates: tileX * 5 + 2
expect(rakanishu?.subtileX).toBe(rakanishu!.tileX * 5 + 2)
expect(rakanishu?.subtileY).toBe(rakanishu!.tileY * 5 + 2)
// Verify canvas.objects contains the Type 1 entity
const obj = result.level.objects.find(
o => o.type === 1 && o.x === rakanishu!.subtileX && o.y === rakanishu!.subtileY
)
expect(obj).toBeDefined()
expect(obj?.id).toBe(rakanishu?.objectId)
})
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
const stripDs1 = makeMockDs1(16, 48)
const pieces: WildernessPiece[] = [
{ name: "Act 5 - Siege To Barricade", border: false, levels: [stripDs1] },
]
const result = generateWilderness({
levelId: 110,
levelName: "Bloody Foothills",
levelTypeName: "Act 5 - Siege",
sizeX: 240,
sizeY: 48,
subType: -1,
subTheme: -1,
seed: 110,
pieces,
substitutions: [],
})
const landmarks = result.stats.landmarkEntities ?? []
const dacFarren = landmarks.find(e => e.name === "Dac Farren")
const shenk = landmarks.find(e => e.name === "Shenk the Overseer")
expect(dacFarren).toBeDefined()
expect(shenk).toBeDefined()
expect(dacFarren?.objectId).toBe(87)
expect(shenk?.objectId).toBe(88)
// Both must be recorded in canvas.objects as type 1
const dacObj = result.level.objects.find(
o => o.type === 1 && o.x === dacFarren!.subtileX && o.y === dacFarren!.subtileY
)
const shenkObj = result.level.objects.find(
o => o.type === 1 && o.x === shenk!.subtileX && o.y === shenk!.subtileY
)
expect(dacObj).toBeDefined()
expect(shenkObj).toBeDefined()
})
test("places Act 4 Hadriel on Chaos Sanctuary (Level 108)", () => {
const csDs1 = makeMockDs1(25, 25)
const pieces: WildernessPiece[] = [
{ name: "Act 4 - Diablo Entry", border: false, levels: [csDs1] },
]
const result = generateWilderness({
levelId: 108,
levelName: "Chaos Sanctuary",
levelTypeName: "Act 4 - Lava",
sizeX: 120,
sizeY: 120,
subType: -1,
subTheme: -1,
seed: 108,
pieces,
substitutions: [],
})
const hadriel = result.stats.landmarkEntities?.find(e => e.name === "Hadriel")
expect(hadriel).toBeDefined()
expect(hadriel?.objectId).toBe(86)
expect(hadriel?.role).toBe("npc_guard")
const obj = result.level.objects.find(
o => o.type === 1 && o.x === hadriel!.subtileX && o.y === hadriel!.subtileY
)
expect(obj).toBeDefined()
expect(obj?.id).toBe(86)
})
test("supports customLandmarks override in WildernessRequest", () => {
const borderDs1 = makeMockDs1(8, 8)
const customLandmarks: LandmarkEntityDef[] = [
{
id: "CustomBoss",
name: "Test Custom Boss",
role: "superunique",
act: 1,
levelIds: [4],
monsterId: "fallen1",
defaultLandmark: "Custom Landmark",
modifiers: ["lightenchant", "fast"],
minionCount: [4, 6],
minionMonsterId: "fallen1",
objectId: 999,
defaultRelativePos: { x: 0.5, y: 0.5 },
},
]
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 123,
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
substitutions: [],
customLandmarks,
})
expect(result.stats.landmarkEntities?.length).toBe(1)
const custom = result.stats.landmarkEntities?.[0]
expect(custom?.name).toBe("Test Custom Boss")
expect(custom?.tileX).toBe(40)
expect(custom?.tileY).toBe(40)
expect(custom?.objectId).toBe(999)
})
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
const borderDs1 = makeMockDs1(8, 8)
const result = generateWilderness({
levelId: 5, // Dark Wood (Treehead WoodFist)
levelName: "Dark Wood",
levelTypeName: "Act 1 - Wilderness",
sizeX: 100,
sizeY: 100,
subType: 6,
subTheme: 0,
seed: 99,
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
substitutions: [],
})
const treehead = result.stats.landmarkEntities?.find(e => e.name === "Treehead WoodFist")
expect(treehead).toBeDefined()
expect(treehead?.tileX).toBeGreaterThanOrEqual(1)
expect(treehead?.tileX).toBeLessThan(99)
expect(treehead?.tileY).toBeGreaterThanOrEqual(1)
expect(treehead?.tileY).toBeLessThan(99)
})
})
describe("Integration with planLevelMonsters (src/game/monsters.ts)", () => {
test("Aura Enchanted modifier exists in CANONICAL_ELITE_MODIFIERS", () => {
const aura = CANONICAL_ELITE_MODIFIERS.find(m => m.name === "aura")
expect(aura).toBeDefined()
expect(aura?.label).toBe("Aura Enchanted")
expect(aura?.id).toBe(30)
})
test("CANONICAL_SUPER_UNIQUES_BY_LEVEL maps all 5 Acts correctly", () => {
// Act 1
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[3]).toBeDefined() // Bishibosh
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[4]).toBeDefined() // Rakanishu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[5]).toBeDefined() // Treehead WoodFist
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[17]).toBeDefined() // Blood Raven
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[25]).toBeDefined() // Countess
// Act 2
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[43]).toBeDefined() // Beetleburst
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[44]).toBeDefined() // Dark Elder
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[59]).toBeDefined() // Creeping Feature
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[60]).toBeDefined() // Bloodwitch
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[61]).toBeDefined() // Fangskin
// Act 3
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[76]).toBeDefined() // Sszark
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[78]).toBeDefined() // Stormtree & Endugu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[84]).toBeDefined() // Sszark
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[88]).toBeDefined() // Endugu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[91]).toBeDefined() // Endugu
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[94]).toBeDefined() // Sarina
// Act 4
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[105]).toBeDefined() // Izual
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[107]).toBeDefined() // Hephasto
// Act 5
const act5Bloody = CANONICAL_SUPER_UNIQUES_BY_LEVEL[110] // Dac Farren & Shenk
expect(Array.isArray(act5Bloody)).toBe(true)
expect((act5Bloody as readonly unknown[]).length).toBe(2)
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[111]).toBeDefined() // Eyeback
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[112]).toBeDefined() // Thresh Socket
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[114]).toBeDefined() // Frozenstein
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[115]).toBeDefined() // Bonesaw
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[124]).toBeDefined() // Nihlathak
})
test("planLevelMonsters creates deterministic boss packs for multi-boss level 110", () => {
const levelsTxt = mockTable([
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
"110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1",
].join("\n"))
const monstatsTxt = mockTable([
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",
].join("\n"))
const monlvlTxt = mockTable([
"Level\tAC\tTH\tHP\tDM\tXP",
"35\t100\t100\t100\t100\t100",
].join("\n"))
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, "normal")
expect(plan.superUniques).toContain("Dac Farren")
expect(plan.superUniques).toContain("Shenk the Overseer")
const dacPack = plan.packs.find(p => p.superUniqueId === "Dac Farren")
const shenkPack = plan.packs.find(p => p.superUniqueId === "Shenk the Overseer")
expect(dacPack).toBeDefined()
expect(shenkPack).toBeDefined()
expect(dacPack?.members[0]?.rank).toBe("unique")
expect(shenkPack?.members[0]?.rank).toBe("unique")
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
})
})
})

View File

@ -0,0 +1,429 @@
import { describe, expect, test } from "vitest"
import {
generateWilderness,
OUTDOOR_WAYPOINT_LEVELS,
actOfLevel,
ACT_POPULATION_CONFIG,
type WildernessPiece,
type WildernessSubstitution,
} from "../src/game/wilderness.ts"
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
function makeMockDs1(width: number, height: number, hasGapInWalls = false, isOpen = false): Ds1 {
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
let isWall: boolean
if (isOpen) {
// Clearings have open interior floor with perimeter walls
isWall = (x === 0 || y === 0 || y === height - 1 || (x === width - 1 && !hasGapInWalls))
} else {
isWall = !(hasGapInWalls && x === Math.floor(width / 2))
}
row.push({
walls: isWall ? [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }] : [],
floors: [{ prop1: 2, 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: [],
npcPathOffset: null,
}
}
function makeGenericOutdoorPieces(prefix: string, landmarkName?: string): WildernessPiece[] {
const pieces: WildernessPiece[] = []
for (let i = 1; i <= 12; i += 1) {
pieces.push({
name: `${prefix} Border ${i}`,
border: true,
levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)],
})
}
pieces.push({ name: `${prefix} Town Transition East`, border: true, levels: [makeMockDs1(8, 8, true)] })
pieces.push({ name: `${prefix} Town Transition South`, border: true, levels: [makeMockDs1(8, 8, true)] })
if (landmarkName) {
pieces.push({ name: landmarkName, border: false, levels: [makeMockDs1(8, 8, true, true)] })
}
return pieces
}
function makeAct3JunglePieces(): WildernessPiece[] {
const pieces: WildernessPiece[] = [
{ name: "Act 3 - Jungle Tail", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Jungle Head", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Clearing Pygmy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Clearing Webby E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Clearing Boggy E", border: false, levels: [makeMockDs1(32, 32, true, true)] },
{ name: "Act 3 - Jungle NS W", border: false, levels: [makeMockDs1(32, 32, true, false)] },
]
return pieces
}
describe("Wilderness Thematic Waypoint and Shrine Distribution (Issue #56)", () => {
test("actOfLevel correctly identifies acts from levelId or levelTypeName", () => {
expect(actOfLevel(3, "Act 1 - Wilderness")).toBe(1)
expect(actOfLevel(43, "Act 2 - Desert")).toBe(2)
expect(actOfLevel(78, "Act 3 - Jungle")).toBe(3)
expect(actOfLevel(106, "Act 4 - Mesa")).toBe(4)
expect(actOfLevel(112, "Act 5 - Barricade")).toBe(5)
// Falls back to numeric levelId ranges when levelTypeName is generic
expect(actOfLevel(4, undefined)).toBe(1)
expect(actOfLevel(42, undefined)).toBe(2)
expect(actOfLevel(76, undefined)).toBe(3)
expect(actOfLevel(107, undefined)).toBe(4)
expect(actOfLevel(117, undefined)).toBe(5)
})
test("OUTDOOR_WAYPOINT_LEVELS canonical set covers Acts 1..5 outdoor waypoints", () => {
// Act 1
expect(OUTDOOR_WAYPOINT_LEVELS.has(3)).toBe(true) // Cold Plains
expect(OUTDOOR_WAYPOINT_LEVELS.has(4)).toBe(true) // Stony Field
expect(OUTDOOR_WAYPOINT_LEVELS.has(5)).toBe(true) // Dark Wood
expect(OUTDOOR_WAYPOINT_LEVELS.has(6)).toBe(true) // Black Marsh
expect(OUTDOOR_WAYPOINT_LEVELS.has(2)).toBe(false) // Blood Moor (no WP)
// Act 2
expect(OUTDOOR_WAYPOINT_LEVELS.has(42)).toBe(true) // Dry Hills
expect(OUTDOOR_WAYPOINT_LEVELS.has(43)).toBe(true) // Far Oasis
expect(OUTDOOR_WAYPOINT_LEVELS.has(44)).toBe(true) // Lost City
expect(OUTDOOR_WAYPOINT_LEVELS.has(46)).toBe(true) // Canyon of the Magi
expect(OUTDOOR_WAYPOINT_LEVELS.has(41)).toBe(false) // Rocky Waste (no WP)
// Act 3
expect(OUTDOOR_WAYPOINT_LEVELS.has(76)).toBe(true) // Spider Forest
expect(OUTDOOR_WAYPOINT_LEVELS.has(77)).toBe(true) // Great Marsh
expect(OUTDOOR_WAYPOINT_LEVELS.has(78)).toBe(true) // Flayer Jungle
expect(OUTDOOR_WAYPOINT_LEVELS.has(79)).toBe(true) // Lower Kurast
expect(OUTDOOR_WAYPOINT_LEVELS.has(80)).toBe(true) // Kurast Bazaar
expect(OUTDOOR_WAYPOINT_LEVELS.has(81)).toBe(true) // Upper Kurast
expect(OUTDOOR_WAYPOINT_LEVELS.has(83)).toBe(true) // Travincal
// Act 4
expect(OUTDOOR_WAYPOINT_LEVELS.has(106)).toBe(true) // City of the Damned
expect(OUTDOOR_WAYPOINT_LEVELS.has(107)).toBe(true) // River of Flame
expect(OUTDOOR_WAYPOINT_LEVELS.has(104)).toBe(false) // Outer Steppes (no WP)
// Act 5
expect(OUTDOOR_WAYPOINT_LEVELS.has(111)).toBe(true) // Frigid Highlands
expect(OUTDOOR_WAYPOINT_LEVELS.has(112)).toBe(true) // Arreat Plateau
expect(OUTDOOR_WAYPOINT_LEVELS.has(117)).toBe(true) // Frozen Tundra
expect(OUTDOOR_WAYPOINT_LEVELS.has(110)).toBe(false) // Bloody Foothills (no WP)
})
test("Act 2 roadless maps (Far Oasis 43, Lost City 44) spawn Waypoint id 10 near oasis/ruins or hub", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Ruin 1")
const result = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed: 0x434343,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
expect(result.stats.waypointTile).not.toBeNull()
// Waypoint object for Act 2 is ID 10
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 10)
expect(wpObj).toBeDefined()
// Shrines in Act 2 are from Act 2 shrine pool (54, 55, 103, 104, 105, 126)
const act2ShrinePool = new Set(ACT_POPULATION_CONFIG[2].shrinePool)
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act2ShrinePool.has(o.id))
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
// Well for Act 2 is ID 234
const wellObj = result.level.objects.find(o => o.type === 2 && o.id === 234)
expect(wellObj).toBeDefined()
})
test("Act 2 Rocky Waste (level 41) does not spawn waypoint", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
const result = generateWilderness({
levelId: 41,
levelName: "Rocky Waste",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed: 0x414141,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(0)
expect(result.stats.waypointTile).toBeNull()
})
test("Act 3 Flayer Jungle (level 78) spawns Waypoint id 1 and Act 3 shrines in jungle clearings", () => {
const pieces = makeAct3JunglePieces()
const result = generateWilderness({
levelId: 78,
levelName: "Flayer Jungle",
levelTypeName: "Act 3 - Jungle",
sizeX: 64,
sizeY: 192,
subType: 10,
subTheme: 0,
seed: 0x787878,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
expect(result.stats.waypointTile).not.toBeNull()
// Waypoint object for Act 3 is ID 1
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 1)
expect(wpObj).toBeDefined()
// Waypoint is inside the clearing column (x < 32)
const wpTile = result.stats.waypointTile as { x: number; y: number }
expect(wpTile.x).toBeLessThan(32)
// Act 3 shrines spawned
const act3ShrinePool = new Set(ACT_POPULATION_CONFIG[3].shrinePool)
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act3ShrinePool.has(o.id))
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
})
test("Act 4 City of the Damned (level 106) spawns Waypoint id 0 and Act 4 shrines", () => {
const pieces = makeGenericOutdoorPieces("Act 4 - Mesa")
const result = generateWilderness({
levelId: 106,
levelName: "City of the Damned",
levelTypeName: "Act 4 - Mesa",
sizeX: 80,
sizeY: 80,
subType: 12,
subTheme: 0,
seed: 0x106106,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
// Act 4 waypoint id is 0
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 0)
expect(wpObj).toBeDefined()
// Outer Steppes (104) has no waypoint
const steppesResult = generateWilderness({
levelId: 104,
levelName: "Outer Steppes",
levelTypeName: "Act 4 - Mesa",
sizeX: 80,
sizeY: 80,
subType: 12,
subTheme: 0,
seed: 0x104104,
pieces,
substitutions: [],
})
expect(steppesResult.stats.waypointsSpawned).toBe(0)
expect(steppesResult.stats.waypointTile).toBeNull()
})
test("Act 5 Arreat Plateau (level 112) spawns Waypoint id 12 near ruins or central hub", () => {
const pieces = makeGenericOutdoorPieces("Act 5 - Barricade", "Act 5 - Ruins 1")
const result = generateWilderness({
levelId: 112,
levelName: "Arreat Plateau",
levelTypeName: "Act 5 - Barricade",
sizeX: 80,
sizeY: 80,
subType: 14,
subTheme: 0,
seed: 0x112112,
pieces,
substitutions: [],
})
expect(result.stats.waypointsSpawned).toBe(1)
expect(result.stats.waypointTile).toBeDefined()
// Act 5 waypoint id is 12
const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 12)
expect(wpObj).toBeDefined()
// Act 5 shrines spawned
const act5ShrinePool = new Set(ACT_POPULATION_CONFIG[5].shrinePool)
const shrineObjs = result.level.objects.filter(o => o.type === 2 && act5ShrinePool.has(o.id))
expect(shrineObjs.length).toBeGreaterThanOrEqual(1)
})
test("Dispersed distribution: outdoor shrines maintain minimum distance spacing (>= 7 tiles)", () => {
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
const result = generateWilderness({
levelId: 3,
levelName: "Cold Plains",
levelTypeName: "Act 1 - Wilderness",
sizeX: 96,
sizeY: 96,
subType: 6,
subTheme: 0,
seed: 0x8899aabb,
pieces,
substitutions: [],
})
const shrineIds = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
const shrines = result.level.objects.filter(o => o.type === 2 && shrineIds.has(o.id))
// If multiple shrines are spawned, check pairwise distance >= 7
if (shrines.length >= 2) {
for (let i = 0; i < shrines.length; i += 1) {
for (let j = i + 1; j < shrines.length; j += 1) {
const s1 = shrines[i]!
const s2 = shrines[j]!
const dist = Math.hypot(
Math.floor(s1.x / 5) - Math.floor(s2.x / 5),
Math.floor(s1.y / 5) - Math.floor(s2.y / 5),
)
expect(dist).toBeGreaterThanOrEqual(7)
}
}
}
})
test("Thematic clustering: companion chests or scatter spawn within 2..4 tiles of shrines", () => {
const pieces = makeGenericOutdoorPieces("Act 1 - Wild")
// Use multiple seeds to observe companion spawning (60% probability)
let foundClusteredCompanion = false
const act1ChestsAndScatter = new Set([
...ACT_POPULATION_CONFIG[1].chestPool,
...ACT_POPULATION_CONFIG[1].scatterPool,
])
const act1Shrines = new Set(ACT_POPULATION_CONFIG[1].shrinePool)
for (let s = 0; s < 10; s += 1) {
const result = generateWilderness({
levelId: 4,
levelName: "Stony Field",
levelTypeName: "Act 1 - Wilderness",
sizeX: 80,
sizeY: 80,
subType: 6,
subTheme: 0,
seed: 0x5000 + s * 17,
pieces,
substitutions: [],
})
const shrines = result.level.objects.filter(o => o.type === 2 && act1Shrines.has(o.id))
const companions = result.level.objects.filter(o => o.type === 2 && act1ChestsAndScatter.has(o.id))
for (const shrine of shrines) {
const sx = Math.floor(shrine.x / 5)
const sy = Math.floor(shrine.y / 5)
for (const comp of companions) {
const cx = Math.floor(comp.x / 5)
const cy = Math.floor(comp.y / 5)
const dist = Math.hypot(cx - sx, cy - sy)
if (dist >= 1.9 && dist <= 4.5) {
foundClusteredCompanion = true
break
}
}
if (foundClusteredCompanion) break
}
if (foundClusteredCompanion) break
}
expect(foundClusteredCompanion).toBe(true)
})
test("shrineSubstitutions scales shrine count based on LvlSub max", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert")
const mockShrineSub: WildernessSubstitution = {
name: "Act 2 - Shrines",
type: 8,
gridSize: 1,
bordType: 0,
dt1Mask: 0,
prob: [100, 100, 100, 100, 100],
trials: [1, 1, 1, 1, 1],
max: [4, 4, 4, 4, 4],
levels: [],
}
const result = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 96,
sizeY: 96,
subType: 8,
subTheme: 0,
seed: 0x777777,
pieces,
substitutions: [],
shrineSubstitutions: [mockShrineSub],
})
// With max = 4 plus healing well, shrinesSpawned should scale up (>= 2)
expect(Number(result.stats.shrinesSpawned)).toBeGreaterThanOrEqual(2)
})
test("Determinism: same seed produces identical waypoint and shrine layouts", () => {
const pieces = makeGenericOutdoorPieces("Act 2 - Desert", "Act 2 - Oasis 1")
const seed = 0xabcdef12
const run1 = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
const run2 = generateWilderness({
levelId: 43,
levelName: "Far Oasis",
levelTypeName: "Act 2 - Desert",
sizeX: 80,
sizeY: 80,
subType: 8,
subTheme: 0,
seed,
pieces,
substitutions: [],
})
expect(run1.stats.waypointsSpawned).toBe(run2.stats.waypointsSpawned)
expect(run1.stats.waypointTile).toEqual(run2.stats.waypointTile)
expect(run1.stats.shrinesSpawned).toBe(run2.stats.shrinesSpawned)
expect(run1.stats.interactivesSpawned).toBe(run2.stats.interactivesSpawned)
expect(run1.level.objects).toEqual(run2.level.objects)
})
})