import { describe, it, expect, beforeAll } from 'vitest' import * as fs from 'fs' import { parseRunesTable, loadRunewords, checkRunewordMatch, getRunewordDisplayName, normalizeRuneCode, parseSocketedRunes, isNormalQuality, getBaseItemSocketCount, getItemTypeCodes, CANONICAL_RUNES_TABLE, RUNES_TABLE_PATH, RUNE_NAME_TO_CODE, RUNE_CODE_TO_NAME, type RunewordTable, type RunewordDefinition, type RunewordMatchResult, } from '../src/game/runeword.ts' import { parseTable, type D2Table } from '../src/game/acts.ts' import { ItemQuality, type Item, type ItemBase } from '../src/game/items.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { decodeTbl } from '../src/formats/tbl.ts' const hasD2 = fs.existsSync('samples/d2/d2exp.mpq') describe('Runes.txt Loading and Runeword Assembly (Issue #105)', () => { const shieldBase: ItemBase = { id: 'kit', name: 'Kite Shield', kind: 'armor', invWidth: 2, invHeight: 3, maxStack: 1, value: 100, damage: 0, defense: 18, tags: ['shld'], level: 15, } const swordBase: ItemBase = { id: 'crs', name: 'Crystal Sword', kind: 'weapon', invWidth: 2, invHeight: 3, maxStack: 1, value: 250, damage: 15, defense: 0, tags: ['swor', 'weap'], level: 11, } const armorBase: ItemBase = { id: 'fld', name: 'Field Plate', kind: 'armor', invWidth: 2, invHeight: 3, maxStack: 1, value: 400, damage: 0, defense: 105, tags: ['tors', 'armo'], level: 15, } function createTestShield(totalSockets = 3, quality = ItemQuality.NORMAL, extra: Partial = {}): Item { return { base: shieldBase, prefix: null, suffix: null, level: 25, name: 'Kite Shield', stats: {}, invWidth: 2, invHeight: 3, stack: 1, value: 100, totalSockets, quality, ...extra, } } describe('Table Parsing & Counts (1.13c Gold Standard)', () => { it('parses exactly 169 rows, 78 complete runewords, and 23 ladder runewords from canonical data', () => { const table = CANONICAL_RUNES_TABLE expect(table.all.length).toBe(169) expect(table.rows.length).toBe(169) expect(table.length).toBe(169) expect(table.complete.length).toBe(78) expect(table.ladder.length).toBe(23) // All ladder runewords should be in the complete list for (const ladderItem of table.ladder) { expect(ladderItem.server).toBe(true) expect(ladderItem.ladder).toBe(true) expect(ladderItem.complete).toBe(true) expect(ladderItem.enabled).toBe(true) } // 55 non-ladder complete runewords (78 - 23) const nonLadderComplete = table.complete.filter(r => !r.server) expect(nonLadderComplete.length).toBe(55) }) it('parses row 0 Ancient\'s Pledge properly', () => { const ap = CANONICAL_RUNES_TABLE.get('Runeword1') expect(ap).toBeDefined() expect(ap?.name).toBe('Runeword1') expect(ap?.runeName).toBe("Ancient's Pledge") expect(ap?.complete).toBe(true) expect(ap?.server).toBe(false) expect(ap?.runewordId).toBe(1) expect(ap?.itypes).toEqual(['shld']) expect(ap?.etypes).toEqual([]) expect(ap?.runes).toEqual(['r08', 'r09', 'r07']) // Ral, Ort, Tal expect(ap?.t1Codes.length).toBe(4) expect(ap?.t1Codes[0]).toEqual({ code: 'res-cold', min: 30, max: 30 }) expect(ap?.t1Codes[1]).toEqual({ code: 'res-all', min: 13, max: 13 }) expect(ap?.t1Codes[2]).toEqual({ code: 'ac%', min: 50, max: 50 }) expect(ap?.t1Codes[3]).toEqual({ code: 'dmg-to-mana', min: 10, max: 10 }) expect(ap?.stats['res-cold']).toBe(30) expect(ap?.stats['res-all']).toBe(13) expect(ap?.stats['ac%']).toBe(50) expect(ap?.stats['dmg-to-mana']).toBe(10) }) it('parses ladder runeword Spirit (Runeword130, row 49)', () => { const spirit = CANONICAL_RUNES_TABLE.get("Spirit") expect(spirit).toBeDefined() expect(spirit?.name).toBe('Runeword130') expect(spirit?.server).toBe(true) expect(spirit?.ladder).toBe(true) expect(spirit?.complete).toBe(true) expect(spirit?.itypes).toEqual(['swor', 'shld']) expect(spirit?.runes).toEqual(['r07', 'r10', 'r09', 'r11']) // Tal, Thul, Ort, Amn }) it('parses non-empty params in modifiers (e.g. charged skill on Black / Runeword6)', () => { const black = CANONICAL_RUNES_TABLE.get('Black') expect(black).toBeDefined() expect(black?.name).toBe('Runeword6') const chargedProp = black?.t1Codes.find(p => p.code === 'charged') expect(chargedProp).toBeDefined() expect(chargedProp?.param).toBe('74') expect(chargedProp?.min).toBe(12) expect(chargedProp?.max).toBe(4) }) }) describe('Rune Normalization and Parsing Helpers', () => { it('normalizes rune names and codes to standard r01..r33 codes', () => { expect(normalizeRuneCode('Ral')).toBe('r08') expect(normalizeRuneCode('ral')).toBe('r08') expect(normalizeRuneCode('Ral Rune')).toBe('r08') expect(normalizeRuneCode('r08')).toBe('r08') expect(normalizeRuneCode('Ort')).toBe('r09') expect(normalizeRuneCode('ort')).toBe('r09') expect(normalizeRuneCode('Tal')).toBe('r07') expect(normalizeRuneCode('tal')).toBe('r07') expect(normalizeRuneCode('Zod')).toBe('r33') expect(normalizeRuneCode('El')).toBe('r01') expect(normalizeRuneCode('r1')).toBe('r01') }) it('parses arrays and concatenated rune strings like RalOrtTal', () => { expect(parseSocketedRunes(['Ral', 'Ort', 'Tal'])).toEqual(['Ral', 'Ort', 'Tal']) expect(parseSocketedRunes(['r08', 'r09', 'r07'])).toEqual(['r08', 'r09', 'r07']) expect(parseSocketedRunes('RalOrtTal')).toEqual(['Ral', 'Ort', 'Tal']) expect(parseSocketedRunes(['RalOrtTal'])).toEqual(['Ral', 'Ort', 'Tal']) expect(parseSocketedRunes('TalThulOrtAmn')).toEqual(['Tal', 'Thul', 'Ort', 'Amn']) expect(parseSocketedRunes('JahIthBer')).toEqual(['Jah', 'Ith', 'Ber']) }) it('verifies RUNE_NAME_TO_CODE and RUNE_CODE_TO_NAME tables', () => { expect(Object.keys(RUNE_NAME_TO_CODE).length).toBe(33) expect(Object.keys(RUNE_CODE_TO_NAME).length).toBe(33) expect(RUNE_NAME_TO_CODE['ber']).toBe('r30') expect(RUNE_CODE_TO_NAME['r30']).toBe('Ber') }) }) describe('Quality and Socket Extraction Helpers', () => { it('identifies normal, superior, and low quality items as valid', () => { const normalItem = createTestShield(3, ItemQuality.NORMAL) const superiorItem = createTestShield(3, ItemQuality.SUPERIOR) const lowItem = createTestShield(3, ItemQuality.LOW) expect(isNormalQuality(normalItem)).toBe(true) expect(isNormalQuality(superiorItem)).toBe(true) expect(isNormalQuality(lowItem)).toBe(true) }) it('rejects magic, rare, set, unique, crafted, and tempered items', () => { expect(isNormalQuality(createTestShield(3, ItemQuality.MAGIC))).toBe(false) expect(isNormalQuality(createTestShield(3, ItemQuality.RARE))).toBe(false) expect(isNormalQuality(createTestShield(3, ItemQuality.SET))).toBe(false) expect(isNormalQuality(createTestShield(3, ItemQuality.UNIQUE))).toBe(false) expect(isNormalQuality(createTestShield(3, ItemQuality.CRAFTED))).toBe(false) expect(isNormalQuality(createTestShield(3, ItemQuality.TEMPERED))).toBe(false) const affixItem = createTestShield(3, ItemQuality.NORMAL, { prefix: { id: 'test', name: 'Sturdy', kind: 'prefix', level: 1, itemTypes: [], modifiers: [] }, }) expect(isNormalQuality(affixItem)).toBe(false) const rarityItem = createTestShield(3, undefined, { rarity: 'magic' }) expect(isNormalQuality(rarityItem)).toBe(false) }) it('extracts socket count from various item representations', () => { expect(getBaseItemSocketCount({ ...shieldBase, totalSockets: 3 } as any)).toBe(3) expect(getBaseItemSocketCount({ ...shieldBase, sockets: 4 } as any)).toBe(4) expect(getBaseItemSocketCount({ ...shieldBase, stats: { sockets: 2 } } as any)).toBe(2) expect(getBaseItemSocketCount({ base: { ...shieldBase, totalSockets: 5 } } as any)).toBe(5) expect(getBaseItemSocketCount(createTestShield(3))).toBe(3) }) it('extracts item type codes from item and base tags', () => { const shield = createTestShield(3) const codes = getItemTypeCodes(shield) expect(codes).toContain('shld') expect(codes).toContain('kit') expect(codes).toContain('armo') }) }) describe('Runeword Assembly: checkRunewordMatch', () => { const isAIdentity = (type: string, target: string) => type.toLowerCase() === target.toLowerCase() it('valid assembly: Ancient\'s Pledge (RalOrtTal in 3-socket shield)', () => { const shield = createTestShield(3, ItemQuality.NORMAL) const runes = ['Ral', 'Ort', 'Tal'] const match = checkRunewordMatch(shield, runes, isAIdentity, CANONICAL_RUNES_TABLE) expect(match).toBeDefined() expect(match?.name).toBe('Runeword1') expect(match?.runeName).toBe("Ancient's Pledge") expect(match?.runewordId).toBe(1) expect(match?.complete).toBe(true) expect(match?.server).toBe(false) expect(match?.runes).toEqual(['r08', 'r09', 'r07']) // Aggregated stats from T1Code1..4 expect(match?.stats['res-cold']).toBe(30) expect(match?.stats['res-all']).toBe(13) expect(match?.stats['ac%']).toBe(50) expect(match?.stats['dmg-to-mana']).toBe(10) }) it('valid assembly with item codes (r08, r09, r07)', () => { const shield = createTestShield(3, ItemQuality.NORMAL) const match = checkRunewordMatch(shield, ['r08', 'r09', 'r07'], isAIdentity, CANONICAL_RUNES_TABLE) expect(match).toBeDefined() expect(match?.name).toBe('Runeword1') }) it('valid assembly with concatenated string (RalOrtTal)', () => { const shield = createTestShield(3, ItemQuality.NORMAL) const match = checkRunewordMatch(shield, ['RalOrtTal'], isAIdentity, CANONICAL_RUNES_TABLE) expect(match).toBeDefined() expect(match?.name).toBe('Runeword1') }) it('socket count mismatch rejects (e.g. 2 runes in 3-socket base)', () => { const shield3 = createTestShield(3, ItemQuality.NORMAL) // Only 2 runes in 3-socket base const matchUnder = checkRunewordMatch(shield3, ['Ral', 'Ort'], isAIdentity, CANONICAL_RUNES_TABLE) expect(matchUnder).toBeUndefined() // 4 runes in 3-socket base const matchOver = checkRunewordMatch(shield3, ['Ral', 'Ort', 'Tal', 'Thul'], isAIdentity, CANONICAL_RUNES_TABLE) expect(matchOver).toBeUndefined() // 3 runes in 4-socket base const shield4 = createTestShield(4, ItemQuality.NORMAL) const match4 = checkRunewordMatch(shield4, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE) expect(match4).toBeUndefined() }) it('rune sequence mismatch rejects', () => { const shield = createTestShield(3, ItemQuality.NORMAL) // Wrong sequence (Tal, Ort, Ral instead of Ral, Ort, Tal) const match1 = checkRunewordMatch(shield, ['Tal', 'Ort', 'Ral'], isAIdentity, CANONICAL_RUNES_TABLE) expect(match1).toBeUndefined() // Wrong sequence (Ral, Tal, Ort) const match2 = checkRunewordMatch(shield, ['Ral', 'Tal', 'Ort'], isAIdentity, CANONICAL_RUNES_TABLE) expect(match2).toBeUndefined() // Completely wrong runes const match3 = checkRunewordMatch(shield, ['El', 'Eld', 'Tir'], isAIdentity, CANONICAL_RUNES_TABLE) expect(match3).toBeUndefined() }) it('quality mismatch rejects (e.g. magic base with sockets)', () => { const magicShield = createTestShield(3, ItemQuality.MAGIC) expect(checkRunewordMatch(magicShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() const rareShield = createTestShield(3, ItemQuality.RARE) expect(checkRunewordMatch(rareShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() const setShield = createTestShield(3, ItemQuality.SET) expect(checkRunewordMatch(setShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() const uniqueShield = createTestShield(3, ItemQuality.UNIQUE) expect(checkRunewordMatch(uniqueShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() const craftedShield = createTestShield(3, ItemQuality.CRAFTED) expect(checkRunewordMatch(craftedShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() }) it('item type mismatch rejects (e.g. RalOrtTal in 3-socket sword or armor)', () => { const swordItem: Item = { base: swordBase, prefix: null, suffix: null, level: 25, name: 'Crystal Sword', stats: {}, invWidth: 2, invHeight: 3, stack: 1, value: 250, totalSockets: 3, quality: ItemQuality.NORMAL, } expect(checkRunewordMatch(swordItem, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() const armorItem: Item = { base: armorBase, prefix: null, suffix: null, level: 25, name: 'Field Plate', stats: {}, invWidth: 2, invHeight: 3, stack: 1, value: 400, totalSockets: 3, quality: ItemQuality.NORMAL, } expect(checkRunewordMatch(armorItem, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() }) it('item type inheritance via isA allows Paladin shield (pash -> shld)', () => { const palaShieldBase: ItemBase = { id: 'pa1', name: 'Targe', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 150, damage: 0, defense: 14, tags: ['pash'], // Paladin shield sub-type level: 3, } const palaShield: Item = { base: palaShieldBase, prefix: null, suffix: null, level: 20, name: 'Targe', stats: {}, invWidth: 2, invHeight: 2, stack: 1, value: 150, totalSockets: 3, quality: ItemQuality.NORMAL, } // Without inheritance: fails expect(checkRunewordMatch(palaShield, ['Ral', 'Ort', 'Tal'], isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() // With isA inheritance: pash is a shld const isAWithPala = (type: string, target: string) => { if (type === 'pash' && target === 'shld') return true return type === target } const match = checkRunewordMatch(palaShield, ['Ral', 'Ort', 'Tal'], isAWithPala, CANONICAL_RUNES_TABLE) expect(match).toBeDefined() expect(match?.name).toBe('Runeword1') }) it('handles excluded item types (etypes)', () => { // Create a synthetic table with an excluded type const rawTable: D2Table = { header: [ 'Name', 'Rune Name', 'complete', 'server', 'itype1', 'itype2', 'itype3', 'itype4', 'itype5', 'itype6', 'etype1', 'etype2', 'etype3', '*runes', 'Rune1', 'Rune2', 'Rune3', 'Rune4', 'Rune5', 'Rune6', 'T1Code1', 'T1Param1', 'T1Min1', 'T1Max1', 'eol', ], rows: [ [ 'Runeword999', 'Test Word', '1', '', 'weap', '', '', '', '', '', 'swor', '', '', '', 'r01', 'r02', '', '', '', '', 'dmg%', '', '50', '50', '0', ], ], } const testTable = parseRunesTable(rawTable) const swordItem: Item = { base: swordBase, prefix: null, suffix: null, level: 10, name: 'Crystal Sword', stats: {}, invWidth: 2, invHeight: 3, stack: 1, value: 200, totalSockets: 2, quality: ItemQuality.NORMAL, } // Should be excluded because sword has tag 'swor', matching etype1 'swor' expect(checkRunewordMatch(swordItem, ['El', 'Eld'], isAIdentity, testTable)).toBeUndefined() // An axe (tags: ['axe', 'weap']) is not excluded const axeBase: ItemBase = { id: 'hax', name: 'Hand Axe', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 100, damage: 5, defense: 0, tags: ['axe', 'weap'], level: 3, } const axeItem: Item = { base: axeBase, prefix: null, suffix: null, level: 10, name: 'Hand Axe', stats: {}, invWidth: 1, invHeight: 3, stack: 1, value: 100, totalSockets: 2, quality: ItemQuality.NORMAL, } const matchAxe = checkRunewordMatch(axeItem, ['El', 'Eld'], isAIdentity, testTable) expect(matchAxe).toBeDefined() expect(matchAxe?.name).toBe('Runeword999') }) it('enforces ladder-only restriction (server === 1)', () => { // Spirit (Runeword130, server === 1, TalThulOrtAmn in 4-socket sword) const sword4: Item = { base: swordBase, prefix: null, suffix: null, level: 30, name: 'Crystal Sword', stats: {}, invWidth: 2, invHeight: 3, stack: 1, value: 500, totalSockets: 4, quality: ItemQuality.NORMAL, } const spiritRunes = ['Tal', 'Thul', 'Ort', 'Amn'] // In non-ladder or without options: rejected expect(checkRunewordMatch(sword4, spiritRunes, isAIdentity, CANONICAL_RUNES_TABLE)).toBeUndefined() expect(checkRunewordMatch(sword4, spiritRunes, isAIdentity, CANONICAL_RUNES_TABLE, { isLadder: false })).toBeUndefined() // In ladder mode: accepted const match = checkRunewordMatch(sword4, spiritRunes, isAIdentity, CANONICAL_RUNES_TABLE, { isLadder: true }) expect(match).toBeDefined() expect(match?.name).toBe('Runeword130') expect(match?.runeName).toBe('Spirit') expect(match?.server).toBe(true) }) }) describe('Display Name Resolution: Name Key to string.tbl Key', () => { it('display name uses Name key (Runeword26 -> string.tbl key)', () => { const doom = CANONICAL_RUNES_TABLE.get('Runeword26') expect(doom).toBeDefined() expect(doom?.name).toBe('Runeword26') expect(doom?.runewordId).toBe(26) // Mock string table representing expansionstring.tbl const mockStringTable = new Map([ ['Runeword1', "Ancients' Pledge"], ['Runeword26', 'Doom'], ['Runeword33', 'Enigma'], ['Runeword130', 'Spirit'], ]) expect(getRunewordDisplayName(doom!, mockStringTable)).toBe('Doom') expect(getRunewordDisplayName('Runeword26', mockStringTable)).toBe('Doom') const ap = CANONICAL_RUNES_TABLE.get('Runeword1')! expect(getRunewordDisplayName(ap, mockStringTable)).toBe("Ancients' Pledge") // Fallback when string table is not provided expect(getRunewordDisplayName(doom!)).toBe('Doomsayer') // fallback to runeName expect(getRunewordDisplayName('UnknownKey')).toBe('UnknownKey') }) }) describe.skipIf(!hasD2)('1.13c MPQ Integration Tests', () => { let archives: MountedArchives let mpqTable: RunewordTable beforeAll(async () => { 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}`))) } mpqTable = await loadRunewords(archives) }) it('verifies RUNES_TABLE_PATH constant', () => { expect(RUNES_TABLE_PATH).toBe('data\\global\\excel\\Runes.txt') }) it('loads exactly 169 rows, 78 complete, and 23 ladder runewords from MPQ', () => { expect(mpqTable.all.length).toBe(169) expect(mpqTable.complete.length).toBe(78) expect(mpqTable.ladder.length).toBe(23) }) it('verifies exact parity between MPQ parsed table and CANONICAL_RUNES_TABLE', () => { for (let i = 0; i < mpqTable.all.length; i++) { const a = mpqTable.all[i]! const b = CANONICAL_RUNES_TABLE.all[i]! expect(a.name).toBe(b.name) expect(a.runeName).toBe(b.runeName) expect(a.complete).toBe(b.complete) expect(a.server).toBe(b.server) expect(a.runes).toEqual(b.runes) expect(a.itypes).toEqual(b.itypes) expect(a.etypes).toEqual(b.etypes) expect(a.t1Codes).toEqual(b.t1Codes) expect(a.stats).toEqual(b.stats) } }) it('resolves real expansionstring.tbl display names for Runeword26 and Runeword1', async () => { const tblBytes = await archives.read('data\\local\\lng\\eng\\expansionstring.tbl') const tbl = decodeTbl(tblBytes) const doom = mpqTable.get('Runeword26')! expect(getRunewordDisplayName(doom, tbl)).toBe('Doom') const ap = mpqTable.get('Runeword1')! expect(getRunewordDisplayName(ap, tbl)).toBe("Ancients' Pledge") const enigma = mpqTable.get('Runeword33')! expect(getRunewordDisplayName(enigma, tbl)).toBe('Enigma') }) }) })