import { describe, it, expect, beforeAll, vi } 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 { parseTable } from '../src/game/acts.ts' import type { ItemBase } from '../src/game/items.ts' import { D2Rng } from '../src/game/d2-rng.ts' import { loadArmor, ArmorTable } from '../src/game/armor.ts' import { loadWeapons, type WeaponTable } from '../src/game/weapons.ts' import { DIFFICULTY_LEVELS_TABLE_PATH, ITEM_UPGRADE_FLAG_EXCEPTIONAL, ITEM_UPGRADE_FLAG_ELITE, DifficultyLevelsTable, parseDifficultyLevelsTable, loadDifficultyLevels, rollBaseUpgrade, type UpgradableItemBase, } from '../src/game/item-upgrade.ts' const hasD2 = fs.existsSync('samples/d2/d2data.mpq') /** * Creates a minimal fallback D2Table for Difficultylevels.txt * matching the canonical 1.13c table values. */ function createMinimalDifficultyLevelsTable(): D2Table { const header = [ 'Name', 'ResistPenalty', 'DeathExpPenalty', 'UberCodeOddsNormal', 'UberCodeOddsGood', 'UltraCodeOddsNormal', 'UltraCodeOddsGood', ] const rows = [ ['Normal', '0', '0', '0', '0', '0', '0'], ['Nightmare', '-40', '5', '10', '20', '0', '0'], ['Hell', '-100', '10', '20', '40', '30', '40'], ] return { header, rows } } describe('Item Base Upgrade System (Issue #103)', () => { let diffTable: DifficultyLevelsTable let armorTable: ArmorTable | undefined let weaponTable: WeaponTable | 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}`))) } diffTable = await loadDifficultyLevels(archives) armorTable = await loadArmor(archives) weaponTable = await loadWeapons(archives) } else { diffTable = parseDifficultyLevelsTable(createMinimalDifficultyLevelsTable()) } }) describe('Difficultylevels.txt loader & parser', () => { it('defines canonical MPQ path', () => { expect(DIFFICULTY_LEVELS_TABLE_PATH).toBe('data\\global\\excel\\Difficultylevels.txt') }) it('loads and parses DifficultyLevelsTable with correct values', () => { expect(diffTable).toBeInstanceOf(DifficultyLevelsTable) expect(diffTable.length).toBeGreaterThanOrEqual(3) // Normal: 0, 0, 0, 0 const normal = diffTable.normal expect(normal).toBeDefined() expect(normal.name).toBe('Normal') expect(normal.uberCodeOddsNormal).toBe(0) expect(normal.uberCodeOddsGood).toBe(0) expect(normal.ultraCodeOddsNormal).toBe(0) expect(normal.ultraCodeOddsGood).toBe(0) expect(normal.UberCodeOddsNormal).toBe(0) expect(normal.UberCodeOddsGood).toBe(0) expect(normal.UltraCodeOddsNormal).toBe(0) expect(normal.UltraCodeOddsGood).toBe(0) // Nightmare: 10, 20, 0, 0 const nightmare = diffTable.nightmare expect(nightmare).toBeDefined() expect(nightmare.name).toBe('Nightmare') expect(nightmare.uberCodeOddsNormal).toBe(10) expect(nightmare.uberCodeOddsGood).toBe(20) expect(nightmare.ultraCodeOddsNormal).toBe(0) expect(nightmare.ultraCodeOddsGood).toBe(0) expect(nightmare.UberCodeOddsNormal).toBe(10) expect(nightmare.UberCodeOddsGood).toBe(20) expect(nightmare.UltraCodeOddsNormal).toBe(0) expect(nightmare.UltraCodeOddsGood).toBe(0) // Hell: 20, 40, 30, 40 const hell = diffTable.hell expect(hell).toBeDefined() expect(hell.name).toBe('Hell') expect(hell.uberCodeOddsNormal).toBe(20) expect(hell.uberCodeOddsGood).toBe(40) expect(hell.ultraCodeOddsNormal).toBe(30) expect(hell.ultraCodeOddsGood).toBe(40) expect(hell.UberCodeOddsNormal).toBe(20) expect(hell.UberCodeOddsGood).toBe(40) expect(hell.UltraCodeOddsNormal).toBe(30) expect(hell.UltraCodeOddsGood).toBe(40) }) it('supports case-insensitive lookup by difficulty name', () => { expect(diffTable.get('normal')?.uberCodeOddsNormal).toBe(0) expect(diffTable.get('Nightmare')?.uberCodeOddsGood).toBe(20) expect(diffTable.get('HELL')?.ultraCodeOddsGood).toBe(40) }) it('exposes direct properties for Normal, Nightmare, and Hell', () => { expect(diffTable.Normal.name).toBe('Normal') expect(diffTable.Nightmare.name).toBe('Nightmare') expect(diffTable.Hell.name).toBe('Hell') }) }) describe('rollBaseUpgrade logic', () => { // Mock base items for isolated testing const capNormal: UpgradableItemBase = { id: 'cap', name: 'Cap', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 12, damage: 0, defense: 5, tags: ['helm'], level: 1, // qlvl 1 normcode: 'cap', ubercode: 'xap', ultracode: 'uap', } const warHatExceptional: UpgradableItemBase = { id: 'xap', name: 'War Hat', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 800, damage: 0, defense: 53, tags: ['helm'], level: 34, // qlvl 34 normcode: 'cap', ubercode: 'xap', ultracode: 'uap', } const shakoElite: UpgradableItemBase = { id: 'uap', name: 'Shako', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 2400, damage: 0, defense: 141, tags: ['helm'], level: 58, // qlvl 58 normcode: 'cap', ubercode: 'xap', ultracode: 'uap', } const mockLookup = (code: string): ItemBase | undefined => { const normalized = code.trim().toLowerCase() if (normalized === 'cap') return capNormal if (normalized === 'xap') return warHatExceptional if (normalized === 'uap') return shakoElite return undefined } it('Normal difficulty: odds are 0 -> base never upgrades and consumes 0 RNG rolls', () => { const rng = new D2Rng(12345) const randSpy = vi.spyOn(rng, 'rand') // Test with Normal difficulty odds (0, 0) const result = rollBaseUpgrade(capNormal, 0, 0, rng, mockLookup) expect(result.base).toBe(capNormal) expect(result.base.id).toBe('cap') expect(result.base.level).toBe(1) expect(result.isUber).toBe(false) expect(randSpy).not.toHaveBeenCalled() // Repeat multiple times with different seeds for (let seed = 1; seed <= 50; seed++) { const testRng = new D2Rng(seed) const res = rollBaseUpgrade( capNormal, diffTable.normal.uberCodeOddsNormal, diffTable.normal.ultraCodeOddsNormal, testRng, mockLookup, ) expect(res.base.id).toBe('cap') expect(res.isUber).toBe(false) } }) it('Test odds = 1024 -> base 100% upgrades to ubercode / ultracode', () => { // 1. Exceptional 100% upgrade (qf4 = 1024, qf5 = 0) const rng1 = new D2Rng(999) const resultUber = rollBaseUpgrade(capNormal, 1024, 0, rng1, mockLookup) expect(resultUber.base).toBe(warHatExceptional) expect(resultUber.base.id).toBe('xap') expect(resultUber.isUber).toBe(true) // 2. Elite 100% upgrade (qf4 = 0, qf5 = 1024) const rng2 = new D2Rng(888) const resultUltra = rollBaseUpgrade(capNormal, 0, 1024, rng2, mockLookup) expect(resultUltra.base).toBe(shakoElite) expect(resultUltra.base.id).toBe('uap') expect(resultUltra.isUber).toBe(true) // 3. Both 100% (qf4 = 1024, qf5 = 1024) -> elite takes priority const rng3 = new D2Rng(777) const resultBoth = rollBaseUpgrade(capNormal, 1024, 1024, rng3, mockLookup) expect(resultBoth.base).toBe(shakoElite) expect(resultBoth.base.id).toBe('uap') expect(resultBoth.isUber).toBe(true) }) it('Handle empty or "xxx" gracefully without upgrading', () => { const rng = new D2Rng(42) // Base with 'xxx' for both ubercode and ultracode const xxxBase: UpgradableItemBase = { id: 'xxxitem', name: 'No Upgrades Item', kind: 'armor', invWidth: 1, invHeight: 1, maxStack: 1, value: 10, damage: 0, defense: 1, tags: [], level: 5, normcode: 'xxxitem', ubercode: 'xxx', ultracode: 'XXX', } // Even with 100% odds, it must not upgrade const resXxx = rollBaseUpgrade(xxxBase, 1024, 1024, rng, () => undefined) expect(resXxx.base).toBe(xxxBase) expect(resXxx.isUber).toBe(false) // Base with empty strings const emptyBase: UpgradableItemBase = { ...xxxBase, ubercode: '', ultracode: ' ', } const resEmpty = rollBaseUpgrade(emptyBase, 1024, 1024, rng, () => undefined) expect(resEmpty.base).toBe(emptyBase) expect(resEmpty.isUber).toBe(false) // Base without ubercode or ultracode fields const regularItemBase: ItemBase = { id: 'gold', name: 'Gold', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 50000, value: 1, damage: 0, defense: 0, tags: [], level: 1, } const resReg = rollBaseUpgrade(regularItemBase, 1024, 1024, rng, mockLookup) expect(resReg.base).toBe(regularItemBase) expect(resReg.isUber).toBe(false) // Ultracode is 'xxx' but ubercode is valid: falls back to exceptional const mixedBase: UpgradableItemBase = { ...capNormal, ubercode: 'xap', ultracode: 'xxx', } const resMixed = rollBaseUpgrade(mixedBase, 1024, 1024, rng, mockLookup) expect(resMixed.base).toBe(warHatExceptional) expect(resMixed.base.id).toBe('xap') expect(resMixed.isUber).toBe(true) // Lookup returns undefined (unrecognized code): gracefully keeps original base const unresolvableBase: UpgradableItemBase = { ...capNormal, ubercode: 'nonexistent', ultracode: 'unknown', } const resUnresolved = rollBaseUpgrade(unresolvableBase, 1024, 1024, rng, () => undefined) expect(resUnresolved.base).toBe(unresolvableBase) expect(resUnresolved.isUber).toBe(false) }) it('RNG consumption: exactly two rand(1024) rolls', () => { const rng = new D2Rng(100) const randSpy = vi.spyOn(rng, 'rand') // Call with non-zero qf4 and qf5 rollBaseUpgrade(capNormal, 20, 40, rng, mockLookup) expect(randSpy).toHaveBeenCalledTimes(2) expect(randSpy).toHaveBeenNthCalledWith(1, 1024) expect(randSpy).toHaveBeenNthCalledWith(2, 1024) }) it('Switch to new base qlvl and sets isUber = true', () => { const rng = new D2Rng(123) // 1. Exceptional upgrade changes base to War Hat (qlvl 34, isUber = true) const resExceptional = rollBaseUpgrade(capNormal, 1024, 0, rng, mockLookup) expect(resExceptional.isUber).toBe(true) expect(resExceptional.base.level).toBe(34) // Switches to War Hat qlvl expect(resExceptional.base.defense).toBe(53) // 2. Elite upgrade changes base to Shako (qlvl 58, isUber = true) const resElite = rollBaseUpgrade(capNormal, 0, 1024, rng, mockLookup) expect(resElite.isUber).toBe(true) expect(resElite.base.level).toBe(58) // Switches to Shako qlvl expect(resElite.base.defense).toBe(141) // 3. Non-upgraded base retains original qlvl and isUber = false const resNormal = rollBaseUpgrade(capNormal, 0, 0, rng, mockLookup) expect(resNormal.isUber).toBe(false) expect(resNormal.base.level).toBe(1) // Cap qlvl }) if (hasD2) { it('integrates with real Armor.txt bases and verifies tier progression', () => { expect(armorTable).toBeDefined() const cap = armorTable!.get('cap') expect(cap).toBeDefined() expect(cap?.ubercode).toBe('xap') expect(cap?.ultracode).toBe('uap') expect(cap?.level).toBe(1) const lookup = (code: string) => armorTable!.get(code) const rng = new D2Rng(54321) // Upgrade to exceptional const uberResult = rollBaseUpgrade(cap!, 1024, 0, rng, lookup) expect(uberResult.isUber).toBe(true) expect(uberResult.base.id).toBe('xap') expect(uberResult.base.level).toBe(34) // War Hat qlvl // Upgrade to elite const eliteResult = rollBaseUpgrade(cap!, 0, 1024, rng, lookup) expect(eliteResult.isUber).toBe(true) expect(eliteResult.base.id).toBe('uap') expect(eliteResult.base.level).toBe(58) // Shako qlvl }) it('integrates with real Weapons.txt bases and verifies tier progression', () => { expect(weaponTable).toBeDefined() const handAxe = weaponTable!.get('hax') expect(handAxe).toBeDefined() expect(handAxe?.ubercode).toBe('9ha') expect(handAxe?.ultracode).toBe('7ha') expect(handAxe?.level).toBe(3) const lookup = (code: string) => weaponTable!.get(code) const rng = new D2Rng(65432) // Upgrade to exceptional (Hatchet) const uberResult = rollBaseUpgrade(handAxe!, 1024, 0, rng, lookup) expect(uberResult.isUber).toBe(true) expect(uberResult.base.id).toBe('9ha') expect(uberResult.base.level).toBe(31) // Hatchet qlvl // Upgrade to elite (Tomahawk) const eliteResult = rollBaseUpgrade(handAxe!, 0, 1024, rng, lookup) expect(eliteResult.isUber).toBe(true) expect(eliteResult.base.id).toBe('7ha') expect(eliteResult.base.level).toBe(54) // Tomahawk qlvl }) } }) describe('Disassembly alignment verification (D2Game!6FC32380 & 6FC31880)', () => { it('defines bitflags 0x04 for exceptional and 0x10 for elite', () => { expect(ITEM_UPGRADE_FLAG_EXCEPTIONAL).toBe(0x04) expect(ITEM_UPGRADE_FLAG_ELITE).toBe(0x10) }) it('verifies rand < odds threshold condition with specific seed', () => { const base: UpgradableItemBase = { id: 'tst', name: 'Test', kind: 'armor', invWidth: 1, invHeight: 1, maxStack: 1, value: 10, damage: 0, defense: 1, tags: [], level: 1, ubercode: 'xtst', ultracode: 'utst', } const xtst: ItemBase = { ...base, id: 'xtst', level: 25 } const utst: ItemBase = { ...base, id: 'utst', level: 50 } const lookup = (c: string) => (c === 'xtst' ? xtst : c === 'utst' ? utst : undefined) // Test seed where we know exact rolls const rng = new D2Rng(0) const roll1 = rng.rand(1024) const roll2 = rng.rand(1024) // Reset seed to replay exact rolls const testRng = new D2Rng(0) // Set qf4 to roll1 (so roll1 < qf4 is false) and qf5 to roll2 + 1 (so roll2 < qf5 is true) const res = rollBaseUpgrade(base, roll1, roll2 + 1, testRng, lookup) expect(res.base.id).toBe('utst') expect(res.isUber).toBe(true) }) }) })