764 lines
27 KiB
TypeScript
764 lines
27 KiB
TypeScript
/**
|
|
* 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
|
|
|
|
/**
|
|
* Complete canonical set of 35 preset level IDs defined in `Levels.txt` with `DrlgType == 2`:
|
|
*
|
|
* - Act 1 (13): 1 (Rogue Encampment), 13 (Cave 2), 14 (Underground Passage 2),
|
|
* 15 (Hole 2), 16 (Pit 2), 20 (Forgotten Tower), 25 (Tower Cellar 5),
|
|
* 26 (Monastery Gate), 27 (Outer Cloister), 32 (Inner Cloister),
|
|
* 33 (Cathedral), 37 (Catacombs 4), 38 (Tristram).
|
|
* - Act 2 (3): 40 (Lut Gholein), 50 (Harem 1), 73 (Duriel's Lair).
|
|
* - Act 3 (11): 75 (Kurast Docktown), 90 (Swampy Pit 3), 91 (Flayer Dungeon 3),
|
|
* 93 (Sewers 2), 94..99 (Ruined Temple, Disused Fane, Forgotten Reliquary,
|
|
* Forgotten Temple, Ruined Fane, Disused Reliquary), 102 (Durance of Hate 3).
|
|
* - Act 4 (1): 103 (The Pandemonium Fortress).
|
|
* (Note: Level 108 Chaos Sanctuary is assembled via cross-wing presets, while having DrlgType == 3 in Levels.txt).
|
|
* - Act 5 (7): 109 (Harrogath), 120 (Rocky Summit / Arreat Summit),
|
|
* 121 (Nihlathaks Temple), 124 (Halls of Vaught), 131 (Throne of Destruction),
|
|
* 132 (The Worldstone Chamber), 136 (Tristram / Pandemonium Finale).
|
|
*/
|
|
export const PRESET_LEVEL_IDS: readonly number[] = [
|
|
// Act 1 (13)
|
|
1, 13, 14, 15, 16, 20, 25, 26, 27, 32, 33, 37, 38,
|
|
// Act 2 (3)
|
|
40, 50, 73,
|
|
// Act 3 (11)
|
|
75, 90, 91, 93, 94, 95, 96, 97, 98, 99, 102,
|
|
// Act 4 (1)
|
|
103,
|
|
// Act 5 (7)
|
|
109, 120, 121, 124, 131, 132, 136,
|
|
] as const
|
|
|
|
/** Set of canonical DrlgType == 2 preset level IDs for O(1) membership check. */
|
|
export const PRESET_LEVEL_ID_SET: ReadonlySet<number> = new Set(PRESET_LEVEL_IDS)
|
|
|
|
/** All preset level IDs handled by the preset pipeline, including Level 108 Chaos Sanctuary. */
|
|
export const ALL_PRESET_LEVEL_IDS: readonly number[] = [
|
|
...PRESET_LEVEL_IDS.filter(id => id < 108),
|
|
CHAOS_SANCTUARY_LEVEL_ID,
|
|
...PRESET_LEVEL_IDS.filter(id => id > 108),
|
|
] as const
|
|
|
|
/**
|
|
* Checks whether a level is handled by the preset level generator.
|
|
* Returns true for all 35 canonical DrlgType == 2 levels and Level 108 Chaos Sanctuary.
|
|
*
|
|
* @param levelId - `Levels.txt` level Id.
|
|
*/
|
|
export function isPresetLevel(levelId: number): boolean {
|
|
return levelId === CHAOS_SANCTUARY_LEVEL_ID || PRESET_LEVEL_ID_SET.has(levelId)
|
|
}
|
|
|
|
/** 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
|
|
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
|
|
readonly animSpeed?: number | 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
|
|
}
|
|
|
|
/**
|
|
* A discrete 8x8 cell room or sector in a preset level.
|
|
* Corresponds to 1.13c D2Common `DRLGPRESET_BuildArea` room allocations.
|
|
*/
|
|
export interface PresetRoom {
|
|
/** Identifier for this room (e.g. "preset_room_0_0"). */
|
|
readonly id: string | number
|
|
/** Top-left cell X on preset canvas. */
|
|
readonly x: number
|
|
/** Top-left cell Y on preset canvas. */
|
|
readonly y: number
|
|
/** Width in cells (normally 8, clipped at canvas boundary). */
|
|
readonly width: number
|
|
/** Height in cells (normally 8, clipped at canvas boundary). */
|
|
readonly height: number
|
|
/** Grid column index (x / 8). */
|
|
readonly gridX: number
|
|
/** Grid row index (y / 8). */
|
|
readonly gridY: number
|
|
/** Grid cell coordinate X (alias for x for MapRoom compatibility). */
|
|
readonly cellX: number
|
|
/** Grid cell coordinate Y (alias for y for MapRoom compatibility). */
|
|
readonly cellY: number
|
|
/** Width in cells (alias for width for MapRoom compatibility). */
|
|
readonly cellsX: number
|
|
/** Height in cells (alias for height for MapRoom compatibility). */
|
|
readonly cellsY: number
|
|
/** Objects placed within this room. */
|
|
readonly objects: readonly Ds1Object[]
|
|
}
|
|
|
|
/** Configuration options for {@link slicePresetRooms}. */
|
|
export interface SlicePresetRoomsOptions {
|
|
/**
|
|
* If true, treats object coordinates directly as cell coordinates rather than
|
|
* sub-tiles (5 sub-tiles per cell). Default is false (standard DS1 sub-tiles).
|
|
*/
|
|
readonly cellCoords?: boolean | undefined
|
|
}
|
|
|
|
/**
|
|
* Slices any preset canvas into discrete 8x8 cell PresetRooms, assigning local
|
|
* bounds and partitioning objects by their room.
|
|
*
|
|
* Matches 1.13c DrlgPreset.cpp DRLGPRESET_BuildArea:
|
|
* ```cpp
|
|
* const int nXEnd = pDrlgMap->pDrlgCoord.nPosX + pDrlgMap->pDrlgCoord.nWidth;
|
|
* const int nYEnd = pDrlgMap->pDrlgCoord.nPosY + pDrlgMap->pDrlgCoord.nHeight;
|
|
* for (int nY = pDrlgMap->pDrlgCoord.nPosY; nY < nYEnd; nY += 8) {
|
|
* for (int nX = pDrlgMap->pDrlgCoord.nPosX; nX < nXEnd; nX += 8) {
|
|
* // 8x8 tile room allocation (clipped at border)
|
|
* }
|
|
* }
|
|
* ```
|
|
*
|
|
* @param width - Canvas width in cells.
|
|
* @param height - Canvas height in cells.
|
|
* @param objects - Objects placed on canvas (coordinates in sub-tiles by default).
|
|
* @param options - Optional configuration flags.
|
|
* @returns Array of 8x8 PresetRoom descriptors covering the canvas.
|
|
*/
|
|
export function slicePresetRooms(
|
|
width: number,
|
|
height: number,
|
|
objects: readonly Ds1Object[] = [],
|
|
options?: SlicePresetRoomsOptions,
|
|
): readonly PresetRoom[] {
|
|
if (width <= 0 || height <= 0) return []
|
|
|
|
const rooms: PresetRoom[] = []
|
|
let gridY = 0
|
|
for (let y = 0; y < height; y += 8) {
|
|
const roomHeight = Math.min(8, height - y)
|
|
let gridX = 0
|
|
for (let x = 0; x < width; x += 8) {
|
|
const roomWidth = Math.min(8, width - x)
|
|
|
|
const roomObjects: Ds1Object[] = []
|
|
for (const obj of objects) {
|
|
const ox = options?.cellCoords ? Math.floor(obj.x) : Math.floor(obj.x / SUB_TILES_PER_TILE)
|
|
const oy = options?.cellCoords ? Math.floor(obj.y) : Math.floor(obj.y / SUB_TILES_PER_TILE)
|
|
if (ox >= x && ox < x + roomWidth && oy >= y && oy < y + roomHeight) {
|
|
roomObjects.push(obj)
|
|
}
|
|
}
|
|
|
|
rooms.push({
|
|
id: `preset_room_${gridX}_${gridY}`,
|
|
x,
|
|
y,
|
|
width: roomWidth,
|
|
height: roomHeight,
|
|
gridX,
|
|
gridY,
|
|
cellX: x,
|
|
cellY: y,
|
|
cellsX: roomWidth,
|
|
cellsY: roomHeight,
|
|
objects: roomObjects,
|
|
})
|
|
|
|
gridX += 1
|
|
}
|
|
gridY += 1
|
|
}
|
|
|
|
return rooms
|
|
}
|
|
|
|
/**
|
|
* Seed structure matching 1.13c D2SeedStrc.
|
|
*/
|
|
export interface D2Seed {
|
|
low: number
|
|
high: number
|
|
}
|
|
|
|
/**
|
|
* Initializes a 1.13c D2SeedStrc state matching SEED_InitLowSeed.
|
|
*
|
|
* @param seed - Initial seed value (low 32-bits).
|
|
* @param high - Initial high seed value (defaults to 666 matching 1.13c SEED_InitLowSeed).
|
|
*/
|
|
export function createD2Seed(seed: number, high = 666): D2Seed {
|
|
return {
|
|
low: seed >>> 0,
|
|
high: high >>> 0,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advances seed and returns 32-bit pseudo-random roll matching 1.13c SEED_RollRandomNumber:
|
|
* `uint64_t lSeed = pSeed->nHighSeed + 0x6AC690C5i64 * pSeed->nLowSeed;`
|
|
*/
|
|
export function rollD2Random(seedState: D2Seed): number {
|
|
const lSeed = BigInt(seedState.high) + 0x6ac690c5n * BigInt(seedState.low)
|
|
seedState.low = Number(lSeed & 0xffffffffn)
|
|
seedState.high = Number((lSeed >> 32n) & 0xffffffffn)
|
|
return seedState.low
|
|
}
|
|
|
|
/**
|
|
* Rolls a limited random number in `[0, max - 1]` matching 1.13c SEED_RollLimitedRandomNumber:
|
|
* ```cpp
|
|
* if (nMax > 0) {
|
|
* if ((nMax - 1) & nMax) return (unsigned int)SEED_RollRandomNumber(pSeed) % nMax;
|
|
* else return SEED_RollRandomNumber(pSeed) & (nMax - 1);
|
|
* }
|
|
* return 0;
|
|
* ```
|
|
*/
|
|
export function rollD2LimitedRandom(seedState: D2Seed | number, max: number): number {
|
|
if (max <= 0) return 0
|
|
const state = typeof seedState === 'number' ? createD2Seed(seedState) : seedState
|
|
const roll = rollD2Random(state)
|
|
if (((max - 1) & max) === 0) {
|
|
return roll & (max - 1)
|
|
}
|
|
return roll % max
|
|
}
|
|
|
|
/**
|
|
* Resolves 0-based picked file index (nPickedFile) for a preset piece from
|
|
* `LvlPrest.txt` `File1..File6` variants and level seed, matching 1.13c DRLGPRESET_AllocDrlgMap.
|
|
*
|
|
* In 1.13c DrlgPreset.cpp:
|
|
* `pDrlgMap->nPickedFile = SEED_RollLimitedRandomNumber(pSeed, pDrlgMap->pLvlPrestTxtRecord->dwFiles);`
|
|
*
|
|
* @param filesCount - Number of available file variants (`LvlPrest.txt.Files` or variants length).
|
|
* @param seed - Level seed or D2Seed state.
|
|
* @returns 0-based index of picked file variant.
|
|
*/
|
|
export function resolvePickedFileIndex(filesCount: number, seed: number | D2Seed): number {
|
|
return rollD2LimitedRandom(seed, filesCount)
|
|
}
|
|
|
|
/**
|
|
* Resolves the picked file variant from an array of file candidates or DS1s.
|
|
*/
|
|
export function resolvePickedFile<T>(files: readonly T[], seed: number | D2Seed): T | undefined {
|
|
if (files.length === 0) return undefined
|
|
const index = resolvePickedFileIndex(files.length, seed)
|
|
return files[index]
|
|
}
|
|
|
|
/** 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 nPickedFile?: number | undefined
|
|
readonly rooms?: number | undefined
|
|
readonly animSpeed?: number | 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
|
|
/** Discrete 8x8 rooms sliced from the preset canvas. */
|
|
readonly rooms?: readonly PresetRoom[] | undefined
|
|
/** Animated tile playback speed from `LvlPrest.txt` `Animate` column. */
|
|
readonly animSpeed?: number | undefined
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const animSpeed = request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
|
|
|
|
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 lavaPiece = findMatchingPresetPiece(request.pieces, 'LavaX')
|
|
const placements: readonly PresetPlacement[] = request.placements ?? (lavaPiece ? [
|
|
// 19 surrounding LavaX 24x24 macro-rooms per DrlgOutdoors.cpp:1361 nLavaLvlPrestIds[25]
|
|
{ name: 'LavaX', x: 0, y: 0 }, { name: 'LavaX', x: 24, y: 0 }, { name: 'LavaX', x: 48, y: 0 }, { name: 'LavaX', x: 72, y: 0 }, { name: 'LavaX', x: 96, y: 0 },
|
|
{ name: 'LavaX', x: 0, y: 24 }, { name: 'LavaX', x: 24, y: 24 }, { name: 'LavaX', x: 72, y: 24 }, { name: 'LavaX', x: 96, y: 24 },
|
|
{ name: 'LavaX', x: 0, y: 48 }, { name: 'LavaX', x: 96, y: 48 },
|
|
{ name: 'LavaX', x: 0, y: 72 }, { name: 'LavaX', x: 24, y: 72 }, { name: 'LavaX', x: 72, y: 72 }, { name: 'LavaX', x: 96, y: 72 },
|
|
{ name: 'LavaX', x: 0, y: 96 }, { name: 'LavaX', x: 24, y: 96 }, { name: 'LavaX', x: 72, y: 96 }, { name: 'LavaX', x: 96, y: 96 },
|
|
// 6 Core Chaos Sanctuary DS1s at exact DrlgOutdoors.cpp:1361 coordinates
|
|
{ name: 'Act 4 - Diablo Arm N', x: 48, y: 24 },
|
|
{ name: 'Act 4 - Diablo Arm W', x: 24, y: 48 },
|
|
{ name: 'Act 4 - Diablo Heart', x: 48, y: 48 },
|
|
{ name: 'Act 4 - Diablo Arm E', x: 72, y: 48 },
|
|
{ name: 'Act 4 - Diablo Arm S', x: 48, y: 72 },
|
|
{ name: 'Act 4 - Diablo Entry', x: 48, y: 96 },
|
|
] : CHAOS_SANCTUARY_PLACEMENTS)
|
|
const placed = new Set<string>()
|
|
const substitutions: PresetSubstitutionRecord[] = []
|
|
|
|
const seedVal = request.seed ?? 0
|
|
for (let idx = 0; idx < placements.length; idx += 1) {
|
|
const item = placements[idx]!
|
|
// If seed selects variant 2 (e.g. WingN2 / WingW2 / WingE2 / WingS2), check for matching variant piece
|
|
let piece = findMatchingPresetPiece(request.pieces, item.name)
|
|
if (seedVal > 0 && item.name.includes('Arm')) {
|
|
const suffix = ((seedVal + idx) % 2) + 1
|
|
const armDir = item.name.slice(-1)
|
|
const varPiece = findMatchingPresetPiece(request.pieces, `Wing${armDir}${suffix}`)
|
|
if (varPiece) piece = varPiece
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
const rooms = slicePresetRooms(canvas.width, canvas.height, canvas.objects)
|
|
|
|
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,
|
|
rooms: rooms.length,
|
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
|
},
|
|
rooms,
|
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (resolving multi-piece or multi-level variants if present)
|
|
if (request.pieces.length > 0) {
|
|
let pickedPieceIndex = 0
|
|
let pickedPiece = request.pieces[0]
|
|
let nPickedFile: number | undefined = undefined
|
|
|
|
if (request.pieces.length > 1) {
|
|
pickedPieceIndex = resolvePickedFileIndex(request.pieces.length, request.seed ?? 0)
|
|
pickedPiece = request.pieces[pickedPieceIndex]
|
|
nPickedFile = pickedPieceIndex
|
|
}
|
|
|
|
if (pickedPiece && pickedPiece.levels.length > 0) {
|
|
let ds1 = pickedPiece.levels[0]
|
|
if (pickedPiece.levels.length > 1) {
|
|
const variantIndex = resolvePickedFileIndex(pickedPiece.levels.length, request.seed ?? 0)
|
|
ds1 = pickedPiece.levels[variantIndex] ?? ds1
|
|
nPickedFile = variantIndex
|
|
}
|
|
|
|
if (ds1) {
|
|
const width = request.sizeX && request.sizeX > 0 ? request.sizeX : ds1.width
|
|
const height = request.sizeY && request.sizeY > 0 ? request.sizeY : ds1.height
|
|
|
|
let resultLevel: Ds1 = ds1
|
|
if (width !== ds1.width || height !== ds1.height) {
|
|
const canvas = makeCanvas(
|
|
width,
|
|
height,
|
|
ds1.wallLayers,
|
|
ds1.floorLayers,
|
|
ds1.cells[0]?.[0]?.substitutions?.length ?? 1,
|
|
)
|
|
stampDs1(canvas, ds1, 0, 0)
|
|
resultLevel = {
|
|
version: ds1.version === 0 ? 18 : ds1.version,
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
act: request.act ?? ds1.act ?? 1,
|
|
substitutionType: ds1.substitutionType,
|
|
wallLayers: canvas.wallLayers,
|
|
floorLayers: canvas.floorLayers,
|
|
cells: canvas.cells,
|
|
objects: canvas.objects,
|
|
npcPathOffset: ds1.npcPathOffset,
|
|
}
|
|
}
|
|
|
|
let groundCells = 0
|
|
for (let y = 0; y < resultLevel.height; y += 1) {
|
|
const row = resultLevel.cells[y]
|
|
if (row === undefined) continue
|
|
for (let x = 0; x < resultLevel.width; x += 1) {
|
|
const c = row[x]
|
|
if (c !== undefined && c.floors.some(f => !f.hidden && f.prop1 !== 0)) {
|
|
groundCells += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
const rooms = slicePresetRooms(resultLevel.width, resultLevel.height, resultLevel.objects)
|
|
const animSpeed = pickedPiece.animSpeed ?? request.pieces.find(p => p.animSpeed && p.animSpeed > 0)?.animSpeed
|
|
|
|
return {
|
|
level: resultLevel,
|
|
stats: {
|
|
levelId: request.levelId,
|
|
levelName: request.levelName ?? pickedPiece.name,
|
|
sizeX: width,
|
|
sizeY: height,
|
|
seed: request.seed ?? 0,
|
|
borderBlocks: 0,
|
|
borderUsage: {},
|
|
substitutions: [
|
|
{ name: pickedPiece.name, role: 'preset', enabled: true, clusters: 1 },
|
|
],
|
|
substitutedClusters: 1,
|
|
objects: resultLevel.objects.length,
|
|
cells: width * height,
|
|
groundCells,
|
|
nPickedFile: nPickedFile ?? 0,
|
|
rooms: rooms.length,
|
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
|
},
|
|
rooms,
|
|
...(animSpeed !== undefined ? { animSpeed } : {}),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to general assembly
|
|
return generateChaosSanctuary(request)
|
|
}
|