From 72aa83526098da50784de845ab4bbb067c99ea59 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 06:20:35 +0000 Subject: [PATCH] refactor(map-gen/preset): move Chaos Sanctuary (Level 108) into preset pipeline (fixes #45) --- scripts/pack-act-assets.ts | 28 +- scripts/verify-generators.ts | 2 +- src/game/preset.ts | 437 ++++++++++++++++++++++++++++++ src/game/wilderness.ts | 35 +-- tests/preset.test.ts | 237 ++++++++++++++++ tests/wilderness-features.test.ts | 37 --- 6 files changed, 701 insertions(+), 75 deletions(-) create mode 100644 src/game/preset.ts create mode 100644 tests/preset.test.ts diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index d150c1a..5b6626b 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -49,6 +49,8 @@ import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' import type { MazePiece, MazePieceKind } 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' @@ -1522,10 +1524,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) diff --git a/scripts/verify-generators.ts b/scripts/verify-generators.ts index 36d1ce0..d0abb70 100644 --- a/scripts/verify-generators.ts +++ b/scripts/verify-generators.ts @@ -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. diff --git a/src/game/preset.ts b/src/game/preset.ts new file mode 100644 index 0000000..0ad0dbe --- /dev/null +++ b/src/game/preset.ts @@ -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> | 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() + 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) +} diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 5ff9582..60fff8c 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -2519,34 +2519,6 @@ function stampAct5SiegeStrips( return placed } -/** - * Assemble the 7 cross-wing presets for Act 4 Chaos Sanctuary (LevelId 108, 120x120). - */ -function stampAct4ChaosSanctuary( - canvas: Canvas, - pieces: readonly WildernessPiece[], - stats: WildernessStats, -): Set { - const placements: Array<{ name: string; x: number; y: number }> = [ - { 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 }, - ] - const placed = new Set() - for (const item of placements) { - const piece = pieces.find(p => p.name.trim() === item.name.trim() || p.name.trim().startsWith(item.name.trim())) - const ds1 = piece?.levels[0] - if (!ds1) continue - stampDs1(canvas, ds1, item.x, item.y) - placed.add(item.name) - stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 }) - } - return placed -} /** * Assemble the 6 presets for Act 3 Travincal (LevelId 83, 64x64). @@ -3463,20 +3435,17 @@ export function generateWilderness(request: WildernessRequest): WildernessResult } stats.groundCells = fillGround(canvas, groundTile, 0, 0, canvas.width, canvas.height) - // Special case: Act 5 Bloody Foothills (110), Act 4 Chaos Sanctuary (108), + // Special case: Act 5 Bloody Foothills (110), // Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from // deterministic multi-preset layouts rather than rectangular perimeter borders. if ( request.levelId === 110 || request.levelTypeName === 'Act 5 - Siege' || - request.levelId === 108 || request.levelId === 83 || (request.levelId >= 76 && request.levelId <= 78) ) { let placedSpecial: Set - if (request.levelId === 108) { - placedSpecial = stampAct4ChaosSanctuary(canvas, request.pieces, stats) - } else if (request.levelId === 83) { + if (request.levelId === 83) { placedSpecial = stampAct3Travincal(canvas, request.pieces, stats) } else if (request.levelId >= 76 && request.levelId <= 78) { placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats) diff --git a/tests/preset.test.ts b/tests/preset.test.ts new file mode 100644 index 0000000..032c4db --- /dev/null +++ b/tests/preset.test.ts @@ -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) + }) +}) diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index 5981e73..739535e 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -251,43 +251,6 @@ describe("Wilderness Features & Border Rules", () => { 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 = [