diff --git a/src/game/item-upgrade.ts b/src/game/item-upgrade.ts new file mode 100644 index 0000000..e0c1c20 --- /dev/null +++ b/src/game/item-upgrade.ts @@ -0,0 +1,334 @@ +/** + * Diablo II Exceptional / Elite Item Base Upgrade System. + * + * Implements base item quality upgrades during TreasureClass drop generation: + * - Reads difficulty parameters from `Difficultylevels.txt` (UberCodeOddsNormal, UberCodeOddsGood, + * UltraCodeOddsNormal, UltraCodeOddsGood for Normal, Nightmare, Hell). + * - Implements 1.13c disassembly logic from D2Game!6FC32380 and D2Game!6FC31880: + * - qf[4] (uberCodeOdds) and qf[5] (ultraCodeOdds) checks against rng.rand(1024). + * - Flags 0x04 (exceptional) and 0x10 (elite). + * - Selection priority: elite ultracode -> exceptional ubercode -> original base (normcode). + * - When upgraded, the item base is replaced with the upgraded base, and `isUber` is set to true. + */ +import type { D2Table } from './acts.ts' +import { parseTable } from './acts.ts' +import type { MountedArchives } from '../mpq/mount.ts' +import type { ItemBase } from './items.ts' +import type { D2Rng } from './d2-rng.ts' + +/** Canonical MPQ path of `Difficultylevels.txt`. */ +export const DIFFICULTY_LEVELS_TABLE_PATH = 'data\\global\\excel\\Difficultylevels.txt' + +/** Bitflag set when exceptional upgrade succeeds (genFlags |= 0x04). */ +export const ITEM_UPGRADE_FLAG_EXCEPTIONAL = 0x04 + +/** Bitflag set when elite upgrade succeeds (genFlags |= 0x10). */ +export const ITEM_UPGRADE_FLAG_ELITE = 0x10 + +/** + * Difficulty level odds parsed from `Difficultylevels.txt`. + */ +export interface DifficultyLevelOdds { + readonly name: string + readonly uberCodeOddsNormal: number + readonly uberCodeOddsGood: number + readonly ultraCodeOddsNormal: number + readonly ultraCodeOddsGood: number + // PascalCase aliases matching exact table column names + readonly UberCodeOddsNormal: number + readonly UberCodeOddsGood: number + readonly UltraCodeOddsNormal: number + readonly UltraCodeOddsGood: number +} + +/** + * Case-insensitive, whitespace-tolerant Map for looking up items/difficulties by key. + */ +export class CaseInsensitiveMap extends Map { + 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)) + } +} + +/** Default canonical odds for standard Diablo II difficulties. */ +export const DEFAULT_DIFFICULTY_LEVEL_ODDS: Record = { + Normal: { + name: 'Normal', + uberCodeOddsNormal: 0, + uberCodeOddsGood: 0, + ultraCodeOddsNormal: 0, + ultraCodeOddsGood: 0, + UberCodeOddsNormal: 0, + UberCodeOddsGood: 0, + UltraCodeOddsNormal: 0, + UltraCodeOddsGood: 0, + }, + Nightmare: { + name: 'Nightmare', + uberCodeOddsNormal: 10, + uberCodeOddsGood: 20, + ultraCodeOddsNormal: 0, + ultraCodeOddsGood: 0, + UberCodeOddsNormal: 10, + UberCodeOddsGood: 20, + UltraCodeOddsNormal: 0, + UltraCodeOddsGood: 0, + }, + Hell: { + name: 'Hell', + uberCodeOddsNormal: 20, + uberCodeOddsGood: 40, + ultraCodeOddsNormal: 30, + ultraCodeOddsGood: 40, + UberCodeOddsNormal: 20, + UberCodeOddsGood: 40, + UltraCodeOddsNormal: 30, + UltraCodeOddsGood: 40, + }, +} + +/** + * Parsed collection of difficulty level upgrade odds from `Difficultylevels.txt`. + */ +export class DifficultyLevelsTable { + readonly all: readonly DifficultyLevelOdds[] + readonly byName: Map + readonly normal: DifficultyLevelOdds + readonly nightmare: DifficultyLevelOdds + readonly hell: DifficultyLevelOdds + readonly Normal: DifficultyLevelOdds + readonly Nightmare: DifficultyLevelOdds + readonly Hell: DifficultyLevelOdds + + constructor(all: DifficultyLevelOdds[], byName: Map) { + this.all = all + this.byName = byName + + const normal = byName.get('Normal') ?? all[0] ?? DEFAULT_DIFFICULTY_LEVEL_ODDS.Normal! + const nightmare = byName.get('Nightmare') ?? all[1] ?? DEFAULT_DIFFICULTY_LEVEL_ODDS.Nightmare! + const hell = byName.get('Hell') ?? all[2] ?? DEFAULT_DIFFICULTY_LEVEL_ODDS.Hell! + + this.normal = normal + this.nightmare = nightmare + this.hell = hell + this.Normal = normal + this.Nightmare = nightmare + this.Hell = hell + } + + get(name: string): DifficultyLevelOdds | undefined { + return this.byName.get(name) + } + + [Symbol.iterator](): IterableIterator { + return this.all[Symbol.iterator]() + } + + get length(): number { + return this.all.length + } +} + +/** + * Parses `Difficultylevels.txt` table rows into {@link DifficultyLevelsTable}. + * + * @param table - Raw parsed tab-separated D2Table. + * @returns {@link DifficultyLevelsTable} instance. + */ +export function parseDifficultyLevelsTable(table: D2Table): DifficultyLevelsTable { + const colMap = new Map() + const lowerMap = new Map() + + table.header.forEach((name, idx) => { + const trimmed = name.trim() + if (!colMap.has(trimmed)) { + colMap.set(trimmed, idx) + } + const lower = trimmed.toLowerCase() + if (!lowerMap.has(lower)) { + lowerMap.set(lower, idx) + } + }) + + const strAt = (row: readonly string[], colName: string): string => { + const idx = colMap.get(colName) ?? lowerMap.get(colName.toLowerCase()) + return idx !== undefined ? (row[idx] ?? '').trim() : '' + } + + const numAt = (row: readonly string[], colName: string, fallback = 0): number => { + const raw = strAt(row, colName) + if (!raw) return fallback + const val = Number(raw) + return Number.isFinite(val) ? val : fallback + } + + const all: DifficultyLevelOdds[] = [] + const byName = new CaseInsensitiveMap() + + for (const row of table.rows) { + const name = strAt(row, 'Name') + if (!name || name === 'Expansion') { + continue + } + + const uberCodeOddsNormal = numAt(row, 'UberCodeOddsNormal', 0) + const uberCodeOddsGood = numAt(row, 'UberCodeOddsGood', 0) + const ultraCodeOddsNormal = numAt(row, 'UltraCodeOddsNormal', 0) + const ultraCodeOddsGood = numAt(row, 'UltraCodeOddsGood', 0) + + const entry: DifficultyLevelOdds = { + name, + uberCodeOddsNormal, + uberCodeOddsGood, + ultraCodeOddsNormal, + ultraCodeOddsGood, + UberCodeOddsNormal: uberCodeOddsNormal, + UberCodeOddsGood: uberCodeOddsGood, + UltraCodeOddsNormal: ultraCodeOddsNormal, + UltraCodeOddsGood: ultraCodeOddsGood, + } + + all.push(entry) + byName.set(name, entry) + } + + return new DifficultyLevelsTable(all, byName) +} + +/** + * Loads and parses `Difficultylevels.txt` from mounted MPQ archives. + * + * @param archives - Mounted MPQ archives. + * @param path - Optional override MPQ path (defaults to {@link DIFFICULTY_LEVELS_TABLE_PATH}). + * @returns Promise resolving to {@link DifficultyLevelsTable}. + */ +export async function loadDifficultyLevels( + archives: MountedArchives, + path = DIFFICULTY_LEVELS_TABLE_PATH, +): Promise { + const bytes = await archives.read(path) + const d2Table = parseTable(bytes) + return parseDifficultyLevelsTable(d2Table) +} + +/** + * ItemBase with optional tier code linkages (normcode, ubercode, ultracode). + */ +export interface UpgradableItemBase extends ItemBase { + readonly normcode?: string | undefined + readonly ubercode?: string | undefined + readonly ultracode?: string | undefined + readonly code?: string | undefined +} + +/** + * Result of rolling an item base upgrade. + */ +export interface BaseUpgradeResult { + /** The resulting item base (original or exceptional/elite). */ + readonly base: ItemBase + /** Whether the base was upgraded to exceptional or elite tier. */ + readonly isUber: boolean +} + +/** + * Checks whether a base code is valid for upgrade (non-empty and not 'xxx'). + */ +function isValidUpgradeCode(code: unknown): code is string { + if (typeof code !== 'string') { + return false + } + const trimmed = code.trim() + return trimmed.length > 0 && trimmed.toLowerCase() !== 'xxx' +} + +/** + * Rolls exceptional / elite base upgrade for an item base. + * + * Disassembly reference: + * - D2Game!6FC32380 (drop roll loop): + * `if (qf[4] && (rng.rand(1024) < qf[4])) genFlags |= 0x04` + * `if (qf[5] && (rng.rand(1024) < qf[5])) genFlags |= 0x10` + * - D2Game!6FC31880 (base upgrade dispatch): + * - If genFlags & 0x10 (elite): check base `ultracode`. If valid and !== 'xxx', use ultracode. + * - Else if genFlags & 0x04 (exceptional): check base `ubercode`. If valid and !== 'xxx', use ubercode. + * - Otherwise keep original `normcode`. + * - When upgraded: + * - Replaces item base with the exceptional/elite base (providing the new base's `qlvl` / `level`). + * - `isUber` flag is set to true. + * + * @param base - Original item base (usually normal tier). + * @param qf4 - Uber code odds (qf[4], e.g. UberCodeOddsNormal or UberCodeOddsGood). + * @param qf5 - Ultra code odds (qf[5], e.g. UltraCodeOddsNormal or UltraCodeOddsGood). + * @param rng - Diablo II PRNG instance. + * @param lookupBase - Lookup function resolving a 3-4 letter item code to an {@link ItemBase}. + * @returns {@link BaseUpgradeResult} containing the new or original base and `isUber`. + */ +export function rollBaseUpgrade( + base: ItemBase, + qf4: number, + qf5: number, + rng: D2Rng, + lookupBase: (code: string) => ItemBase | undefined, +): BaseUpgradeResult { + let genFlags = 0 + + if (qf4 > 0 && rng.rand(1024) < qf4) { + genFlags |= ITEM_UPGRADE_FLAG_EXCEPTIONAL + } + + if (qf5 > 0 && rng.rand(1024) < qf5) { + genFlags |= ITEM_UPGRADE_FLAG_ELITE + } + + const uBase = base as Partial + const rawUltra = uBase.ultracode + const rawUber = uBase.ubercode + + const ultracode = isValidUpgradeCode(rawUltra) ? rawUltra.trim() : undefined + const ubercode = isValidUpgradeCode(rawUber) ? rawUber.trim() : undefined + + let targetCode: string | undefined + if ((genFlags & ITEM_UPGRADE_FLAG_ELITE) && ultracode) { + targetCode = ultracode + } else if ((genFlags & ITEM_UPGRADE_FLAG_EXCEPTIONAL) && ubercode) { + targetCode = ubercode + } + + if (targetCode) { + const upgraded = lookupBase(targetCode) ?? lookupBase(targetCode.toLowerCase()) + if (upgraded) { + return { base: upgraded, isUber: true } + } + // If elite was selected but lookup failed, fall back to exceptional if rolled and valid + if ( + (genFlags & ITEM_UPGRADE_FLAG_ELITE) && + (genFlags & ITEM_UPGRADE_FLAG_EXCEPTIONAL) && + ubercode && + targetCode !== ubercode + ) { + const fallbackUber = lookupBase(ubercode) ?? lookupBase(ubercode.toLowerCase()) + if (fallbackUber) { + return { base: fallbackUber, isUber: true } + } + } + } + + return { base, isUber: false } +} diff --git a/tests/item-upgrade.test.ts b/tests/item-upgrade.test.ts new file mode 100644 index 0000000..27f8652 --- /dev/null +++ b/tests/item-upgrade.test.ts @@ -0,0 +1,438 @@ +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) + }) + }) +})