import { describe, expect, test } from 'vitest' import * as fs from 'node:fs' import { CANONICAL_SET_ITEMS_TABLE, CANONICAL_SET_ITEMS_TSV, CANONICAL_SETS_TABLE, CANONICAL_SETS_TSV, parseSetItemsTable, parseSetsTable, resolveActiveSetBonuses, loadSetData, parseParam, type SetItem, type SetDefinition, } from '../src/game/set-items.ts' import { parseTable } from '../src/game/acts.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { fileSource } from '../src/mpq/file-source.ts' const MPQ_DIR = 'samples/d2' const hasMpqs = fs.existsSync(`${MPQ_DIR}/d2data.mpq`) && fs.existsSync(`${MPQ_DIR}/d2exp.mpq`) describe('SetItems.txt & Sets.txt Table Structure & Parsing (Issue #98)', () => { test('CANONICAL_SET_ITEMS_TABLE loads exactly 127 rows', () => { expect(CANONICAL_SET_ITEMS_TABLE.length).toBe(127) expect(CANONICAL_SET_ITEMS_TABLE.entries.length).toBe(127) expect(CANONICAL_SET_ITEMS_TABLE.rows.length).toBe(127) }) test('CANONICAL_SETS_TABLE loads exactly 32 rows', () => { expect(CANONICAL_SETS_TABLE.length).toBe(32) expect(CANONICAL_SETS_TABLE.entries.length).toBe(32) expect(CANONICAL_SETS_TABLE.rows.length).toBe(32) }) test('Preservation of row indices 0..126 matches .d2s setId specification', () => { for (let index = 0; index < 127; index++) { const entry = CANONICAL_SET_ITEMS_TABLE.entries[index]! expect(entry.id).toBe(index) expect(CANONICAL_SET_ITEMS_TABLE.get(index)?.id).toBe(index) expect(CANONICAL_SET_ITEMS_TABLE.getById(index)?.id).toBe(index) expect(CANONICAL_SET_ITEMS_TABLE[index]?.id).toBe(index) } }) test('All SetItems reference valid sets in Sets.txt (0 dangling references)', () => { const validSetKeys = new Set(CANONICAL_SETS_TABLE.entries.map(s => s.index.trim().toLowerCase())) expect(validSetKeys.size).toBe(32) for (const item of CANONICAL_SET_ITEMS_TABLE.entries) { const setRef = item.set.trim().toLowerCase() expect(validSetKeys.has(setRef), `SetItem "${item.index}" references unknown set "${item.set}"`).toBe(true) const setDef = CANONICAL_SETS_TABLE.get(item.set) expect(setDef, `Lookup for set "${item.set}" returned undefined`).toBeDefined() } }) test('Case-insensitive and trimmed lookups work on both tables', () => { const civerbWard = CANONICAL_SET_ITEMS_TABLE.get("civerb's ward") expect(civerbWard).toBeDefined() expect(civerbWard?.index).toBe("Civerb's Ward") const civerbVest = CANONICAL_SETS_TABLE.get("civerb's vestments") expect(civerbVest).toBeDefined() expect(civerbVest?.index).toBe("Civerb's Vestments") // Handling trailing space on Immortal King's Soul Cage const ikCageWithSpace = CANONICAL_SET_ITEMS_TABLE.get("Immortal King's Soul Cage ") const ikCageWithoutSpace = CANONICAL_SET_ITEMS_TABLE.get("Immortal King's Soul Cage") expect(ikCageWithSpace).toBeDefined() expect(ikCageWithoutSpace).toBeDefined() expect(ikCageWithSpace).toBe(ikCageWithoutSpace) }) test('Table iterable protocol works correctly', () => { let itemCount = 0 for (const item of CANONICAL_SET_ITEMS_TABLE) { expect(item.id).toBe(itemCount) itemCount++ } expect(itemCount).toBe(127) let setCount = 0 for (const setDef of CANONICAL_SETS_TABLE) { expect(setDef.index).toBeDefined() setCount++ } expect(setCount).toBe(32) }) test('Table getBySet returns all member items for a set', () => { const civerbItems = CANONICAL_SET_ITEMS_TABLE.getBySet("Civerb's Vestments") expect(civerbItems.length).toBe(3) const names = civerbItems.map(i => i.index) expect(names).toEqual(["Civerb's Ward", "Civerb's Icon", "Civerb's Cudgel"]) const sigonItems = CANONICAL_SET_ITEMS_TABLE.getBySet("Sigon's Complete Steel") expect(sigonItems.length).toBe(6) }) test('Sets.txt items are correctly linked to SetItems', () => { const civerb = CANONICAL_SETS_TABLE.get("Civerb's Vestments")! expect(civerb.items).toBeDefined() expect(civerb.items?.length).toBe(3) expect(civerb.items).toEqual(["Civerb's Ward", "Civerb's Icon", "Civerb's Cudgel"]) const sigon = CANONICAL_SETS_TABLE.get("Sigon's Complete Steel")! expect(sigon.items?.length).toBe(6) }) }) describe('Sample SetItem & Set Definitions (Issue #98)', () => { test("Civerb's Ward (addFunc=1) field values", () => { const ward = CANONICAL_SET_ITEMS_TABLE.get("Civerb's Ward")! expect(ward).toBeDefined() expect(ward.id).toBe(0) expect(ward.index).toBe("Civerb's Ward") expect(ward.set).toBe("Civerb's Vestments") expect(ward.item).toBe('lrg') expect(ward.rarity).toBe(7) expect(ward.lvl).toBe(13) expect(ward.levelreq).toBe(9) expect(ward.chrtransform).toBe('lyel') expect(ward.invtransform).toBe('lyel') expect(ward.costMult).toBe(5) expect(ward.costAdd).toBe(2500) expect(ward.addFunc).toBe(1) // Base props: ac=15, block=15 expect(ward.props.length).toBe(2) expect(ward.props[0]).toEqual({ code: 'ac', min: 15, max: 15 }) expect(ward.props[1]).toEqual({ code: 'block', min: 15, max: 15 }) // Partial aprops: aprop1a=mana (21..22), aprop2a=res-pois (25..26) expect(ward.aprops.length).toBe(2) expect(ward.aprops[0]).toEqual({ threshold: 1, slot: 'a', code: 'mana', min: 21, max: 22 }) expect(ward.aprops[1]).toEqual({ threshold: 2, slot: 'a', code: 'res-pois', min: 25, max: 26 }) }) test("Civerb's Icon (addFunc=2) field values", () => { const icon = CANONICAL_SET_ITEMS_TABLE.get("Civerb's Icon")! expect(icon).toBeDefined() expect(icon.id).toBe(1) expect(icon.index).toBe("Civerb's Icon") expect(icon.set).toBe("Civerb's Vestments") expect(icon.item).toBe('amu') expect(icon.rarity).toBe(7) expect(icon.lvl).toBe(13) expect(icon.levelreq).toBe(9) expect(icon.addFunc).toBe(2) // Base props: regen-mana=40, regen=4 expect(icon.props.length).toBe(2) expect(icon.props[0]).toEqual({ code: 'regen-mana', min: 40, max: 40 }) expect(icon.props[1]).toEqual({ code: 'regen', min: 4, max: 4 }) // Partial aprops: aprop1a=res-cold (25..25), aprop2a=ac (25..25) expect(icon.aprops.length).toBe(2) expect(icon.aprops[0]).toEqual({ threshold: 1, slot: 'a', code: 'res-cold', min: 25, max: 25 }) expect(icon.aprops[1]).toEqual({ threshold: 2, slot: 'a', code: 'ac', min: 25, max: 25 }) }) test("Civerb's Vestments set definition values", () => { const vestments = CANONICAL_SETS_TABLE.get("Civerb's Vestments")! expect(vestments).toBeDefined() expect(vestments.index).toBe("Civerb's Vestments") expect(vestments.name).toBe("Civerb's Vestments") expect(vestments.version).toBe(0) expect(vestments.level).toBe(13) // Partial set codes: PCode2a = res-fire (15..15) expect(vestments.pCodes.length).toBe(1) expect(vestments.pCodes[0]).toEqual({ threshold: 2, slot: 'a', code: 'res-fire', min: 15, max: 15 }) // Full set codes: FCode1=str(15), FCode2=dmg-undead(200), FCode3=res-ltng(25) expect(vestments.fCodes.length).toBe(3) expect(vestments.fCodes[0]).toEqual({ code: 'str', par: 0, min: 15, max: 15 }) expect(vestments.fCodes[1]).toEqual({ code: 'dmg-undead', par: 0, min: 200, max: 200 }) expect(vestments.fCodes[2]).toEqual({ code: 'res-ltng', par: 0, min: 25, max: 25 }) }) }) describe('Set Bonus Resolution: resolveActiveSetBonuses (Issue #98)', () => { const ward = CANONICAL_SET_ITEMS_TABLE.get("Civerb's Ward")! const vestments = CANONICAL_SETS_TABLE.get("Civerb's Vestments")! test('0 pieces worn returns empty resolved bonuses', () => { const resolved = resolveActiveSetBonuses(ward, vestments, 0) expect(resolved.props.length).toBe(0) expect(resolved.aprops.length).toBe(0) expect(resolved.pCodes.length).toBe(0) expect(resolved.fCodes.length).toBe(0) expect(resolved.all.length).toBe(0) expect(resolved.isFullSet).toBe(false) }) test('1 piece worn: only individual base item props active', () => { const resolved = resolveActiveSetBonuses(ward, vestments, 1) expect(resolved.isFullSet).toBe(false) expect(resolved.props.length).toBe(2) expect(resolved.props.map(p => p.code)).toEqual(['ac', 'block']) expect(resolved.aprops.length).toBe(0) expect(resolved.pCodes.length).toBe(0) expect(resolved.fCodes.length).toBe(0) expect(resolved.all.length).toBe(2) }) test('2 pieces worn: base props + aprop1 + PCode2 active', () => { const resolved = resolveActiveSetBonuses(ward, vestments, 2) expect(resolved.isFullSet).toBe(false) expect(resolved.props.length).toBe(2) // aprop1 (threshold 1) is active expect(resolved.aprops.length).toBe(1) expect(resolved.aprops[0]?.code).toBe('mana') expect(resolved.aprops[0]?.threshold).toBe(1) // PCode2 (threshold 2) is active expect(resolved.pCodes.length).toBe(1) expect(resolved.pCodes[0]?.code).toBe('res-fire') expect(resolved.pCodes[0]?.threshold).toBe(2) // Full set not yet active expect(resolved.fCodes.length).toBe(0) expect(resolved.all.length).toBe(4) // 2 props + 1 aprop + 1 pCode }) test('3 pieces worn (Full Set): base props + all aprops + all PCodes + all FCodes active', () => { const resolved = resolveActiveSetBonuses(ward, vestments, 3) expect(resolved.isFullSet).toBe(true) expect(resolved.props.length).toBe(2) // Both aprop1 and aprop2 are active expect(resolved.aprops.length).toBe(2) expect(resolved.aprops.map(a => a.code)).toEqual(['mana', 'res-pois']) // PCode2 is active expect(resolved.pCodes.length).toBe(1) expect(resolved.pCodes[0]?.code).toBe('res-fire') // Full set bonuses (3 FCodes) are active expect(resolved.fCodes.length).toBe(3) expect(resolved.fCodes.map(f => f.code)).toEqual(['str', 'dmg-undead', 'res-ltng']) // Total active: 2 + 2 + 1 + 3 = 8 expect(resolved.all.length).toBe(8) }) test("6-piece set progression: Sigon's Complete Steel", () => { const gage = CANONICAL_SET_ITEMS_TABLE.get("Sigon's Gage")! const sigonSet = CANONICAL_SETS_TABLE.get("Sigon's Complete Steel")! expect(sigonSet.items?.length).toBe(6) // 1 piece: props only const res1 = resolveActiveSetBonuses(gage, sigonSet, 1) expect(res1.isFullSet).toBe(false) expect(res1.props.map(p => p.code)).toEqual(['str', 'att']) expect(res1.aprops.length).toBe(0) expect(res1.pCodes.length).toBe(0) expect(res1.fCodes.length).toBe(0) // 2 pieces: props + aprop1 (swing3) + PCode2 (lifesteal) const res2 = resolveActiveSetBonuses(gage, sigonSet, 2) expect(res2.isFullSet).toBe(false) expect(res2.aprops.map(a => a.code)).toEqual(['swing3']) expect(res2.pCodes.map(p => p.code)).toEqual(['lifesteal']) expect(res2.fCodes.length).toBe(0) // 3 pieces: props + aprop1 + PCode2 + PCode3 (ac) const res3 = resolveActiveSetBonuses(gage, sigonSet, 3) expect(res3.isFullSet).toBe(false) expect(res3.aprops.map(a => a.code)).toEqual(['swing3']) expect(res3.pCodes.map(p => p.code)).toEqual(['lifesteal', 'ac']) expect(res3.fCodes.length).toBe(0) // 4 pieces: same partials, not full set const res4 = resolveActiveSetBonuses(gage, sigonSet, 4) expect(res4.isFullSet).toBe(false) expect(res4.fCodes.length).toBe(0) // 5 pieces: same partials, not full set const res5 = resolveActiveSetBonuses(gage, sigonSet, 5) expect(res5.isFullSet).toBe(false) expect(res5.fCodes.length).toBe(0) // 6 pieces (Full Set): props + aprop1 + PCode2 + PCode3 + 5 FCodes const res6 = resolveActiveSetBonuses(gage, sigonSet, 6) expect(res6.isFullSet).toBe(true) expect(res6.aprops.map(a => a.code)).toEqual(['swing3']) expect(res6.pCodes.map(p => p.code)).toEqual(['lifesteal', 'ac']) expect(res6.fCodes.map(f => f.code)).toEqual(['res-fire', 'thorns', 'red-dmg', 'fire-max', 'mana']) expect(res6.all.length).toBe(2 + 1 + 2 + 5) // 10 }) }) describe('Parser Overloads & Helpers (Issue #98)', () => { test('parseParam helper handles numbers and strings', () => { expect(parseParam(undefined)).toBeUndefined() expect(parseParam('')).toBeUndefined() expect(parseParam(' ')).toBeUndefined() expect(parseParam('10')).toBe(10) expect(parseParam('-5')).toBe(-5) expect(parseParam('Nova')).toBe('Nova') expect(parseParam('Fire Mastery')).toBe('Fire Mastery') }) test('parseSetItemsTable accepts string, Uint8Array, and D2Table', () => { const fromString = parseSetItemsTable(CANONICAL_SET_ITEMS_TSV) expect(fromString.length).toBe(127) const fromBytes = parseSetItemsTable(new TextEncoder().encode(CANONICAL_SET_ITEMS_TSV.trim())) expect(fromBytes.length).toBe(127) const fromTable = parseSetItemsTable(parseTable(new TextEncoder().encode(CANONICAL_SET_ITEMS_TSV.trim()))) expect(fromTable.length).toBe(127) }) test('parseSetsTable accepts string, Uint8Array, and D2Table', () => { const fromString = parseSetsTable(CANONICAL_SETS_TSV) expect(fromString.length).toBe(32) const fromBytes = parseSetsTable(new TextEncoder().encode(CANONICAL_SETS_TSV.trim())) expect(fromBytes.length).toBe(32) const fromTable = parseSetsTable(parseTable(new TextEncoder().encode(CANONICAL_SETS_TSV.trim()))) expect(fromTable.length).toBe(32) }) }) describe('Integration: loadSetData from Mounted MPQ Archives (Issue #98)', () => { test('loads SetItems.txt and Sets.txt directly from 1.13c MPQs if available', async () => { if (!hasMpqs) { console.log('Skipping MPQ integration test: samples/d2 MPQ files not present in worktree') return } const archives = new MountedArchives() for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { const p = `${MPQ_DIR}/${name}` if (fs.existsSync(p)) { archives.add(name, await MpqArchive.open(await fileSource(p))) } } const { items, sets } = await loadSetData(archives) expect(items.length).toBe(127) expect(sets.length).toBe(32) // Check 0 dangling references for (const item of items) { expect(sets.get(item.set), `SetItem "${item.index}" references missing set "${item.set}"`).toBeDefined() } // Check linkItems const civerb = sets.get("Civerb's Vestments")! expect(civerb.items).toEqual(["Civerb's Ward", "Civerb's Icon", "Civerb's Cudgel"]) const sigon = sets.get("Sigon's Complete Steel")! expect(sigon.items?.length).toBe(6) // Check bonus resolution with MPQ data const ward = items.get("Civerb's Ward")! const fullBonus = resolveActiveSetBonuses(ward, civerb, 3) expect(fullBonus.isFullSet).toBe(true) expect(fullBonus.fCodes.length).toBe(3) }) })