diff --git a/src/game/maze-special-passes.ts b/src/game/maze-special-passes.ts index d2799a4..f9e5468 100644 --- a/src/game/maze-special-passes.ts +++ b/src/game/maze-special-passes.ts @@ -121,6 +121,27 @@ export const LVLPREST_ACT5_TEMPLE_REMAPPING_BASE = 1044 export const LVLPREST_ACT5_TEMPLE_FINAL_ROOM = 1059 export const LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE = 1060 +export const LVLPREST_ACT2_CORRUPT_HAREM_NE = 354 +export const LVLPREST_ACT2_CORRUPT_HAREM_SE = 355 +export const LVLPREST_ACT2_CORRUPT_HAREM_SW = 356 +export const LVLPREST_ACT2_CORRUPT_HAREM_NW = 357 +export const LVLPREST_ACT2_BASEMENT_NE = 358 +export const LVLPREST_ACT2_BASEMENT_SE = 359 +export const LVLPREST_ACT2_BASEMENT_SW = 360 +export const LVLPREST_ACT2_BASEMENT_NW = 361 +export const LVLPREST_ACT3_SPIDER_SW = 659 +export const LVLPREST_ACT3_SPIDER_SE = 660 +export const LVLPREST_ACT3_SPIDER_NW = 661 +export const LVLPREST_ACT3_SPIDER_NE = 662 +export const LVLPREST_ACT3_SPIDER_CHEST_NW = 663 +export const LVLPREST_ACT3_SPIDER_CHEST_NE = 664 +export const LVLPREST_ACT5_LAVA_N = 1053 +export const LVLPREST_ACT5_LAVA_S = 1054 +export const LVLPREST_ACT5_LAVA_E = 1055 +export const LVLPREST_ACT5_LAVA_W = 1056 +export const LVLPREST_ACT5_LAVA_EW = 1057 +export const LVLPREST_ACT5_LAVA_NS = 1058 + /** * 1.13c `nExpansionHardcodedPresetsRemapping` orientations for Act 5 Temple levels (`DrlgMaze.cpp`). */ @@ -141,6 +162,8 @@ export const LVLPREST_MAZE_DEF_REGISTRY: Readonly { - // Was `expect(UNIMPLEMENTED_PASSES).toEqual([])`: the constant was hardcoded - // to `[]`, so it asserted a claim the profile table contradicts. The gap list - // is now derived, so the honest assertion is "it names exactly the level types - // whose `specials` table is still empty". - test('UNIMPLEMENTED_PASSES names every level type with no transcribed specials table', () => { + test('UNIMPLEMENTED_PASSES is empty when all maze profiles define specials', () => { const emptySpecials = MAZE_LEVEL_TYPE_PROFILES .filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined) .map(profile => profile.levelTypeName) - expect(emptySpecials.length).toBeGreaterThan(0) - expect(UNIMPLEMENTED_PASSES).toEqual( - emptySpecials.map(name => `DRLGMAZE_GenerateLevel specials table (${name})`), - ) + expect(emptySpecials).toEqual([]) + expect(UNIMPLEMENTED_PASSES).toEqual([]) }) test('unimplementedPassesFor is empty for a fully transcribed level type', () => { expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([]) expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([]) - }) - - test('unimplementedPassesFor reports the gap for a level type with no specials', () => { - expect(unimplementedPassesFor('Act 3 - Spider')) - .toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)']) + expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([]) + expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([]) + expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([]) + expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([]) }) test('unimplementedPassesFor reports a level type that has no profile at all', () => { @@ -138,7 +131,7 @@ describe('Diablo II DRLG Maze Special Passes', () => { .toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)']) }) - test('generateMaze reports the level type gap, not a global constant', () => { + test('generateMaze reports empty unimplementedPasses for transcribed levels', () => { const spider = generateMaze({ levelId: 84, levelName: 'Spider Cavern', @@ -148,8 +141,7 @@ describe('Diablo II DRLG Maze Special Passes', () => { seed: 8484, pieces: createStandardPieceSet('Act 3 - Spider'), }) - expect(spider.stats.unimplementedPasses) - .toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)']) + expect(spider.stats.unimplementedPasses).toEqual([]) // The other two diagnostic channels must travel with it. expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true) expect(Array.isArray(spider.stats.notes)).toBe(true) diff --git a/tests/maze-specials.test.ts b/tests/maze-specials.test.ts new file mode 100644 index 0000000..7f4ccfe --- /dev/null +++ b/tests/maze-specials.test.ts @@ -0,0 +1,348 @@ +import { describe, test, expect } from 'vitest' +import { + generateMaze, + unimplementedPassesFor, + UNIMPLEMENTED_PASSES, + MAZE_LEVEL_TYPE_PROFILES, + type MazePiece, + type MazePieceKind, + type MazeWarp, + type MazeWaypoint, +} from '../src/game/maze.ts' +import { + LVLPREST_ACT2_CORRUPT_HAREM_NE, + LVLPREST_ACT2_CORRUPT_HAREM_SE, + LVLPREST_ACT2_CORRUPT_HAREM_SW, + LVLPREST_ACT2_CORRUPT_HAREM_NW, + LVLPREST_ACT2_BASEMENT_NE, + LVLPREST_ACT2_BASEMENT_SE, + LVLPREST_ACT2_BASEMENT_SW, + LVLPREST_ACT2_BASEMENT_NW, + LVLPREST_ACT3_SPIDER_SW, + LVLPREST_ACT3_SPIDER_SE, + LVLPREST_ACT3_SPIDER_NW, + LVLPREST_ACT3_SPIDER_NE, + LVLPREST_ACT3_SPIDER_CHEST_NW, + LVLPREST_ACT3_SPIDER_CHEST_NE, + LVLPREST_ACT5_LAVA_N, + LVLPREST_ACT5_LAVA_S, + LVLPREST_ACT5_LAVA_E, + LVLPREST_ACT5_LAVA_W, + LVLPREST_ACT5_LAVA_EW, + LVLPREST_ACT5_LAVA_NS, + LVLPREST_MAZE_DEF_REGISTRY, +} from '../src/game/maze-special-passes.ts' +import type { Ds1, Ds1Cell, Ds1Object } from '../src/formats/ds1.ts' + +function mockDs1(width = 10, height = 10, objects: Ds1Object[] = []): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y++) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x++) { + row.push({ + walls: [{ prop1: 1, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }], + floors: [{ prop1: 1, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], + shadows: [{ prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }], + substitutions: [], + }) + } + cells.push(row) + } + return { + version: 18, + width, + height, + act: 1, + substitutionType: 0, + wallLayers: 1, + floorLayers: 1, + cells, + objects, + npcPathOffset: null, + } +} + +function mockPiece( + name: string, + kind: MazePieceKind, + sides = '', + objects: Ds1Object[] = [], + width = 10, + height = 10, +): MazePiece { + return { + name, + kind, + sides, + levels: [mockDs1(width, height, objects)], + } +} + +const SIDES = ['W', 'E', 'EW', 'S', 'SW', 'SE', 'SEW', 'N', 'NW', 'NE', 'NEW', 'NS', 'NSW', 'NSE', 'NSEW'] + +function createPieceSetForType( + levelTypeName: string, + extraKinds: { kind: MazePieceKind; nameToken: string; objects?: Ds1Object[] }[] = [], +): MazePiece[] { + const pieces: MazePiece[] = [ + mockPiece(`${levelTypeName} Entrance`, 'entrance', ''), + ...SIDES.map(s => mockPiece(`${levelTypeName} ${s}`, 'room', s)), + ...['N', 'S', 'E', 'W'].map(s => + mockPiece(`${levelTypeName} Prev ${s}`, 'prev', s, [{ type: 2, id: 1, x: 25, y: 25, flags: 0 }]), + ), + ...['N', 'S', 'E', 'W'].map(s => + mockPiece(`${levelTypeName} Next ${s}`, 'next', s, [{ type: 2, id: 2, x: 25, y: 25, flags: 0 }]), + ), + ...['N', 'S', 'E', 'W'].map(s => + mockPiece(`${levelTypeName} Waypoint ${s}`, 'waypoint', s, [{ type: 2, id: 156, x: 25, y: 25, flags: 0 }]), + ), + ...['N', 'S', 'E', 'W'].map(s => + mockPiece(`${levelTypeName} Treasure ${s}`, 'treasure', s, [{ type: 2, id: 397, x: 25, y: 25, flags: 0 }]), + ), + ] + for (const extra of extraKinds) { + for (const s of ['N', 'S', 'E', 'W', 'NW', 'NE', 'SW', 'SE']) { + pieces.push(mockPiece(`${levelTypeName} ${extra.nameToken} ${s}`, extra.kind, s, extra.objects ?? [])) + } + } + return pieces +} + +describe('Maze Specials Profiles (Issue #84)', () => { + describe('Profile completeness and unimplementedPasses check', () => { + test('all 4 target level types report 0 unimplemented passes', () => { + expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([]) + expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([]) + expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([]) + expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([]) + }) + + test('UNIMPLEMENTED_PASSES global array is completely empty', () => { + expect(UNIMPLEMENTED_PASSES).toEqual([]) + }) + + test('profile table contains correctly configured special tables for all four types', () => { + const harem = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 2 - Harem')! + expect(harem).toBeDefined() + expect(harem.ring).toBe(2) + expect(harem.grow).toBe(false) + expect(harem.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ + { kind: 'prev', levelIds: [51] }, + { kind: 'next', levelIds: [51] }, + ]) + + const basement = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 2 - Basement')! + expect(basement).toBeDefined() + expect(basement.ring).toBe(2) + expect(basement.grow).toBe(false) + expect(basement.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ + { kind: 'prev', levelIds: [52, 53, 54] }, + { kind: 'waypoint', levelIds: [52] }, + { kind: 'next', levelIds: [52, 53] }, + ]) + + const spider = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 3 - Spider')! + expect(spider).toBeDefined() + expect(spider.ring).toBe(2) + expect(spider.grow).toBe(false) + expect(spider.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ + { kind: 'prev', levelIds: [84, 85] }, + { kind: 'treasure', levelIds: [84, 85] }, + ]) + + const lava = MAZE_LEVEL_TYPE_PROFILES.find(p => p.levelTypeName === 'Act 5 - Lava')! + expect(lava).toBeDefined() + expect(lava.grow).toBe(false) + expect(lava.lavaPairs).toBe(true) + expect(lava.specials.map(s => ({ kind: s.kind, levelIds: s.levelIds }))).toEqual([ + { kind: 'prev', levelIds: [125, 126, 127] }, + { kind: 'treasure', levelIds: [125, 126, 127] }, + ]) + }) + }) + + describe('LvlPrest constants & def registry', () => { + test('defines canonical LvlPrest Def IDs', () => { + expect(LVLPREST_ACT2_CORRUPT_HAREM_NE).toBe(354) + expect(LVLPREST_ACT2_CORRUPT_HAREM_SE).toBe(355) + expect(LVLPREST_ACT2_CORRUPT_HAREM_SW).toBe(356) + expect(LVLPREST_ACT2_CORRUPT_HAREM_NW).toBe(357) + + expect(LVLPREST_ACT2_BASEMENT_NE).toBe(358) + expect(LVLPREST_ACT2_BASEMENT_SE).toBe(359) + expect(LVLPREST_ACT2_BASEMENT_SW).toBe(360) + expect(LVLPREST_ACT2_BASEMENT_NW).toBe(361) + + expect(LVLPREST_ACT3_SPIDER_SW).toBe(659) + expect(LVLPREST_ACT3_SPIDER_SE).toBe(660) + expect(LVLPREST_ACT3_SPIDER_NW).toBe(661) + expect(LVLPREST_ACT3_SPIDER_NE).toBe(662) + expect(LVLPREST_ACT3_SPIDER_CHEST_NW).toBe(663) + expect(LVLPREST_ACT3_SPIDER_CHEST_NE).toBe(664) + + expect(LVLPREST_ACT5_LAVA_N).toBe(1053) + expect(LVLPREST_ACT5_LAVA_S).toBe(1054) + expect(LVLPREST_ACT5_LAVA_E).toBe(1055) + expect(LVLPREST_ACT5_LAVA_W).toBe(1056) + expect(LVLPREST_ACT5_LAVA_EW).toBe(1057) + expect(LVLPREST_ACT5_LAVA_NS).toBe(1058) + }) + + test('registers Spider chests in LVLPREST_MAZE_DEF_REGISTRY', () => { + expect(LVLPREST_MAZE_DEF_REGISTRY[LVLPREST_ACT3_SPIDER_CHEST_NW]).toEqual({ + name: 'Act 3 - Spider Chest NW', + kind: 'treasure', + sides: 'NW', + }) + expect(LVLPREST_MAZE_DEF_REGISTRY[LVLPREST_ACT3_SPIDER_CHEST_NE]).toEqual({ + name: 'Act 3 - Spider Chest NE', + kind: 'treasure', + sides: 'NE', + }) + }) + }) + + describe('generateMaze execution with specials', () => { + test('Act 2 - Harem (Level 51) places prev and next specials', () => { + const pieces = createPieceSetForType('Act 2 - Harem') + const result = generateMaze({ + levelId: 51, + levelName: 'Harem Level 2', + levelTypeName: 'Act 2 - Harem', + sectionSize: 10, + minRooms: 4, + merge: 0, + seed: 51001, + pieces, + }) + + expect(result.stats.unimplementedPasses).toEqual([]) + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const kinds = specials.map(s => s.kind) + expect(kinds).toContain('prev') + expect(kinds).toContain('next') + const warps = result.stats.warps as MazeWarp[] + expect(warps.length).toBeGreaterThanOrEqual(2) + expect(warps.some(w => w.direction === 'up')).toBe(true) + expect(warps.some(w => w.direction === 'down')).toBe(true) + }) + + test('Act 2 - Basement (Level 52) places prev, waypoint, and next specials', () => { + const pieces = createPieceSetForType('Act 2 - Basement') + const result = generateMaze({ + levelId: 52, + levelName: 'Palace Cellar Level 1', + levelTypeName: 'Act 2 - Basement', + sectionSize: 10, + minRooms: 4, + merge: 0, + seed: 52001, + pieces, + hasWaypoint: true, + }) + + expect(result.stats.unimplementedPasses).toEqual([]) + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const kinds = specials.map(s => s.kind) + expect(kinds).toContain('prev') + expect(kinds).toContain('waypoint') + expect(kinds).toContain('next') + const waypoints = result.stats.waypoints as MazeWaypoint[] + expect(waypoints.length).toBe(1) + const warps = result.stats.warps as MazeWarp[] + expect(warps.length).toBeGreaterThanOrEqual(2) + }) + + test('Act 2 - Basement (Level 53) places prev and next without waypoint', () => { + const pieces = createPieceSetForType('Act 2 - Basement') + const result = generateMaze({ + levelId: 53, + levelName: 'Palace Cellar Level 2', + levelTypeName: 'Act 2 - Basement', + sectionSize: 10, + minRooms: 4, + merge: 0, + seed: 53001, + pieces, + }) + + expect(result.stats.unimplementedPasses).toEqual([]) + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const kinds = specials.map(s => s.kind) + expect(kinds).toContain('prev') + expect(kinds).toContain('next') + expect(kinds).not.toContain('waypoint') + const waypoints = result.stats.waypoints as MazeWaypoint[] + expect(waypoints.length).toBe(0) + }) + + test('Act 2 - Basement (Level 54) places prev only', () => { + const pieces = createPieceSetForType('Act 2 - Basement') + const result = generateMaze({ + levelId: 54, + levelName: 'Palace Cellar Level 3', + levelTypeName: 'Act 2 - Basement', + sectionSize: 10, + minRooms: 4, + merge: 0, + seed: 54001, + pieces, + }) + + expect(result.stats.unimplementedPasses).toEqual([]) + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const kinds = specials.map(s => s.kind) + expect(kinds).toContain('prev') + expect(kinds).not.toContain('next') + expect(kinds).not.toContain('waypoint') + }) + + test('Act 3 - Spider (Level 84 & 85) places prev and treasure', () => { + for (const levelId of [84, 85]) { + const pieces = createPieceSetForType('Act 3 - Spider') + const result = generateMaze({ + levelId, + levelName: levelId === 84 ? 'Spider Cave' : 'Spider Cavern', + levelTypeName: 'Act 3 - Spider', + sectionSize: 10, + minRooms: 4, + merge: 0, + seed: levelId * 1000 + 7, + pieces, + }) + + expect(result.stats.unimplementedPasses).toEqual([]) + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const kinds = specials.map(s => s.kind) + expect(kinds).toContain('prev') + expect(kinds).toContain('treasure') + const warps = result.stats.warps as MazeWarp[] + expect(warps.some(w => w.direction === 'up')).toBe(true) + } + }) + + test('Act 5 - Lava (Levels 125, 126, 127) places prev and treasure', () => { + for (const levelId of [125, 126, 127]) { + const pieces = createPieceSetForType('Act 5 - Lava') + const result = generateMaze({ + levelId, + levelName: `Lava Pit ${levelId}`, + levelTypeName: 'Act 5 - Lava', + sectionSize: 10, + minRooms: 4, + merge: 0, + seed: levelId * 1000 + 9, + pieces, + }) + + expect(result.stats.unimplementedPasses).toEqual([]) + const specials = result.stats.specialsApplied as { room: number; kind: string; sides: string }[] + const kinds = specials.map(s => s.kind) + expect(kinds).toContain('prev') + expect(kinds).toContain('treasure') + const warps = result.stats.warps as MazeWarp[] + expect(warps.some(w => w.direction === 'up')).toBe(true) + } + }) + }) +})