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