diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 5b6626b..29d0f42 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -46,7 +46,7 @@ import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import type { SpriteFrame } from '../src/formats/sprite.ts' import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' -import type { MazePiece, MazePieceKind } from '../src/game/maze.ts' +import type { MazePiece, MazePieceKind, MazeWaypoint } from '../src/game/maze.ts' import { generateWilderness } from '../src/game/wilderness.ts' import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' import { generatePreset } from '../src/game/preset.ts' @@ -539,6 +539,7 @@ function buildSceneLinks( entrances: readonly WildernessEntrance[], mazeWarps: readonly MazeWarp[], waypointCells: readonly { x: number; y: number }[], + mazeWaypoints?: readonly MazeWaypoint[], ): SceneLinks { const outEntrances: SceneEntrance[] = [] const outWarps: SceneWarp[] = [] @@ -783,7 +784,7 @@ function buildSceneLinks( const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255 const waypoints: SceneWaypoint[] = [] if (waypointId !== 255) { - const fromArt = waypointCells[0] + const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined) const spot = fromArt ?? findWaypointSpot(grid, region) if (spot !== null && spot !== undefined) { const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot @@ -1156,6 +1157,7 @@ async function bakeDs1Variant( generatorLinks: { readonly entrances: readonly WildernessEntrance[] readonly mazeWarps: readonly MazeWarp[] + readonly mazeWaypoints?: readonly MazeWaypoint[] readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } = { entrances: [], mazeWarps: [] }, ): Promise { @@ -1385,6 +1387,7 @@ async function bakeDs1Variant( generatorLinks.entrances, generatorLinks.mazeWarps, waypointCells, + generatorLinks.mazeWaypoints, ) const sceneJson = { @@ -1585,7 +1588,11 @@ for (const entry of LEVELS) { await bakeDs1Variant( entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed, - { entrances: [], mazeWarps: (result.stats.warps ?? []) as MazeWarp[] }, + { + entrances: [], + mazeWarps: (result.stats.warps ?? []) as MazeWarp[], + mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[], + }, ) } catch (err) { console.error(`failed to bake maze ${label}: ${(err as Error).message}`) diff --git a/src/game/maze.ts b/src/game/maze.ts index 94ba571..cb9bf5e 100644 --- a/src/game/maze.ts +++ b/src/game/maze.ts @@ -80,6 +80,7 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall } from '../formats/ds1.ts' import { Rng } from './rng.ts' import { SUB_TILES_PER_TILE } from './map.ts' +export { SUB_TILES_PER_TILE } /* ------------------------------------------------------------------------- * * Directions @@ -192,6 +193,27 @@ export type MazePieceKind = | 'down' | 'quest' | 'treasure' + | 'waypoint' + +/** + * A waypoint room, as reported in `stats.waypoints`. + */ +export interface MazeWaypoint { + /** The room the piece went into, matching `specialsApplied`. */ + readonly room: number + /** The room's top-left corner in the final map, in cells. */ + readonly x: number + readonly y: number + /** The room's extent, in cells. */ + readonly width: number + readonly height: number + /** The room's centre, where the player lands or clicks. */ + readonly centreX: number + readonly centreY: number + /** Sub-tile coordinates of the waypoint entity within the synthesized map. */ + readonly entityX: number + readonly entityY: number +} /** * One `LvlPrest.txt` row that can be stamped into a maze level. @@ -265,6 +287,13 @@ export interface MazeRequest { readonly staffTombLevelId?: number /** Boss tomb (`LvlMaze.Rooms` doubled); see {@link staffTombLevelId}. */ readonly bossTombLevelId?: number + /** + * Explicit override for waypoint placement. + * If true, forces a waypoint room to be placed (using waypoint pieces). + * If false, skips waypoint room placement even if the level profile includes it. + * If omitted, defaults to whether the level id has a waypoint in the profile. + */ + readonly hasWaypoint?: boolean } /** The generated level. */ @@ -294,6 +323,8 @@ const KIND_PREFIXES: readonly (readonly [string, MazePieceKind])[] = [ ['Next', 'next'], ['Prev', 'prev'], ['Theme', 'theme'], + ['Waypoint', 'waypoint'], + ['waypoint', 'waypoint'], ] /** Which `LvlPrest` name families belong to which maze level type. */ @@ -338,17 +369,17 @@ export function classifyMazePieceName(name: string, levelTypeName: string): { ki const rest = name.slice(matchedFamily.length).trim() if (rest === '') return { kind: 'entrance', sides: '' } for (const [prefix, kind] of KIND_PREFIXES) { - if (!rest.startsWith(prefix)) continue + if (!rest.toLowerCase().startsWith(prefix.toLowerCase())) continue const tail = rest.slice(prefix.length).trim() // `Treasure 1` is numbered rather than sided; everything else uses sides. if (kind === 'treasure' && /^\d+$/.test(tail)) return { kind, sides: '' } return { kind, sides: tail } } - const reversedMatch = /^([NSEW]+)\s+(up|down)$/i.exec(rest) + const reversedMatch = /^([NSEW]+)\s+(up|down|waypoint)$/i.exec(rest) if (reversedMatch !== null) { const sides = reversedMatch[1]!.toUpperCase() const role = reversedMatch[2]!.toLowerCase() - return { kind: role === 'up' ? 'prev' : 'next', sides } + return { kind: role === 'up' ? 'prev' : role === 'down' ? 'next' : 'waypoint', sides } } // Anything left is a bare side token: `W`, `EW`, `NSEW`, ... if (/^[NSEW]+$/.test(rest)) return { kind: 'room', sides: rest } @@ -395,7 +426,7 @@ export function inferLevelTypeName(pieces: readonly MazePiece[]): string | null */ interface SpecialStep { /** Which kind of special piece to place. */ - readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' + readonly kind: 'prev' | 'next' | 'down' | 'quest' | 'treasure' | 'waypoint' /** Side token of the plain room this step replaces, e.g. `N`. */ readonly plainSides: string /** Side token of the special piece. */ @@ -534,7 +565,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ theme: true, specials: [ { kind: 'prev', entries: entries() }, - { kind: 'treasure', entries: entries(), levelIds: [29] }, + { kind: 'waypoint', entries: entries(), levelIds: [29] }, { kind: 'quest', entries: entries(), levelIds: [30] }, // D2MOO's `else` hangs off the `JAILLEV3` test, so levels 1 and 2 get a // `Next` staircase and level 3 gets the Cathedral descent instead. @@ -549,7 +580,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ opening: 'catacombs', specials: [ { kind: 'next', entries: entries() }, - { kind: 'treasure', entries: entries(), levelIds: [35] }, + { kind: 'waypoint', entries: entries(), levelIds: [35] }, ], }, { @@ -560,6 +591,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ specials: [ { kind: 'prev', entries: entries() }, { kind: 'next', entries: entries(), levelIds: [47, 48] }, + { kind: 'waypoint', entries: entries(), levelIds: [48] }, ], }, { @@ -570,6 +602,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ singleRoom: { 61: ['Act 2 - Tomb Tainted Sun X'] }, specials: [ { kind: 'next', entries: entries(), levelIds: [55, 56, 57, 58] }, + { kind: 'waypoint', entries: entries(), levelIds: [57] }, ], }, { @@ -600,6 +633,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ specials: [ { kind: 'prev', entries: entries(), levelIds: [100, 101] }, { kind: 'next', entries: entries(), levelIds: [100, 101] }, + { kind: 'waypoint', entries: entries(), levelIds: [101] }, ], }, { @@ -681,6 +715,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ { kind: 'prev', entries: entries(), levelIds: [113, 115, 118] }, { kind: 'next', entries: entries(), levelIds: [113, 115, 118] }, { kind: 'down', entries: entries(), levelIds: [113, 115, 118] }, + { kind: 'waypoint', entries: entries(), levelIds: [113, 115, 118] }, ], }, { @@ -711,6 +746,17 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ levelIds: [122, 123], inPlaceOnly: true, }, + { + kind: 'waypoint', + entries: [ + { plainSides: 'NE', sides: 'NE', direction: DIRECTION_NORTH }, + { plainSides: 'NW', sides: 'NW', direction: DIRECTION_NORTH }, + { plainSides: 'SE', sides: 'SE', direction: DIRECTION_SOUTH }, + { plainSides: 'SW', sides: 'SW', direction: DIRECTION_SOUTH }, + ], + levelIds: [123], + inPlaceOnly: true, + }, ], }, { @@ -720,6 +766,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [ prevChain: 'baal', specials: [ { kind: 'next', entries: entries(), levelIds: [128, 129, 130] }, + { kind: 'waypoint', entries: entries(), levelIds: [129] }, ], }, { levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] }, @@ -888,6 +935,8 @@ interface MazeStats { placementAttempts: number /** Stair rooms, with the coordinates only `stamp` can work out. */ warps: MazeWarp[] + /** Waypoint rooms, with the coordinates and entity positions only `stamp` can work out. */ + waypoints: MazeWaypoint[] } /** @@ -1839,6 +1888,36 @@ function stamp(level: MazeLevel, stats: MazeStats): Ds1 { centreY: originY + Math.floor(variant.height / 2), }) } + if (piece.kind === 'waypoint') { + const wpObj = variant.objects.find(obj => obj.type === 2 && (obj.id === 119 || obj.id === 145 || obj.id === 156 || obj.id === 157 || obj.id === 237 || obj.id === 238 || obj.id === 288 || obj.id === 323 || obj.id === 324 || obj.id === 398 || obj.id === 402 || obj.id === 429)) ?? variant.objects.find(obj => obj.type === 2) + let entityX: number + let entityY: number + if (wpObj !== undefined) { + entityX = originX * SUB_TILES_PER_TILE + wpObj.x + entityY = originY * SUB_TILES_PER_TILE + wpObj.y + } else { + entityX = (originX + Math.floor(variant.width / 2)) * SUB_TILES_PER_TILE + 2 + entityY = (originY + Math.floor(variant.height / 2)) * SUB_TILES_PER_TILE + 2 + objects.push({ + type: 2, + id: 119, + x: entityX, + y: entityY, + flags: 0, + }) + } + stats.waypoints.push({ + room: room.id, + x: originX, + y: originY, + width: variant.width, + height: variant.height, + centreX: originX + Math.floor(variant.width / 2), + centreY: originY + Math.floor(variant.height / 2), + entityX, + entityY, + }) + } stampedRooms += 1 } @@ -1928,7 +2007,7 @@ export function generateMaze(request: MazeRequest): MazeResult { const stats: MazeStats = { patterns: {}, fallbacks: [], specialsApplied: [], unresolvedRoles: [], notes: [], - ringRooms: 0, placementAttempts: 0, warps: [], + ringRooms: 0, placementAttempts: 0, warps: [], waypoints: [], } const rng = new Rng(request.seed) const mergePerMille = Math.max(0, Math.min(1000, Math.floor(request.merge))) @@ -1993,8 +2072,16 @@ export function generateMaze(request: MazeRequest): MazeResult { // One counter shared by every table, advanced once per call that happens. let cursor = rng.int(0, 3) for (const group of profile.specials) { - if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue - if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue + if (group.kind === 'waypoint') { + if (request.hasWaypoint === false) continue + if (request.hasWaypoint !== true) { + if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue + if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue + } + } else { + if (group.levelIds !== undefined && !group.levelIds.includes(request.levelId)) continue + if (group.exceptLevelIds !== undefined && group.exceptLevelIds.includes(request.levelId)) continue + } placeSpecialGroup(level, request.pieces, group, cursor, index, stats) cursor = (cursor + 1) % 4 } @@ -2034,6 +2121,14 @@ export function generateMaze(request: MazeRequest): MazeResult { fallbacks: stats.fallbacks, specialsApplied: stats.specialsApplied, warps: stats.warps, + waypoints: stats.waypoints, + waypoint: stats.waypoints[0] ?? null, + waypointTile: stats.waypoints[0] + ? { + x: Math.floor(stats.waypoints[0].entityX / SUB_TILES_PER_TILE), + y: Math.floor(stats.waypoints[0].entityY / SUB_TILES_PER_TILE), + } + : null, unresolvedRoles: stats.unresolvedRoles, unimplementedPasses: UNIMPLEMENTED_PASSES, notes: stats.notes, diff --git a/tests/maze-waypoint.test.ts b/tests/maze-waypoint.test.ts new file mode 100644 index 0000000..5259f15 --- /dev/null +++ b/tests/maze-waypoint.test.ts @@ -0,0 +1,528 @@ +import { describe, expect, test } from 'vitest' +import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' +import { + classifyMazePieceName, + generateMaze, + SUB_TILES_PER_TILE, +} from '../src/game/maze.ts' +import type { MazePiece, MazeRequest } from '../src/game/maze.ts' + +function createMockDs1(width = 8, height = 8, objects: Ds1Object[] = []): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + row.push({ + walls: [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }], + floors: [{ prop1: 2, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects, + npcPathOffset: null, + } +} + +function createPiece( + name: string, + levelTypeName: string, + ds1Objects: Ds1Object[] = [], + width = 8, + height = 8, +): MazePiece { + const classified = classifyMazePieceName(name, levelTypeName) + if (!classified) throw new Error(`Could not classify ${name} for ${levelTypeName}`) + return { + name, + kind: classified.kind, + sides: classified.sides, + levels: [createMockDs1(width, height, ds1Objects)], + } +} + +function createCatacombsPieces(wpObjects: Ds1Object[] = []): MazePiece[] { + const lt = 'Act 1 - Catacombs' + const sided = [ + 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', + ] + const pieces: MazePiece[] = [] + for (const s of sided) { + pieces.push(createPiece(`${lt} ${s}`, lt)) + } + pieces.push(createPiece(`${lt} Prev EW`, lt)) + pieces.push(createPiece(`${lt} Prev NS`, lt)) + for (const s of ['N', 'E', 'S', 'W']) { + pieces.push(createPiece(`${lt} Next ${s}`, lt)) + pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects)) + } + return pieces +} + +function createJailPieces(wpObjects: Ds1Object[] = []): MazePiece[] { + const lt = 'Act 1 - Jail' + const sided = [ + 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', + ] + const pieces: MazePiece[] = [] + for (const s of sided) { + pieces.push(createPiece(`${lt} ${s}`, lt)) + } + for (const s of ['N', 'E', 'S', 'W']) { + pieces.push(createPiece(`${lt} Prev ${s}`, lt)) + pieces.push(createPiece(`${lt} Next ${s}`, lt)) + pieces.push(createPiece(`${lt} Waypoint ${s}`, lt, wpObjects)) + } + return pieces +} + +function createKurastPieces(): MazePiece[] { + const lt = 'Act 3 - Kurast' + const sided = [ + 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', + ] + const pieces: MazePiece[] = [] + for (const s of sided) { + pieces.push(createPiece(`${lt} ${s}`, lt)) + } + for (const s of ['N', 'E', 'S', 'W']) { + pieces.push(createPiece(`Act 3 - Mephisto Prev ${s}`, lt)) + pieces.push(createPiece(`Act 3 - Mephisto Next ${s}`, lt)) + pieces.push(createPiece(`Act 3 - Mephisto Waypoint ${s}`, lt)) + } + return pieces +} + +function createBaalPieces(): MazePiece[] { + const lt = 'Act 5 - Baal' + const sided = [ + 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', + ] + const pieces: MazePiece[] = [] + for (const s of sided) { + pieces.push(createPiece(`${lt} ${s}`, lt)) + } + // prevChain requires Prev NSE, SEW, NSW, NEW + for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) { + pieces.push(createPiece(`${lt} Prev ${s}`, lt)) + } + for (const s of ['N', 'E', 'S', 'W']) { + pieces.push(createPiece(`${lt} Next ${s}`, lt)) + pieces.push(createPiece(`${lt} Waypoint ${s}`, lt)) + } + return pieces +} + +function createSewerPieces(): MazePiece[] { + const lt = 'Act 2 - Sewer' + const sided = [ + 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', + ] + const pieces: MazePiece[] = [] + for (const s of sided) { + pieces.push(createPiece(`${lt} ${s}`, lt)) + } + for (const s of ['N', 'E', 'S', 'W']) { + pieces.push(createPiece(`${lt} Prev ${s}`, lt)) + pieces.push(createPiece(`${lt} Next ${s}`, lt)) + pieces.push(createPiece(`${lt} Waypoint ${s}`, lt)) + } + return pieces +} + +function createTombPieces(): MazePiece[] { + const lt = 'Act 2 - Tomb' + const sided = [ + 'NSEW', 'NSE', 'NSW', 'NEW', 'SEW', 'NS', 'EW', 'NE', 'NW', 'SE', 'SW', 'N', 'S', 'E', 'W', + ] + const pieces: MazePiece[] = [] + for (const s of sided) { + pieces.push(createPiece(`${lt} ${s}`, lt)) + } + for (const s of ['NSE', 'SEW', 'NSW', 'NEW']) { + pieces.push(createPiece(`${lt} Prev ${s}`, lt)) + } + for (const s of ['N', 'E', 'S', 'W']) { + pieces.push(createPiece(`${lt} Next ${s}`, lt)) + pieces.push(createPiece(`${lt} Waypoint ${s}`, lt)) + } + return pieces +} + +describe('Issue #47: Maze Waypoint preset replacement pass', () => { + describe('classifyMazePieceName', () => { + test('classifies waypoint pieces for all acts correctly', () => { + expect(classifyMazePieceName('Act 1 - Jail Waypoint W', 'Act 1 - Jail')).toEqual({ + kind: 'waypoint', + sides: 'W', + }) + expect(classifyMazePieceName('Act 1 - Catacombs Waypoint N', 'Act 1 - Catacombs')).toEqual({ + kind: 'waypoint', + sides: 'N', + }) + expect(classifyMazePieceName('Act 2 - Sewer Waypoint S', 'Act 2 - Sewer')).toEqual({ + kind: 'waypoint', + sides: 'S', + }) + expect(classifyMazePieceName('Act 2 - Tomb Waypoint E', 'Act 2 - Tomb')).toEqual({ + kind: 'waypoint', + sides: 'E', + }) + expect(classifyMazePieceName('Act 3 - Mephisto Waypoint W', 'Act 3 - Kurast')).toEqual({ + kind: 'waypoint', + sides: 'W', + }) + expect(classifyMazePieceName('Act 5 - Ice waypoint N', 'Act 5 - Ice Caves')).toEqual({ + kind: 'waypoint', + sides: 'N', + }) + expect(classifyMazePieceName('Act 5 - Temple NE Waypoint', 'Act 5 - Temple')).toEqual({ + kind: 'waypoint', + sides: 'NE', + }) + expect(classifyMazePieceName('Act 5 - Temple SW Waypoint', 'Act 5 - Temple')).toEqual({ + kind: 'waypoint', + sides: 'SW', + }) + expect(classifyMazePieceName('Act 5 - Baal Waypoint N', 'Act 5 - Baal')).toEqual({ + kind: 'waypoint', + sides: 'N', + }) + }) + + test('preserves classification of other piece kinds', () => { + expect(classifyMazePieceName('Act 1 - Jail Prev W', 'Act 1 - Jail')).toEqual({ + kind: 'prev', + sides: 'W', + }) + expect(classifyMazePieceName('Act 1 - Catacombs Next S', 'Act 1 - Catacombs')).toEqual({ + kind: 'next', + sides: 'S', + }) + expect(classifyMazePieceName('Act 1 - Cave NSEW', 'Act 1 - Cave')).toEqual({ + kind: 'room', + sides: 'NSEW', + }) + }) + }) + + describe('generateMaze waypoint placement', () => { + test('places waypoint room in Catacombs 2 (level 35) and records stats', () => { + const pieces = createCatacombsPieces() + const req: MazeRequest = { + levelId: 35, + levelName: 'Catacombs Level 2', + levelTypeName: 'Act 1 - Catacombs', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 42, + pieces, + } + + const result = generateMaze(req) + const waypoints = result.stats.waypoints as unknown[] + expect(waypoints).toHaveLength(1) + + const wp = result.stats.waypoint as { + room: number + x: number + y: number + width: number + height: number + centreX: number + centreY: number + entityX: number + entityY: number + } + expect(wp).not.toBeNull() + expect(result.stats.waypointTile).toEqual({ + x: Math.floor(wp.entityX / SUB_TILES_PER_TILE), + y: Math.floor(wp.entityY / SUB_TILES_PER_TILE), + }) + + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const wpSpecial = specials.find(s => s.kind === 'waypoint') + expect(wpSpecial).toBeDefined() + expect(wpSpecial?.room).toBe(wp.room) + + // Verify synthesized waypoint entity is present in result.level.objects + const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 119) + expect(wpObj).toBeDefined() + expect(wpObj?.x).toBe(wp.entityX) + expect(wpObj?.y).toBe(wp.entityY) + }) + + test('does NOT place waypoint room in Catacombs 1 (level 34)', () => { + const pieces = createCatacombsPieces() + const req: MazeRequest = { + levelId: 34, + levelName: 'Catacombs Level 1', + levelTypeName: 'Act 1 - Catacombs', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 42, + pieces, + } + + const result = generateMaze(req) + expect(result.stats.waypoints).toEqual([]) + expect(result.stats.waypoint).toBeNull() + expect(result.stats.waypointTile).toBeNull() + + const specials = result.stats.specialsApplied as { kind: string }[] + expect(specials.some(s => s.kind === 'waypoint')).toBe(false) + }) + + test('places waypoint room in Jail 1 (level 29)', () => { + const pieces = createJailPieces() + const req: MazeRequest = { + levelId: 29, + levelName: 'Jail Level 1', + levelTypeName: 'Act 1 - Jail', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 101, + pieces, + } + + const result = generateMaze(req) + expect(result.stats.waypoints).toHaveLength(1) + expect(result.stats.waypoint).not.toBeNull() + }) + + test('honors hasWaypoint override: forces waypoint when true on level without default waypoint', () => { + const pieces = createCatacombsPieces() + const req: MazeRequest = { + levelId: 34, + levelName: 'Catacombs Level 1', + levelTypeName: 'Act 1 - Catacombs', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 42, + pieces, + hasWaypoint: true, + } + + const result = generateMaze(req) + expect(result.stats.waypoints).toHaveLength(1) + expect(result.stats.waypoint).not.toBeNull() + }) + + test('honors hasWaypoint override: disables waypoint when false on level with default waypoint', () => { + const pieces = createCatacombsPieces() + const req: MazeRequest = { + levelId: 35, + levelName: 'Catacombs Level 2', + levelTypeName: 'Act 1 - Catacombs', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 42, + pieces, + hasWaypoint: false, + } + + const result = generateMaze(req) + expect(result.stats.waypoints).toEqual([]) + expect(result.stats.waypoint).toBeNull() + }) + + test('uses authored DS1 object entity coordinates when present', () => { + const authoredObj: Ds1Object = { + type: 2, + id: 119, + x: 18, + y: 22, + flags: 0, + } + const pieces = createCatacombsPieces([authoredObj]) + const req: MazeRequest = { + levelId: 35, + levelName: 'Catacombs Level 2', + levelTypeName: 'Act 1 - Catacombs', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 88, + pieces, + } + + const result = generateMaze(req) + const wp = result.stats.waypoint as { + x: number + y: number + entityX: number + entityY: number + } + expect(wp.entityX).toBe(wp.x * SUB_TILES_PER_TILE + 18) + expect(wp.entityY).toBe(wp.y * SUB_TILES_PER_TILE + 22) + + const objInLevel = result.level.objects.find(o => o.type === 2 && o.id === 119) + expect(objInLevel).toBeDefined() + expect(objInLevel?.x).toBe(wp.entityX) + expect(objInLevel?.y).toBe(wp.entityY) + }) + + test('places waypoint in Durance of Hate 2 (101) but not Durance 1 (100)', () => { + const pieces = createKurastPieces() + const req101: MazeRequest = { + levelId: 101, + levelName: 'Durance of Hate Level 2', + levelTypeName: 'Act 3 - Kurast', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 777, + pieces, + } + const res101 = generateMaze(req101) + expect(res101.stats.waypoints).toHaveLength(1) + expect(res101.stats.waypoint).not.toBeNull() + + const req100: MazeRequest = { + levelId: 100, + levelName: 'Durance of Hate Level 1', + levelTypeName: 'Act 3 - Kurast', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 777, + pieces, + } + const res100 = generateMaze(req100) + expect(res100.stats.waypoints).toEqual([]) + expect(res100.stats.waypoint).toBeNull() + }) + + test('places waypoint in Worldstone Keep 2 (129) but not Worldstone 1 (128)', () => { + const pieces = createBaalPieces() + const req129: MazeRequest = { + levelId: 129, + levelName: 'Worldstone Keep Level 2', + levelTypeName: 'Act 5 - Baal', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 999, + pieces, + } + const res129 = generateMaze(req129) + expect(res129.stats.waypoints).toHaveLength(1) + expect(res129.stats.waypoint).not.toBeNull() + + const req128: MazeRequest = { + levelId: 128, + levelName: 'Worldstone Keep Level 1', + levelTypeName: 'Act 5 - Baal', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 999, + pieces, + } + const res128 = generateMaze(req128) + expect(res128.stats.waypoints).toEqual([]) + expect(res128.stats.waypoint).toBeNull() + }) + + test('places waypoint in Lut Gholein Sewer 2 (48) but not Sewer 1 (47)', () => { + const pieces = createSewerPieces() + const req48: MazeRequest = { + levelId: 48, + levelName: 'Sewers Level 2', + levelTypeName: 'Act 2 - Sewer', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 333, + pieces, + } + const res48 = generateMaze(req48) + expect(res48.stats.waypoints).toHaveLength(1) + expect(res48.stats.waypoint).not.toBeNull() + + const req47: MazeRequest = { + levelId: 47, + levelName: 'Sewers Level 1', + levelTypeName: 'Act 2 - Sewer', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 333, + pieces, + } + const res47 = generateMaze(req47) + expect(res47.stats.waypoints).toEqual([]) + expect(res47.stats.waypoint).toBeNull() + }) + + test('places waypoint in Halls of the Dead 2 (57) but not Tomb 1 (56)', () => { + const pieces = createTombPieces() + const req57: MazeRequest = { + levelId: 57, + levelName: 'Halls of the Dead Level 2', + levelTypeName: 'Act 2 - Tomb', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 444, + pieces, + } + const res57 = generateMaze(req57) + expect(res57.stats.waypoints).toHaveLength(1) + expect(res57.stats.waypoint).not.toBeNull() + + const req56: MazeRequest = { + levelId: 56, + levelName: 'Halls of the Dead Level 1', + levelTypeName: 'Act 2 - Tomb', + sectionSize: 8, + minRooms: 10, + merge: 100, + seed: 444, + pieces, + } + const res56 = generateMaze(req56) + expect(res56.stats.waypoints).toEqual([]) + expect(res56.stats.waypoint).toBeNull() + }) + + test('places waypoint at a distance from the entrance (isExit distance sorting)', () => { + const pieces = createCatacombsPieces() + const req: MazeRequest = { + levelId: 35, + levelName: 'Catacombs Level 2', + levelTypeName: 'Act 1 - Catacombs', + sectionSize: 8, + minRooms: 12, + merge: 0, + seed: 12345, + pieces, + } + + const result = generateMaze(req) + const wp = result.stats.waypoint as { room: number } + const specials = result.stats.specialsApplied as { room: number; kind: string }[] + const prevSpecial = specials.find(s => s.kind === 'prev') + if (prevSpecial) { + // Waypoint room should not be placed in the entrance room + expect(wp.room).not.toBe(prevSpecial.room) + } + }) + }) +})