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) } }) }) })