Compare commits

...

3 Commits

Author SHA1 Message Date
troytt 2a4ef21ce6 feat(items): 加载 ItemTypes.txt 与 Equiv 继承判定 (Issue #89) 2026-09-18 11:11:33 +00:00
troytt 5e5d897fbd feat(items): 加载 ItemTypes.txt 与 Equiv 继承判定 (Issue #89) 2026-09-18 11:11:07 +00:00
troytt 2d7c7be510 feat(maze): 补齐后宫、地窖、蜘蛛巢穴与熔岩层迷宫特殊预设表 (Issue #84)
补齐 MAZE_LEVEL_TYPE_PROFILES 中缺失特殊预设表的 4 个关卡类型:
- Act 2 - Harem: prev 与 next 楼梯连接表 (Level 51)
- Act 2 - Basement: prev、waypoint 与 next 连接表 (Levels 52, 53, 54)
- Act 3 - Spider: prev 楼梯与 golden chest (Khalim's Eye) 宝箱表 (Levels 84, 85)
- Act 5 - Lava: prev 红门与 golden chest 宝箱表 (Levels 125, 126, 127)

同时在 maze-special-passes 中补充对应 LvlPrest.txt Def 常量与 def registry,
使 UNIMPLEMENTED_PASSES 归零并通过全量测试。

TAG=agy
CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
2026-09-18 11:09:44 +00:00
6 changed files with 1172 additions and 21 deletions

291
src/game/item-types.ts Normal file
View File

@ -0,0 +1,291 @@
/**
* Diablo II item types loader and inheritance hierarchy (`ItemTypes.txt`).
*
* `ItemTypes.txt` establishes the item taxonomy:
* - Direct attributes: sockets by item level tier, body equip locations, class specificity,
* rarity multipliers for auto-TC generation, and magic/rare/normal/charm/gem flags.
* - Hierarchy: `Equiv1` and `Equiv2` define parent types (e.g., `shie -> shld`, `wand -> rod`,
* `abow -> bow -> miss -> weap`), forming a directed acyclic graph (DAG).
*
* Exactly 5 types in vanilla 1.13c seed TreasureClass generation (`treasureClass === 1`):
* `bow`, `weap`, `mele`, `armo`, and `abow`.
*/
import type { D2Table } from './acts.ts'
import { cell, parseTable } from './acts.ts'
import type { MountedArchives } from '../mpq/mount.ts'
/** File path of `ItemTypes.txt` inside MPQ archives. */
export const ITEM_TYPES_TABLE_PATH = 'data\\global\\excel\\ItemTypes.txt'
/**
* Definition of an individual item type row from `ItemTypes.txt`.
*/
export interface ItemTypeDefinition {
/** 3-4 character type identifier code (e.g., 'shie', 'weap'). */
readonly code: string
/** Human-readable name from the `ItemType` column. */
readonly name: string
/** Primary parent type code from `Equiv1`. */
readonly equiv1: string
/** Secondary parent type code from `Equiv2`. */
readonly equiv2: string
/** Whether this type seeds an auto-TreasureClass (1 or 0). Exactly 5 types in 1.13c have 1. */
readonly treasureClass: number
/** Auto-TC weight multiplier (typically 3, 1, 2, or 0). */
readonly rarity: number
/** Maximum sockets for item level 1-24. */
readonly maxSock1: number
/** Maximum sockets for item level 25-39. */
readonly maxSock25: number
/** Maximum sockets for item level 40+. */
readonly maxSock40: number
/** Whether items of this type are always magic. */
readonly magic: boolean
/** Whether items of this type can spawn as rare. */
readonly rare: boolean
/** Whether items of this type are always normal/non-magical. */
readonly normal: boolean
/** Whether this type functions as an inventory charm. */
readonly charm: boolean
/** Whether this type functions as a socketable gem. */
readonly gem: boolean
/** Character class code receiving staff mods (e.g., 'pal', 'nec', 'sor'). */
readonly staffMods: string
/** Whether this item can be equipped on character body slots. */
readonly body: boolean
/** Primary body equipment slot code (e.g., 'rarm', 'tors', 'head'). */
readonly bodyLoc1: string
/** Secondary body equipment slot code (e.g., 'larm', 'tors', 'head'). */
readonly bodyLoc2: string
/** Whether this item can be placed in belt slots. */
readonly beltable: boolean
/** Character class code required for class-specific items (e.g., 'ama', 'bar', 'nec'). */
readonly class: string
}
/**
* Case-insensitive, whitespace-tolerant Map for looking up item types by code.
*/
class CaseInsensitiveMap<V> extends Map<string, V> {
private normalize(key: string): string {
return typeof key === 'string' ? key.trim().toLowerCase() : String(key).trim().toLowerCase()
}
override get(key: string): V | undefined {
return super.get(this.normalize(key))
}
override has(key: string): boolean {
return super.has(this.normalize(key))
}
override set(key: string, value: V): this {
return super.set(this.normalize(key), value)
}
override delete(key: string): boolean {
return super.delete(this.normalize(key))
}
}
/**
* Parsed item types table providing array and indexed lookup access.
*/
export interface ItemTypeTable {
/** All valid item type definitions in file order (98 types in vanilla 1.13c). */
readonly types: readonly ItemTypeDefinition[]
/** Lookup map indexed by item type code (case-insensitive and trimmed). */
readonly byCode: ReadonlyMap<string, ItemTypeDefinition>
/** @internal Memoization cache for isA queries. */
readonly _isACache?: Map<string, boolean>
}
/**
* Parses `ItemTypes.txt` from a raw tab-separated D2Table.
*
* Filters out:
* - First 2 placeholder rows of `None` (indices 0 and 1)
* - 3 unused placeholder rows of `Not Used` (indices 14, 17, 23)
* - The `Expansion` separator row (index 58)
* - Any rows with empty `Code`
*
* In vanilla Diablo II 1.13c, 104 rows - 6 filtered = exactly 98 valid types.
*
* @param table - Raw parsed tab-separated table.
* @returns Parsed `ItemTypeTable` with list, code lookup, and inheritance cache.
*/
export function parseItemTypesTable(table: D2Table): ItemTypeTable {
const types: ItemTypeDefinition[] = []
const byCode = new CaseInsensitiveMap<ItemTypeDefinition>()
for (const row of table.rows) {
const code = cell(table, row, 'Code').trim()
const name = cell(table, row, 'ItemType').trim()
// Filter out rows with empty Code, placeholders (None, Not Used), and Expansion separator
if (!code || name === 'None' || name === 'Not Used' || name === 'Expansion') {
continue
}
const def: ItemTypeDefinition = {
code,
name,
equiv1: cell(table, row, 'Equiv1').trim(),
equiv2: cell(table, row, 'Equiv2').trim(),
treasureClass: Number(cell(table, row, 'TreasureClass')) || 0,
rarity: Number(cell(table, row, 'Rarity')) || 0,
maxSock1: Number(cell(table, row, 'MaxSock1')) || 0,
maxSock25: Number(cell(table, row, 'MaxSock25')) || 0,
maxSock40: Number(cell(table, row, 'MaxSock40')) || 0,
magic: cell(table, row, 'Magic').trim() === '1',
rare: cell(table, row, 'Rare').trim() === '1',
normal: cell(table, row, 'Normal').trim() === '1',
charm: cell(table, row, 'Charm').trim() === '1',
gem: cell(table, row, 'Gem').trim() === '1',
staffMods: cell(table, row, 'StaffMods').trim(),
body: cell(table, row, 'Body').trim() === '1',
bodyLoc1: cell(table, row, 'BodyLoc1').trim(),
bodyLoc2: cell(table, row, 'BodyLoc2').trim(),
beltable: cell(table, row, 'Beltable').trim() === '1',
class: cell(table, row, 'Class').trim(),
}
types.push(def)
byCode.set(code, def)
}
return {
types,
byCode,
_isACache: new Map<string, boolean>(),
}
}
/**
* Loads and parses `ItemTypes.txt` from mounted MPQ archives.
*
* @param archives - Mounted MPQ archives.
* @returns Promise resolving to `ItemTypeTable`.
*/
export async function loadItemTypes(archives: MountedArchives): Promise<ItemTypeTable> {
const bytes = await archives.read(ITEM_TYPES_TABLE_PATH)
const d2Table = parseTable(bytes)
return parseItemTypesTable(d2Table)
}
/** Fallback cache for tables that do not define `_isACache`. */
const fallbackCache = new WeakMap<ItemTypeTable, Map<string, boolean>>()
function getOrInitCache(table: ItemTypeTable): Map<string, boolean> {
if (table._isACache) {
return table._isACache
}
let cache = fallbackCache.get(table)
if (!cache) {
cache = new Map<string, boolean>()
fallbackCache.set(table, cache)
}
return cache
}
/**
* Recursive DFS traversal with cycle protection and intermediate cache lookup.
*/
function checkInheritance(
current: string,
target: string,
table: ItemTypeTable,
visited: Set<string>,
cache: Map<string, boolean>,
): boolean {
if (current === target) {
return true
}
if (visited.has(current)) {
return false
}
visited.add(current)
const def = table.byCode.get(current)
if (!def) {
return false
}
if (def.equiv1) {
const eq1 = def.equiv1.trim().toLowerCase()
if (eq1) {
const key1 = `${eq1}:${target}`
const cached1 = cache.get(key1)
if (cached1 === true) {
return true
}
if (cached1 === undefined && checkInheritance(eq1, target, table, visited, cache)) {
return true
}
}
}
if (def.equiv2) {
const eq2 = def.equiv2.trim().toLowerCase()
if (eq2) {
const key2 = `${eq2}:${target}`
const cached2 = cache.get(key2)
if (cached2 === true) {
return true
}
if (cached2 === undefined && checkInheritance(eq2, target, table, visited, cache)) {
return true
}
}
}
return false
}
/**
* Checks whether `typeCode` is or inherits from `ancestorCode`.
*
* - Returns `true` if `typeCode === ancestorCode` (reflexive).
* - Recursively inspects `Equiv1` and `Equiv2` parent links.
* - Protected against infinite cycles in cyclic inheritance chains.
* - Memoized using table-specific cache for performance.
* - Case-insensitive and whitespace-tolerant on both type codes.
*
* @param typeCode - Child type code to test (e.g., 'abow', 'shie').
* @param ancestorCode - Ancestor type code to test for (e.g., 'weap', 'shld').
* @param table - Item types table containing definitions.
* @returns `true` if `typeCode` is or descends from `ancestorCode`.
*/
export function isA(
typeCode: string,
ancestorCode: string,
table: ItemTypeTable,
): boolean {
const code = typeCode.trim().toLowerCase()
const ancestor = ancestorCode.trim().toLowerCase()
if (!code || !ancestor) {
return false
}
// Both must be valid item types in the table
if (!table.byCode.has(code) || !table.byCode.has(ancestor)) {
return false
}
if (code === ancestor) {
return true
}
const cache = getOrInitCache(table)
const cacheKey = `${code}:${ancestor}`
const cached = cache.get(cacheKey)
if (cached !== undefined) {
return cached
}
const visited = new Set<string>()
const result = checkInheritance(code, ancestor, table, visited, cache)
cache.set(cacheKey, result)
return result
}

View File

@ -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<Record<number, { readonly name
[LVLPREST_ACT5_ICE_POOL_A]: { name: 'Act 5 - Ice Pool A', kind: 'quest' },
[LVLPREST_ACT5_ICE_POOL_B]: { name: 'Act 5 - Ice Pool B', kind: 'quest' },
[LVLPREST_ACT5_TEMPLE_FINAL_ROOM]: { name: 'Act 5 - Temple Final Room', kind: 'quest' },
[LVLPREST_ACT3_SPIDER_CHEST_NW]: { name: 'Act 3 - Spider Chest NW', kind: 'treasure', sides: 'NW' },
[LVLPREST_ACT3_SPIDER_CHEST_NE]: { name: 'Act 3 - Spider Chest NE', kind: 'treasure', sides: 'NE' },
}
/**

View File

@ -846,9 +846,37 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
arcane: true,
specials: [{ kind: 'quest', entries: entries() }],
},
{ levelTypeName: 'Act 2 - Harem', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 2 - Basement', ring: 2, grow: false, theme: false, specials: [] },
{ levelTypeName: 'Act 3 - Spider', ring: 2, grow: false, theme: false, specials: [] },
{
levelTypeName: 'Act 2 - Harem',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [51] },
{ kind: 'next', entries: entries(), levelIds: [51] },
],
},
{
levelTypeName: 'Act 2 - Basement',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [52, 53, 54] },
{ kind: 'waypoint', entries: entries(), levelIds: [52] },
{ kind: 'next', entries: entries(), levelIds: [52, 53] },
],
},
{
levelTypeName: 'Act 3 - Spider',
ring: 2,
grow: false,
theme: false,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [84, 85] },
{ kind: 'treasure', entries: entries(), levelIds: [84, 85] },
],
},
{
levelTypeName: 'Act 3 - Kurast',
ring: 2,
@ -987,7 +1015,16 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
],
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
},
{ levelTypeName: 'Act 5 - Lava', grow: false, theme: false, lavaPairs: true, specials: [] },
{
levelTypeName: 'Act 5 - Lava',
grow: false,
theme: false,
lavaPairs: true,
specials: [
{ kind: 'prev', entries: entries(), levelIds: [125, 126, 127] },
{ kind: 'treasure', entries: entries(), levelIds: [125, 126, 127] },
],
},
]
/**

460
tests/item-types.test.ts Normal file
View File

@ -0,0 +1,460 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as fs from 'fs'
import { MountedArchives } from '../src/mpq/mount.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import type { D2Table } from '../src/game/acts.ts'
import { cell, parseTable } from '../src/game/acts.ts'
import {
parseItemTypesTable,
loadItemTypes,
isA,
ITEM_TYPES_TABLE_PATH,
type ItemTypeTable,
} from '../src/game/item-types.ts'
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
describe('ItemTypes loader & Equiv inheritance (Issue #89)', () => {
let table: ItemTypeTable
let rawTable: D2Table | undefined
beforeAll(async () => {
if (hasD2) {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
const bytes = await archives.read(ITEM_TYPES_TABLE_PATH)
rawTable = parseTable(bytes)
table = parseItemTypesTable(rawTable)
} else {
// Minimal self-contained fallback fixture representing standard 1.13c ItemTypes
rawTable = createMinimalFallbackTable()
table = parseItemTypesTable(rawTable)
}
})
it('correctly uses the canonical MPQ table path', () => {
expect(ITEM_TYPES_TABLE_PATH).toBe('data\\global\\excel\\ItemTypes.txt')
})
it('loads item types using loadItemTypes with mounted archives', async () => {
if (hasD2) {
const archives = new MountedArchives()
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
}
const loaded = await loadItemTypes(archives)
expect(loaded.types.length).toBe(98)
expect(loaded.byCode.size).toBe(98)
expect(isA('shie', 'shld', loaded)).toBe(true)
}
})
it('verifies 104 raw rows in original file and filters exactly 6 rows to yield 98 valid types', () => {
if (hasD2 && rawTable) {
expect(rawTable.rows.length).toBe(104)
// Filtered indices:
// Index 0 & 1: None placeholders
expect(cell(rawTable, rawTable.rows[0]!, 'ItemType')).toBe('None')
expect(cell(rawTable, rawTable.rows[0]!, 'Code')).toBe('')
expect(cell(rawTable, rawTable.rows[1]!, 'ItemType')).toBe('None')
expect(cell(rawTable, rawTable.rows[1]!, 'Code')).toBe('')
// Index 14, 17, 23: Not Used placeholders
expect(cell(rawTable, rawTable.rows[14]!, 'ItemType')).toBe('Not Used')
expect(cell(rawTable, rawTable.rows[14]!, 'Code')).toBe('')
expect(cell(rawTable, rawTable.rows[17]!, 'ItemType')).toBe('Not Used')
expect(cell(rawTable, rawTable.rows[17]!, 'Code')).toBe('')
expect(cell(rawTable, rawTable.rows[23]!, 'ItemType')).toBe('Not Used')
expect(cell(rawTable, rawTable.rows[23]!, 'Code')).toBe('')
// Index 58: Expansion separator row
expect(cell(rawTable, rawTable.rows[58]!, 'ItemType')).toBe('Expansion')
expect(cell(rawTable, rawTable.rows[58]!, 'Code')).toBe('')
}
expect(table.types.length).toBe(98)
expect(table.byCode.size).toBe(98)
// Ensure placeholders and separator are not in parsed types
expect(table.byCode.has('')).toBe(false)
expect(table.byCode.has('None')).toBe(false)
expect(table.byCode.has('Not Used')).toBe(false)
expect(table.byCode.has('Expansion')).toBe(false)
})
it('indexes rows by code with case-insensitive and trimmed lookup', () => {
const shie = table.byCode.get('shie')
expect(shie).toBeDefined()
expect(shie?.name).toBe('Shield')
// Case-insensitive & trimmed lookups
expect(table.byCode.get('SHIE')).toBe(shie)
expect(table.byCode.get(' shie ')).toBe(shie)
expect(table.byCode.get('ShIe')).toBe(shie)
expect(table.byCode.has('SHIE')).toBe(true)
expect(table.byCode.has(' shie ')).toBe(true)
expect(table.byCode.has('nonexistent')).toBe(false)
})
it('asserts exactly 5 rows have treasureClass === 1 (TreasureClass seeds)', () => {
const tcSeeds = table.types.filter(t => t.treasureClass === 1)
expect(tcSeeds.length).toBe(5)
const seedCodes = tcSeeds.map(t => t.code)
expect(seedCodes).toEqual(['bow', 'weap', 'mele', 'armo', 'abow'])
const seedNames = tcSeeds.map(t => t.name)
expect(seedNames).toEqual(['Bow', 'Weapon', 'Melee Weapon', 'Any Armor', 'Amazon Bow'])
// All remaining 93 types must have treasureClass === 0
const nonSeeds = table.types.filter(t => t.treasureClass === 0)
expect(nonSeeds.length).toBe(93)
})
it('maps all ItemTypeDefinition fields accurately', () => {
const shie = table.byCode.get('shie')!
expect(shie.code).toBe('shie')
expect(shie.name).toBe('Shield')
expect(shie.equiv1).toBe('shld')
expect(shie.equiv2).toBe('')
expect(shie.treasureClass).toBe(0)
expect(shie.rarity).toBe(3)
expect(shie.maxSock1).toBe(3)
expect(shie.maxSock25).toBe(3)
expect(shie.maxSock40).toBe(4)
expect(shie.magic).toBe(false)
expect(shie.rare).toBe(true)
expect(shie.normal).toBe(false)
expect(shie.charm).toBe(false)
expect(shie.gem).toBe(false)
expect(shie.body).toBe(true)
expect(shie.bodyLoc1).toBe('rarm')
expect(shie.bodyLoc2).toBe('larm')
expect(shie.beltable).toBe(false)
expect(shie.class).toBe('')
// StaffMods test (Paladin scepters, Necromancer wands, Sorceress staves)
const scep = table.byCode.get('scep')!
expect(scep.staffMods).toBe('pal')
expect(scep.rarity).toBe(1)
expect(scep.maxSock1).toBe(3)
expect(scep.maxSock25).toBe(5)
expect(scep.maxSock40).toBe(6)
const wand = table.byCode.get('wand')!
expect(wand.staffMods).toBe('nec')
const staf = table.byCode.get('staf')!
expect(staf.staffMods).toBe('sor')
// Class-specific items
const amaz = table.byCode.get('amaz')!
expect(amaz.class).toBe('ama')
const barb = table.byCode.get('barb')!
expect(barb.class).toBe('bar')
// Beltable potions
const poti = table.byCode.get('poti')!
expect(poti.beltable).toBe(true)
expect(poti.body).toBe(false)
// Charms & gems
const char = table.byCode.get('char')!
expect(char.charm).toBe(true)
expect(char.magic).toBe(true)
const gem = table.byCode.get('gem')!
expect(gem.gem).toBe(true)
})
describe('Type inheritance check (isA)', () => {
it('verifies direct inheritance: shie -> shld', () => {
expect(isA('shie', 'shld', table)).toBe(true)
})
it('verifies melee to weapon inheritance: mele -> weap', () => {
expect(isA('mele', 'weap', table)).toBe(true)
})
it('verifies wand/rod inheritance: wand -> rod', () => {
expect(isA('wand', 'rod', table)).toBe(true)
expect(isA('scep', 'rod', table)).toBe(true)
expect(isA('staf', 'rod', table)).toBe(true)
})
it('verifies multi-hop inheritance chain: abow -> bow -> miss (and abow -> weap)', () => {
expect(isA('abow', 'bow', table)).toBe(true)
expect(isA('bow', 'miss', table)).toBe(true)
expect(isA('abow', 'miss', table)).toBe(true)
expect(isA('miss', 'weap', table)).toBe(true)
expect(isA('abow', 'weap', table)).toBe(true)
})
it('verifies Equiv2 multi-parent inheritance: abow -> amaz -> clas', () => {
expect(isA('abow', 'amaz', table)).toBe(true)
expect(isA('amaz', 'clas', table)).toBe(true)
expect(isA('abow', 'clas', table)).toBe(true)
})
it('verifies diamond inheritance: tkni -> comb -> mele/thro -> weap', () => {
expect(isA('tkni', 'comb', table)).toBe(true)
expect(isA('tkni', 'knif', table)).toBe(true)
expect(isA('tkni', 'mele', table)).toBe(true)
expect(isA('tkni', 'thro', table)).toBe(true)
expect(isA('tkni', 'weap', table)).toBe(true)
})
it('verifies reflexive inheritance (typeCode === ancestorCode)', () => {
expect(isA('shie', 'shie', table)).toBe(true)
expect(isA('weap', 'weap', table)).toBe(true)
expect(isA('armo', 'armo', table)).toBe(true)
})
it('correctly rejects non-ancestor relationships', () => {
expect(isA('shie', 'weap', table)).toBe(false)
expect(isA('bow', 'armo', table)).toBe(false)
expect(isA('weap', 'mele', table)).toBe(false) // Parent is not a descendant of child
expect(isA('shld', 'shie', table)).toBe(false)
expect(isA('armo', 'shie', table)).toBe(false)
})
it('handles empty or unknown codes gracefully without throwing', () => {
expect(isA('', 'weap', table)).toBe(false)
expect(isA('shie', '', table)).toBe(false)
expect(isA('', '', table)).toBe(false)
expect(isA('unknown', 'weap', table)).toBe(false)
expect(isA('shie', 'unknown', table)).toBe(false)
expect(isA('unknown', 'unknown', table)).toBe(false)
})
it('supports case-insensitive and whitespace-padded arguments', () => {
expect(isA('SHIE', 'shld', table)).toBe(true)
expect(isA('shie', 'SHLD', table)).toBe(true)
expect(isA(' abow ', ' WEAP ', table)).toBe(true)
expect(isA('WAND', 'ROD', table)).toBe(true)
})
it('memoizes results in table._isACache', () => {
const cache = table._isACache
expect(cache).toBeDefined()
// Query and check cache entry
const result = isA('wand', 'rod', table)
expect(result).toBe(true)
expect(cache?.get('wand:rod')).toBe(true)
// Negative query memoized
const negResult = isA('wand', 'armo', table)
expect(negResult).toBe(false)
expect(cache?.get('wand:armo')).toBe(false)
})
it('safely handles circular inheritance chains without infinite loop', () => {
const cyclicTable: ItemTypeTable = {
types: [
{
code: 'cycA',
name: 'Cyclic A',
equiv1: 'cycB',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 0,
maxSock25: 0,
maxSock40: 0,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: false,
bodyLoc1: '',
bodyLoc2: '',
beltable: false,
class: '',
},
{
code: 'cycB',
name: 'Cyclic B',
equiv1: 'cycA',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 0,
maxSock25: 0,
maxSock40: 0,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: false,
bodyLoc1: '',
bodyLoc2: '',
beltable: false,
class: '',
},
{
code: 'target',
name: 'Target',
equiv1: '',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 0,
maxSock25: 0,
maxSock40: 0,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: false,
bodyLoc1: '',
bodyLoc2: '',
beltable: false,
class: '',
},
],
byCode: new Map([
['cyca', {
code: 'cycA',
name: 'Cyclic A',
equiv1: 'cycB',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 0,
maxSock25: 0,
maxSock40: 0,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: false,
bodyLoc1: '',
bodyLoc2: '',
beltable: false,
class: '',
}],
['cycb', {
code: 'cycB',
name: 'Cyclic B',
equiv1: 'cycA',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 0,
maxSock25: 0,
maxSock40: 0,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: false,
bodyLoc1: '',
bodyLoc2: '',
beltable: false,
class: '',
}],
['target', {
code: 'target',
name: 'Target',
equiv1: '',
equiv2: '',
treasureClass: 0,
rarity: 0,
maxSock1: 0,
maxSock25: 0,
maxSock40: 0,
magic: false,
rare: false,
normal: false,
charm: false,
gem: false,
staffMods: '',
body: false,
bodyLoc1: '',
bodyLoc2: '',
beltable: false,
class: '',
}],
]),
}
// Should find direct parent
expect(isA('cycA', 'cycB', cyclicTable)).toBe(true)
expect(isA('cycB', 'cycA', cyclicTable)).toBe(true)
// Should safely terminate and return false when target is not reached
expect(isA('cycA', 'target', cyclicTable)).toBe(false)
expect(isA('cycB', 'target', cyclicTable)).toBe(false)
})
})
})
/**
* Creates a minimal fallback D2Table for environments where MPQ is missing.
*/
function createMinimalFallbackTable(): D2Table {
const header = [
'ItemType', 'Code', 'Equiv1', 'Equiv2', 'Repair', 'Body',
'BodyLoc1', 'BodyLoc2', 'Shoots', 'Quiver', 'Throwable', 'Reload',
'ReEquip', 'AutoStack', 'Magic', 'Rare', 'Normal', 'Charm',
'Gem', 'Beltable', 'MaxSock1', 'MaxSock25', 'MaxSock40', 'TreasureClass',
'Rarity', 'StaffMods', 'CostFormula', 'Class', 'VarInvGfx', 'InvGfx1',
'InvGfx2', 'InvGfx3', 'InvGfx4', 'InvGfx5', 'InvGfx6', 'StorePage',
'*eol',
]
// Filtered rows + key types
const rows: string[][] = [
// 0, 1: None placeholders
['None', '', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '0'],
['None', '', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '0'],
['Shield', 'shie', 'shld', '', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '3', '3', '4', '0', '3', '', '', '', '', '', '', '', '', '', '', 'armo', '0'],
['Armor', 'tors', 'armo', '', '1', '1', 'tors', 'tors', '', '', '', '', '', '', '', '1', '', '', '', '', '3', '4', '6', '0', '3', '', '', '', '', '', '', '', '', '', '', 'armo', '0'],
['Gold', 'gold', 'misc', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '1', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'misc', '0'],
// 14: Not Used placeholder
['Not Used', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '3', '', '', '', '', '', '', '', '', '', '', '', '0'],
['Scepter', 'scep', 'rod', '', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '3', '5', '6', '0', '1', 'pal', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Wand', 'wand', 'rod', '', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '2', '2', '2', '0', '1', 'nec', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Staff', 'staf', 'rod', '', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '5', '6', '6', '0', '1', 'sor', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Bow', 'bow', 'miss', '', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '3', '4', '6', '1', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Weapon', 'weap', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '1', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Melee Weapon', 'mele', 'weap', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '1', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Missile Weapon', 'miss', 'weap', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Thrown Weapon', 'thro', 'weap', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Combo Weapon', 'comb', 'mele', 'thro', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Any Armor', 'armo', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '1', '3', '', '', '', '', '', '', '', '', '', '', 'armo', '0'],
['Any Shield', 'shld', 'armo', 'seco', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'armo', '0'],
['Second Hand', 'seco', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'misc', '0'],
['Staves And Rods', 'rod', 'blun', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Blunt', 'blun', 'mele', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
// 58: Expansion separator
['Expansion', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0'],
['Class Specific', 'clas', '', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'misc', '0'],
['Amazon Item', 'amaz', 'clas', '', '0', '0', '', '', '', '', '', '', '', '', '', '1', '', '', '', '', '0', '0', '0', '0', '1', '', '', 'ama', '', '', '', '', '', '', '', 'misc', '0'],
['Barbarian Item', 'barb', 'clas', '', '0', '0', '', '', '', '', '', '', '', '', '', '1', '', '', '', '', '0', '0', '0', '0', '1', '', '', 'bar', '', '', '', '', '', '', '', 'misc', '0'],
['Potion', 'poti', 'misc', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '', '1', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'misc', '0'],
['Charm', 'char', 'misc', '', '0', '0', '', '', '', '', '', '', '', '', '1', '', '', '1', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'misc', '0'],
['Gem', 'gem', 'sock', '', '0', '0', '', '', '', '', '', '', '', '', '', '', '', '', '1', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'misc', '0'],
['Amazon Bow', 'abow', 'bow', 'amaz', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '3', '4', '5', '1', '1', '', '', 'ama', '', '', '', '', '', '', '', 'weap', '0'],
['Throwing Knife', 'tkni', 'comb', 'knif', '1', '1', 'rarm', 'larm', '', '', '1', '1', '1', '1', '', '1', '', '', '', '', '0', '0', '0', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
['Knife', 'knif', 'mele', '', '1', '1', 'rarm', 'larm', '', '', '', '', '', '', '', '1', '', '', '', '', '2', '2', '3', '0', '3', '', '', '', '', '', '', '', '', '', '', 'weap', '0'],
]
return { header, rows }
}

View File

@ -109,28 +109,21 @@ function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[]
}
describe('Diablo II DRLG Maze Special Passes', () => {
// 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)

348
tests/maze-specials.test.ts Normal file
View File

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