/** * 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 type DifficultyLevelTable = DifficultyLevelsTable 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 } } /** Alias for rollBaseUpgrade conforming to spec nomenclature. */ export const upgradeItemBase = rollBaseUpgrade