Merge branch 'fix/issue-45'
This commit is contained in:
commit
80b2fd0f71
|
|
@ -49,6 +49,8 @@ import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
|
||||||
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
import type { MazePiece, MazePieceKind } from '../src/game/maze.ts'
|
||||||
import { generateWilderness } from '../src/game/wilderness.ts'
|
import { generateWilderness } from '../src/game/wilderness.ts'
|
||||||
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } 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 {
|
export interface MazeWarp {
|
||||||
readonly room: number
|
readonly room: number
|
||||||
readonly direction: 'up' | 'down'
|
readonly direction: 'up' | 'down'
|
||||||
|
|
@ -1522,10 +1524,28 @@ for (const entry of LEVELS) {
|
||||||
const libraries: Dt1[] = []
|
const libraries: Dt1[] = []
|
||||||
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
|
||||||
|
|
||||||
for (const ds1Name of info.ds1Names) {
|
if (entry.levelId === 108) {
|
||||||
const level = await loadDs1(ds1Name)
|
const pieces: PresetPiece[] = []
|
||||||
const label = `${entry.slug}-${slugOf(ds1Name)}`
|
for (const ds1Name of info.ds1Names) {
|
||||||
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
|
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') {
|
} else if (entry.kind === 'maze') {
|
||||||
const type = levelType(levelRow)
|
const type = levelType(levelRow)
|
||||||
|
|
|
||||||
|
|
@ -523,7 +523,7 @@ async function substitutions(archives: MountedArchives, lvlsub: D2Table, type: n
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/** The generators that must stay browser-safe. */
|
/** 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.
|
* Assert the generators import nothing Node-only.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -2519,34 +2519,6 @@ function stampAct5SiegeStrips(
|
||||||
return placed
|
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<string> {
|
|
||||||
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<string>()
|
|
||||||
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).
|
* 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)
|
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
|
// Act 3 Travincal (83), and Act 3 Jungles (76, 77, 78) are assembled from
|
||||||
// deterministic multi-preset layouts rather than rectangular perimeter borders.
|
// deterministic multi-preset layouts rather than rectangular perimeter borders.
|
||||||
if (
|
if (
|
||||||
request.levelId === 110 ||
|
request.levelId === 110 ||
|
||||||
request.levelTypeName === 'Act 5 - Siege' ||
|
request.levelTypeName === 'Act 5 - Siege' ||
|
||||||
request.levelId === 108 ||
|
|
||||||
request.levelId === 83 ||
|
request.levelId === 83 ||
|
||||||
(request.levelId >= 76 && request.levelId <= 78)
|
(request.levelId >= 76 && request.levelId <= 78)
|
||||||
) {
|
) {
|
||||||
let placedSpecial: Set<string>
|
let placedSpecial: Set<string>
|
||||||
if (request.levelId === 108) {
|
if (request.levelId === 83) {
|
||||||
placedSpecial = stampAct4ChaosSanctuary(canvas, request.pieces, stats)
|
|
||||||
} else if (request.levelId === 83) {
|
|
||||||
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
|
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
|
||||||
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
||||||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
|
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -251,43 +251,6 @@ describe("Wilderness Features & Border Rules", () => {
|
||||||
expect(result.level.cells[0][224].floors[0].sequence).toBe(24)
|
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", () => {
|
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
|
||||||
const names = [
|
const names = [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue