feat(preset): 统一 35 个预设关卡流水线与 8×8 Room 几何装配切片 (Issue #74)
TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
ea61823e60
commit
97ab449231
|
|
@ -23,6 +23,56 @@ 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). */
|
||||
|
|
@ -75,6 +125,191 @@ export interface PresetRequest {
|
|||
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
|
||||
|
|
@ -91,6 +326,8 @@ export interface PresetStats {
|
|||
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 [key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +337,8 @@ export interface PresetResult {
|
|||
readonly level: Ds1
|
||||
/** Execution statistics and placement records. */
|
||||
readonly stats: PresetStats
|
||||
/** Discrete 8x8 rooms sliced from the preset canvas. */
|
||||
readonly rooms?: readonly PresetRoom[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -341,6 +580,8 @@ export function generateChaosSanctuary(request: PresetRequest): PresetResult {
|
|||
}
|
||||
}
|
||||
|
||||
const rooms = slicePresetRooms(canvas.width, canvas.height, canvas.objects)
|
||||
|
||||
return {
|
||||
level: {
|
||||
version: version === 0 ? 18 : version,
|
||||
|
|
@ -367,7 +608,9 @@ export function generateChaosSanctuary(request: PresetRequest): PresetResult {
|
|||
objects: canvas.objects.length,
|
||||
cells: canvas.width * canvas.height,
|
||||
groundCells,
|
||||
rooms: rooms.length,
|
||||
},
|
||||
rooms,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,42 +635,90 @@ export function generatePreset(request: PresetRequest): PresetResult {
|
|||
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
|
||||
// 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 (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
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
},
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
},
|
||||
rooms,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ import {
|
|||
CHAOS_SANCTUARY_SIZE_X,
|
||||
CHAOS_SANCTUARY_SIZE_Y,
|
||||
CHAOS_SANCTUARY_PLACEMENTS,
|
||||
PRESET_LEVEL_IDS,
|
||||
PRESET_LEVEL_ID_SET,
|
||||
ALL_PRESET_LEVEL_IDS,
|
||||
isPresetLevel,
|
||||
slicePresetRooms,
|
||||
createD2Seed,
|
||||
rollD2Random,
|
||||
rollD2LimitedRandom,
|
||||
resolvePickedFileIndex,
|
||||
resolvePickedFile,
|
||||
} from "../src/game/preset.ts"
|
||||
import type { PresetPiece } from "../src/game/preset.ts"
|
||||
import { generateWilderness } from "../src/game/wilderness.ts"
|
||||
|
|
@ -235,3 +245,359 @@ describe("Preset Map Pipeline (DrlgType 2)", () => {
|
|||
expect(result.stats.substitutedClusters).not.toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Canonical Preset Levels (Levels.txt DrlgType == 2)", () => {
|
||||
test("PRESET_LEVEL_IDS contains all 35 canonical DrlgType == 2 levels across Acts 1-5", () => {
|
||||
expect(PRESET_LEVEL_IDS.length).toBe(35)
|
||||
// Act 1 (13)
|
||||
for (const id of [1, 13, 14, 15, 16, 20, 25, 26, 27, 32, 33, 37, 38]) {
|
||||
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
|
||||
}
|
||||
// Act 2 (3)
|
||||
for (const id of [40, 50, 73]) {
|
||||
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
|
||||
}
|
||||
// Act 3 (11)
|
||||
for (const id of [75, 90, 91, 93, 94, 95, 96, 97, 98, 99, 102]) {
|
||||
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
|
||||
}
|
||||
// Act 4 (1)
|
||||
expect(PRESET_LEVEL_ID_SET.has(103)).toBe(true)
|
||||
// Act 5 (7)
|
||||
for (const id of [109, 120, 121, 124, 131, 132, 136]) {
|
||||
expect(PRESET_LEVEL_ID_SET.has(id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("ALL_PRESET_LEVEL_IDS includes the 35 canonical levels plus Level 108 Chaos Sanctuary", () => {
|
||||
expect(ALL_PRESET_LEVEL_IDS.length).toBe(36)
|
||||
expect(ALL_PRESET_LEVEL_IDS).toContain(108)
|
||||
for (const id of PRESET_LEVEL_IDS) {
|
||||
expect(ALL_PRESET_LEVEL_IDS).toContain(id)
|
||||
}
|
||||
})
|
||||
|
||||
test("isPresetLevel correctly classifies preset vs non-preset level IDs", () => {
|
||||
// 35 canonical preset levels
|
||||
for (const id of PRESET_LEVEL_IDS) {
|
||||
expect(isPresetLevel(id)).toBe(true)
|
||||
}
|
||||
// Level 108 Chaos Sanctuary (stitched preset level)
|
||||
expect(isPresetLevel(108)).toBe(true)
|
||||
|
||||
// Non-preset levels
|
||||
expect(isPresetLevel(2)).toBe(false) // Blood Moor (outdoor)
|
||||
expect(isPresetLevel(3)).toBe(false) // Cold Plains (outdoor)
|
||||
expect(isPresetLevel(8)).toBe(false) // Catacombs Level 1 (maze)
|
||||
expect(isPresetLevel(39)).toBe(false) // Moo Moo Farm (outdoor)
|
||||
expect(isPresetLevel(0)).toBe(false)
|
||||
expect(isPresetLevel(-1)).toBe(false)
|
||||
expect(isPresetLevel(999)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("8x8 Room Geometry Assembly and Slicing (DRLGPRESET_BuildArea)", () => {
|
||||
test("slices 16x16 canvas into 4 exact 8x8 rooms", () => {
|
||||
const rooms = slicePresetRooms(16, 16)
|
||||
expect(rooms.length).toBe(4)
|
||||
|
||||
expect(rooms[0]).toMatchObject({
|
||||
id: "preset_room_0_0",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
gridX: 0,
|
||||
gridY: 0,
|
||||
cellX: 0,
|
||||
cellY: 0,
|
||||
cellsX: 8,
|
||||
cellsY: 8,
|
||||
objects: [],
|
||||
})
|
||||
expect(rooms[1]).toMatchObject({
|
||||
id: "preset_room_1_0",
|
||||
x: 8,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
gridX: 1,
|
||||
gridY: 0,
|
||||
})
|
||||
expect(rooms[2]).toMatchObject({
|
||||
id: "preset_room_0_1",
|
||||
x: 0,
|
||||
y: 8,
|
||||
width: 8,
|
||||
height: 8,
|
||||
gridX: 0,
|
||||
gridY: 1,
|
||||
})
|
||||
expect(rooms[3]).toMatchObject({
|
||||
id: "preset_room_1_1",
|
||||
x: 8,
|
||||
y: 8,
|
||||
width: 8,
|
||||
height: 8,
|
||||
gridX: 1,
|
||||
gridY: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test("clamps non-multiple-of-8 canvas boundaries (20x20 canvas -> 9 rooms)", () => {
|
||||
const rooms = slicePresetRooms(20, 20)
|
||||
// 20 = 8 + 8 + 4 -> 3 x 3 = 9 rooms
|
||||
expect(rooms.length).toBe(9)
|
||||
|
||||
// Check bottom-right clamped room
|
||||
const cornerRoom = rooms.find(r => r.gridX === 2 && r.gridY === 2)
|
||||
expect(cornerRoom).toBeDefined()
|
||||
expect(cornerRoom?.x).toBe(16)
|
||||
expect(cornerRoom?.y).toBe(16)
|
||||
expect(cornerRoom?.width).toBe(4)
|
||||
expect(cornerRoom?.height).toBe(4)
|
||||
expect(cornerRoom?.cellsX).toBe(4)
|
||||
expect(cornerRoom?.cellsY).toBe(4)
|
||||
|
||||
// Check edge rooms with 8x4 or 4x8
|
||||
const topEdgeClamped = rooms.find(r => r.gridX === 2 && r.gridY === 0)
|
||||
expect(topEdgeClamped?.width).toBe(4)
|
||||
expect(topEdgeClamped?.height).toBe(8)
|
||||
|
||||
const leftEdgeClamped = rooms.find(r => r.gridX === 0 && r.gridY === 2)
|
||||
expect(leftEdgeClamped?.width).toBe(8)
|
||||
expect(leftEdgeClamped?.height).toBe(4)
|
||||
})
|
||||
|
||||
test("handles asymmetric canvas (25x13 -> 8 rooms)", () => {
|
||||
// Width 25: 8 + 8 + 8 + 1 -> 4 columns
|
||||
// Height 13: 8 + 5 -> 2 rows
|
||||
// Total: 4 * 2 = 8 rooms
|
||||
const rooms = slicePresetRooms(25, 13)
|
||||
expect(rooms.length).toBe(8)
|
||||
|
||||
const lastColTopRow = rooms.find(r => r.gridX === 3 && r.gridY === 0)
|
||||
expect(lastColTopRow?.width).toBe(1)
|
||||
expect(lastColTopRow?.height).toBe(8)
|
||||
|
||||
const lastColBottomRow = rooms.find(r => r.gridX === 3 && r.gridY === 1)
|
||||
expect(lastColBottomRow?.width).toBe(1)
|
||||
expect(lastColBottomRow?.height).toBe(5)
|
||||
})
|
||||
|
||||
test("returns empty array for zero or negative dimensions", () => {
|
||||
expect(slicePresetRooms(0, 0)).toEqual([])
|
||||
expect(slicePresetRooms(-10, 20)).toEqual([])
|
||||
expect(slicePresetRooms(20, -5)).toEqual([])
|
||||
})
|
||||
|
||||
test("partitions objects by room boundaries using sub-tile coordinates (5 sub-tiles per cell)", () => {
|
||||
const objects = [
|
||||
// In room (0, 0): cell (2, 3) -> sub-tiles (10, 15)
|
||||
{ type: 2, id: 1, x: 10, y: 15, flags: 0 },
|
||||
// In room (1, 0): cell (9, 2) -> sub-tiles (45, 10)
|
||||
{ type: 2, id: 2, x: 45, y: 10, flags: 0 },
|
||||
// In room (0, 1): cell (1, 10) -> sub-tiles (5, 50)
|
||||
{ type: 2, id: 3, x: 5, y: 50, flags: 0 },
|
||||
// In room (1, 1): cell (15, 15) -> sub-tiles (75, 75)
|
||||
{ type: 2, id: 4, x: 75, y: 75, flags: 0 },
|
||||
// Outside canvas: cell (25, 25) -> sub-tiles (125, 125)
|
||||
{ type: 2, id: 5, x: 125, y: 125, flags: 0 },
|
||||
]
|
||||
|
||||
const rooms = slicePresetRooms(16, 16, objects)
|
||||
expect(rooms.length).toBe(4)
|
||||
|
||||
const room00 = rooms.find(r => r.gridX === 0 && r.gridY === 0)
|
||||
expect(room00?.objects.map(o => o.id)).toEqual([1])
|
||||
|
||||
const room10 = rooms.find(r => r.gridX === 1 && r.gridY === 0)
|
||||
expect(room10?.objects.map(o => o.id)).toEqual([2])
|
||||
|
||||
const room01 = rooms.find(r => r.gridX === 0 && r.gridY === 1)
|
||||
expect(room01?.objects.map(o => o.id)).toEqual([3])
|
||||
|
||||
const room11 = rooms.find(r => r.gridX === 1 && r.gridY === 1)
|
||||
expect(room11?.objects.map(o => o.id)).toEqual([4])
|
||||
})
|
||||
|
||||
test("supports direct cell coordinate objects when cellCoords option is enabled", () => {
|
||||
const objects = [
|
||||
{ type: 2, id: 10, x: 4, y: 4, flags: 0 },
|
||||
{ type: 2, id: 20, x: 12, y: 4, flags: 0 },
|
||||
]
|
||||
|
||||
const rooms = slicePresetRooms(16, 16, objects, { cellCoords: true })
|
||||
const r00 = rooms.find(r => r.gridX === 0 && r.gridY === 0)
|
||||
const r10 = rooms.find(r => r.gridX === 1 && r.gridY === 0)
|
||||
|
||||
expect(r00?.objects.map(o => o.id)).toEqual([10])
|
||||
expect(r10?.objects.map(o => o.id)).toEqual([20])
|
||||
})
|
||||
})
|
||||
|
||||
describe("1.13c D2Seed PRNG and Multi-File Variant Selection (DRLGPRESET_AllocDrlgMap)", () => {
|
||||
test("createD2Seed initializes low and default high = 666 matching 1.13c SEED_InitLowSeed", () => {
|
||||
const seed = createD2Seed(12345)
|
||||
expect(seed.low).toBe(12345)
|
||||
expect(seed.high).toBe(666)
|
||||
})
|
||||
|
||||
test("rollD2Random advances seed deterministically with 0x6AC690C5 multiplier", () => {
|
||||
const seed = createD2Seed(100)
|
||||
const roll1 = rollD2Random(seed)
|
||||
// 666 + 0x6AC690C5 * 100 = 179139809166
|
||||
// low 32 bits = 179139809166 % 2^32 = 3046150030
|
||||
// high 32 bits = 179139809166 >> 32 = 41
|
||||
expect(roll1).toBe(3046150030)
|
||||
expect(seed.low).toBe(3046150030)
|
||||
expect(seed.high).toBe(41)
|
||||
|
||||
// Second roll
|
||||
const roll2 = rollD2Random(seed)
|
||||
expect(typeof roll2).toBe("number")
|
||||
expect(roll2).not.toBe(roll1)
|
||||
})
|
||||
|
||||
test("rollD2LimitedRandom implements power-of-2 bitmask and non-power-of-2 modulo branches", () => {
|
||||
// Power of 2 (max = 4): uses roll & 3
|
||||
const p2Roll = rollD2LimitedRandom(100, 4)
|
||||
expect(p2Roll).toBeGreaterThanOrEqual(0)
|
||||
expect(p2Roll).toBeLessThan(4)
|
||||
|
||||
// Non power of 2 (max = 3): uses roll % 3
|
||||
const np2Roll = rollD2LimitedRandom(100, 3)
|
||||
expect(np2Roll).toBeGreaterThanOrEqual(0)
|
||||
expect(np2Roll).toBeLessThan(3)
|
||||
|
||||
// max <= 0 returns 0
|
||||
expect(rollD2LimitedRandom(100, 0)).toBe(0)
|
||||
expect(rollD2LimitedRandom(100, -5)).toBe(0)
|
||||
// max = 1 returns 0
|
||||
expect(rollD2LimitedRandom(100, 1)).toBe(0)
|
||||
})
|
||||
|
||||
test("resolvePickedFileIndex picks deterministic variant indices", () => {
|
||||
const idxA1 = resolvePickedFileIndex(4, 42)
|
||||
const idxA2 = resolvePickedFileIndex(4, 42)
|
||||
expect(idxA1).toBe(idxA2)
|
||||
|
||||
const idxB = resolvePickedFileIndex(4, 99999)
|
||||
expect(idxA1).toBeGreaterThanOrEqual(0)
|
||||
expect(idxA1).toBeLessThan(4)
|
||||
expect(idxB).toBeGreaterThanOrEqual(0)
|
||||
expect(idxB).toBeLessThan(4)
|
||||
})
|
||||
|
||||
test("resolvePickedFile returns matching element or undefined for empty array", () => {
|
||||
const variants = ["fileA.ds1", "fileB.ds1", "fileC.ds1"]
|
||||
const picked = resolvePickedFile(variants, 12345)
|
||||
expect(variants).toContain(picked)
|
||||
expect(resolvePickedFile([], 12345)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Preset Generation Room Integration and Variant Resolution", () => {
|
||||
test("generateChaosSanctuary attaches 225 sliced 8x8 rooms and partitions wing objects", () => {
|
||||
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",
|
||||
"Bridge 2",
|
||||
]
|
||||
const pieces: PresetPiece[] = names.map((name, idx) => {
|
||||
const baseDs1 = makeMockDs1(24, 24, 10 + idx)
|
||||
const ds1: Ds1 = {
|
||||
...baseDs1,
|
||||
objects: [{ type: 2, id: 300 + idx, x: 5, y: 5, flags: 0 }],
|
||||
}
|
||||
return { name, levels: [ds1] }
|
||||
})
|
||||
|
||||
const result = generateChaosSanctuary({
|
||||
levelId: CHAOS_SANCTUARY_LEVEL_ID,
|
||||
pieces,
|
||||
})
|
||||
|
||||
// 120 / 8 = 15 -> 15 x 15 = 225 rooms
|
||||
expect(result.rooms?.length).toBe(225)
|
||||
expect(result.stats.rooms).toBe(225)
|
||||
|
||||
// Arm N object at (48, 0) in cells -> cell (49, 1) -> room (gridX: 6, gridY: 0)
|
||||
const armNRoom = result.rooms?.find(r => r.gridX === 6 && r.gridY === 0)
|
||||
expect(armNRoom?.objects.some(o => o.id === 300)).toBe(true)
|
||||
})
|
||||
|
||||
test("generatePreset resolves multi-level variants and records nPickedFile", () => {
|
||||
const variant0 = makeMockDs1(20, 20, 1)
|
||||
const variant1 = makeMockDs1(20, 20, 2)
|
||||
const variant2 = makeMockDs1(20, 20, 3)
|
||||
|
||||
const pieces: PresetPiece[] = [
|
||||
{
|
||||
name: "MultiVariantLevel",
|
||||
levels: [variant0, variant1, variant2],
|
||||
},
|
||||
]
|
||||
|
||||
const seed = 54321
|
||||
const expectedIndex = resolvePickedFileIndex(3, seed)
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: 38,
|
||||
levelName: "MultiVariantLevel",
|
||||
seed,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.stats.nPickedFile).toBe(expectedIndex)
|
||||
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(expectedIndex + 1)
|
||||
expect(result.rooms).toBeDefined()
|
||||
expect(result.rooms?.length).toBe(9) // 20x20 -> 3x3 = 9 rooms
|
||||
expect(result.stats.rooms).toBe(9)
|
||||
})
|
||||
|
||||
test("generatePreset resolves multi-piece candidates and records nPickedFile", () => {
|
||||
const pieces: PresetPiece[] = [
|
||||
{ name: "VariantPiece_0", levels: [makeMockDs1(16, 16, 1)] },
|
||||
{ name: "VariantPiece_1", levels: [makeMockDs1(16, 16, 2)] },
|
||||
{ name: "VariantPiece_2", levels: [makeMockDs1(16, 16, 3)] },
|
||||
{ name: "VariantPiece_3", levels: [makeMockDs1(16, 16, 4)] },
|
||||
]
|
||||
|
||||
const seed = 777
|
||||
const expectedIndex = resolvePickedFileIndex(4, seed)
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: 14,
|
||||
levelName: "CountessTower",
|
||||
seed,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.stats.nPickedFile).toBe(expectedIndex)
|
||||
expect(result.level.cells[0]?.[0]?.floors[0]?.style).toBe(expectedIndex + 1)
|
||||
expect(result.rooms?.length).toBe(4) // 16x16 -> 2x2 = 4 rooms
|
||||
})
|
||||
|
||||
test("generatePreset stamps onto canvas when requested size differs from DS1", () => {
|
||||
const ds1 = makeMockDs1(16, 16, 1)
|
||||
const pieces: PresetPiece[] = [{ name: "SmallPiece", levels: [ds1] }]
|
||||
|
||||
const result = generatePreset({
|
||||
levelId: 33,
|
||||
sizeX: 32,
|
||||
sizeY: 24,
|
||||
pieces,
|
||||
})
|
||||
|
||||
expect(result.level.width).toBe(32)
|
||||
expect(result.level.height).toBe(24)
|
||||
// 32/8 = 4 columns, 24/8 = 3 rows -> 12 rooms
|
||||
expect(result.rooms?.length).toBe(12)
|
||||
expect(result.stats.rooms).toBe(12)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue