feat(items): 加载 ItemTypes.txt 与 Equiv 继承判定 (Issue #89)
This commit is contained in:
parent
5d6dc8a15f
commit
5e5d897fbd
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
Loading…
Reference in New Issue