Compare commits
No commits in common. "2a4ef21ce60c63523f3d069348c22646dff7ce91" and "4213cff80b0ac363d32114986a2978920f02cb1a" have entirely different histories.
2a4ef21ce6
...
4213cff80b
|
|
@ -1,291 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
@ -121,27 +121,6 @@ export const LVLPREST_ACT5_TEMPLE_REMAPPING_BASE = 1044
|
||||||
export const LVLPREST_ACT5_TEMPLE_FINAL_ROOM = 1059
|
export const LVLPREST_ACT5_TEMPLE_FINAL_ROOM = 1059
|
||||||
export const LVLPREST_ACT5_TEMPLE_NIHLATHAK_WING_BASE = 1060
|
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`).
|
* 1.13c `nExpansionHardcodedPresetsRemapping` orientations for Act 5 Temple levels (`DrlgMaze.cpp`).
|
||||||
*/
|
*/
|
||||||
|
|
@ -162,8 +141,6 @@ 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_A]: { name: 'Act 5 - Ice Pool A', kind: 'quest' },
|
||||||
[LVLPREST_ACT5_ICE_POOL_B]: { name: 'Act 5 - Ice Pool B', 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_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' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -846,37 +846,9 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
||||||
arcane: true,
|
arcane: true,
|
||||||
specials: [{ kind: 'quest', entries: entries() }],
|
specials: [{ kind: 'quest', entries: entries() }],
|
||||||
},
|
},
|
||||||
{
|
{ levelTypeName: 'Act 2 - Harem', ring: 2, grow: false, theme: false, specials: [] },
|
||||||
levelTypeName: 'Act 2 - Harem',
|
{ levelTypeName: 'Act 2 - Basement', ring: 2, grow: false, theme: false, specials: [] },
|
||||||
ring: 2,
|
{ levelTypeName: 'Act 3 - Spider', ring: 2, grow: false, theme: false, specials: [] },
|
||||||
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',
|
levelTypeName: 'Act 3 - Kurast',
|
||||||
ring: 2,
|
ring: 2,
|
||||||
|
|
@ -1015,16 +987,7 @@ export const MAZE_LEVEL_TYPE_PROFILES: readonly MazeLevelProfile[] = [
|
||||||
],
|
],
|
||||||
specialPass: 'DRLGMAZE_PlaceAct5BaalStuff',
|
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] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,460 +0,0 @@
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
@ -109,21 +109,28 @@ function createStandardPieceSet(levelTypeName: string, extraPieces: MazePiece[]
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Diablo II DRLG Maze Special Passes', () => {
|
describe('Diablo II DRLG Maze Special Passes', () => {
|
||||||
test('UNIMPLEMENTED_PASSES is empty when all maze profiles define specials', () => {
|
// 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', () => {
|
||||||
const emptySpecials = MAZE_LEVEL_TYPE_PROFILES
|
const emptySpecials = MAZE_LEVEL_TYPE_PROFILES
|
||||||
.filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined)
|
.filter(profile => profile.specials.length === 0 && profile.singleRoom === undefined)
|
||||||
.map(profile => profile.levelTypeName)
|
.map(profile => profile.levelTypeName)
|
||||||
expect(emptySpecials).toEqual([])
|
expect(emptySpecials.length).toBeGreaterThan(0)
|
||||||
expect(UNIMPLEMENTED_PASSES).toEqual([])
|
expect(UNIMPLEMENTED_PASSES).toEqual(
|
||||||
|
emptySpecials.map(name => `DRLGMAZE_GenerateLevel specials table (${name})`),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unimplementedPassesFor is empty for a fully transcribed level type', () => {
|
test('unimplementedPassesFor is empty for a fully transcribed level type', () => {
|
||||||
expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([])
|
expect(unimplementedPassesFor('Act 1 - Cave')).toEqual([])
|
||||||
expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([])
|
expect(unimplementedPassesFor('Act 2 - Tomb')).toEqual([])
|
||||||
expect(unimplementedPassesFor('Act 2 - Harem')).toEqual([])
|
})
|
||||||
expect(unimplementedPassesFor('Act 2 - Basement')).toEqual([])
|
|
||||||
expect(unimplementedPassesFor('Act 3 - Spider')).toEqual([])
|
test('unimplementedPassesFor reports the gap for a level type with no specials', () => {
|
||||||
expect(unimplementedPassesFor('Act 5 - Lava')).toEqual([])
|
expect(unimplementedPassesFor('Act 3 - Spider'))
|
||||||
|
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unimplementedPassesFor reports a level type that has no profile at all', () => {
|
test('unimplementedPassesFor reports a level type that has no profile at all', () => {
|
||||||
|
|
@ -131,7 +138,7 @@ describe('Diablo II DRLG Maze Special Passes', () => {
|
||||||
.toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)'])
|
.toEqual(['DRLGMAZE_GenerateLevel profile (Act 9 - Nowhere)'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('generateMaze reports empty unimplementedPasses for transcribed levels', () => {
|
test('generateMaze reports the level type gap, not a global constant', () => {
|
||||||
const spider = generateMaze({
|
const spider = generateMaze({
|
||||||
levelId: 84,
|
levelId: 84,
|
||||||
levelName: 'Spider Cavern',
|
levelName: 'Spider Cavern',
|
||||||
|
|
@ -141,7 +148,8 @@ describe('Diablo II DRLG Maze Special Passes', () => {
|
||||||
seed: 8484,
|
seed: 8484,
|
||||||
pieces: createStandardPieceSet('Act 3 - Spider'),
|
pieces: createStandardPieceSet('Act 3 - Spider'),
|
||||||
})
|
})
|
||||||
expect(spider.stats.unimplementedPasses).toEqual([])
|
expect(spider.stats.unimplementedPasses)
|
||||||
|
.toEqual(['DRLGMAZE_GenerateLevel specials table (Act 3 - Spider)'])
|
||||||
// The other two diagnostic channels must travel with it.
|
// The other two diagnostic channels must travel with it.
|
||||||
expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true)
|
expect(Array.isArray(spider.stats.unresolvedRoles)).toBe(true)
|
||||||
expect(Array.isArray(spider.stats.notes)).toBe(true)
|
expect(Array.isArray(spider.stats.notes)).toBe(true)
|
||||||
|
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue