/** * 1.13c Diablo II Item Drop Pipeline. * * Complete implementation of the real TreasureClassEx drop tree, replacing * synthetic rollDrop and deleting demo item bases and affixes. * * Gold standard entry flow (D2Game!6FC32D60): * - nLevel = monster.level * - tcName = monStats.getTreasureClass(difficulty, monsterType) * - Top-level TC group upgrade: resolveTreasureClassGroup(tcTable, tcName, nLevel) * - Expand TC tree using expandTreasureClass (from rollTreasureClass) * - For each rolled leaf item: * * Gold: calculate scaled gold amount and emit goldItem(amount). * * Base item: * - Lookup base in weapons/armor/misc via dropTables.getBase(code). * - Base upgrade: upgradeItemBase(base, qf4, qf5, rng, dropTables.getBase). * - Init item seed: dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0), itemRng = new D2Rng(dwInitSeed). * - ItemRatio quality roll using rollItemQuality. * - Quality branches: * * Unique: lookup eligible enabled uniques (lvl <= nLevel). Fallback if empty: Rare + 3x durability. * * Set: lookup eligible set items (lvl <= nLevel). Fallback if empty: Magic + 2x durability. * * Rare: rollRareAffixes. * * Magic: rollMagicAffixes. * * Superior / Normal / Low: standard base. */ import type { MountedArchives } from '../mpq/mount.ts' import { parseTable } from './acts.ts' import type { D2Table } from './acts.ts' import { D2Rng } from './d2-rng.ts' import { Rng } from './rng.ts' import { type TreasureClassTable, type TreasureClassNode, loadTreasureClasses, resolveTreasureClassGroup, } from './treasure-class.ts' import { expandTreasureClass, type DroppedItemEntry, } from './treasure-engine.ts' import { type WeaponTable, loadWeapons, } from './weapons.ts' import { type ArmorTable, loadArmor, } from './armor.ts' import { type MiscTable, loadMisc, } from './misc-items.ts' import { type ItemRatioTable, loadItemRatio, rollItemQuality, type ItemQualityTier, QUALITY_TIER_TO_ITEM_QUALITY, } from './item-ratio.ts' import { type DifficultyLevelTable, loadDifficultyLevels, DEFAULT_DIFFICULTY_LEVEL_ODDS, upgradeItemBase, } from './item-upgrade.ts' import { type UniqueItemsTable, loadUniqueItems, type UniqueItem, } from './unique-items.ts' import { type SetTable, type SetItemTable, loadSetData, type SetItem, } from './set-items.ts' import { type AffixTable, loadMagicAffixes, } from './affixes.ts' import { type RareNameTable, loadRareNames, } from './rare-names.ts' import { type ItemTypeTable, loadItemTypes, isA, } from './item-types.ts' import { generateAutoTreasureClasses, autoTcToTreasureClassNode, } from './auto-tc.ts' import { type Item, type ItemBase, ItemQuality, goldItem, ItemFlag, decodeItemFlags, formatItemCode, } from './items.ts' import { type MonsterKind, readMonsterKinds, getMonsterTreasureClass, type Difficulty, readSuperUniques, type SuperUnique, } from './monsters.ts' import { rollMagicAffixes, rollRareAffixes, type GeneratedAffixes, type GeneratedRareAffixes, } from './affix-generator.ts' /** * Complete collection of parsed game data tables required for the drop pipeline. */ export interface DropTables { readonly tcTable: TreasureClassTable readonly autoTcTable: Map readonly weapons: WeaponTable readonly armor: ArmorTable readonly misc: MiscTable readonly itemRatio: ItemRatioTable readonly difficultyLevels: DifficultyLevelTable readonly uniques: UniqueItemsTable readonly sets: { readonly items: SetItemTable; readonly sets: SetTable } readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable } readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable } readonly itemTypes: ItemTypeTable readonly monsterKinds: Map readonly superUniques: Map readonly isA: (type: string, target: string) => boolean readonly getBase: (code: string) => ItemBase | undefined } /** * Loads all data tables required by the drop pipeline from mounted MPQ archives. * Fails loudly with an Error if required files or tables are missing. * * @param archives - Mounted MPQ archives. * @returns Promise resolving to DropTables. */ export async function loadDropTables(archives: MountedArchives): Promise { const [ tcTable, weapons, armor, misc, itemRatio, difficultyLevels, uniques, sets, magicAffixes, rareNames, itemTypes, monstatsBytes, ] = await Promise.all([ loadTreasureClasses(archives), loadWeapons(archives), loadArmor(archives), loadMisc(archives), loadItemRatio(archives), loadDifficultyLevels(archives), loadUniqueItems(archives), loadSetData(archives), loadMagicAffixes(archives), loadRareNames(archives), loadItemTypes(archives), archives.read('data\\global\\excel\\monstats.txt'), ]) const rawAutoTcTable = generateAutoTreasureClasses(itemTypes, armor, weapons) const autoTcTable = new Map() for (const [key, node] of rawAutoTcTable) { autoTcTable.set(key, autoTcToTreasureClassNode(node)) } const isAPredicate = (type: string, target: string): boolean => isA(type, target, itemTypes) const getBase = (code: string): ItemBase | undefined => { return weapons.get(code) ?? armor.get(code) ?? misc.get(code) } const monstatsTable: D2Table = parseTable(monstatsBytes) const monsterKinds = readMonsterKinds(monstatsTable) const superUniques = new Map() try { const suBytes = await archives.read('data\\global\\excel\\SuperUniques.txt') const suList = readSuperUniques(parseTable(suBytes)) for (const su of suList) { superUniques.set(su.id, su) } } catch { // If SuperUniques.txt is absent, keep map empty } return { tcTable, autoTcTable, weapons, armor, misc, itemRatio, difficultyLevels, uniques, sets, magicAffixes, rareNames, itemTypes, monsterKinds, superUniques, isA: isAPredicate, getBase, } } /** * Options for executing the TreasureClass drop pipeline. */ export interface DropPipelineOptions { /** Top-level TreasureClass name (e.g. 'Act 1 H2H A', 'Act 1 Champ A'). */ readonly tcName: string /** Monster level (or area level) used for group upgrade and ilvl. */ readonly nLevel: number /** Monster type: 1 = Normal, 2 = Champion, 3 = Unique, 4 = Boss / Quest. */ readonly monsterType?: number | undefined /** Monster drop RNG instance or seed. */ readonly monsterRng?: D2Rng | Rng | number | undefined /** Player Magic Find percentage (e.g. 50 = +50% MF). */ readonly playerMf?: number | undefined /** Game difficulty. */ readonly difficulty?: Difficulty | undefined /** Total players in game (for NoDrop scaling). */ readonly gamePlayers?: number | undefined /** Nearby party members (for NoDrop scaling). */ readonly partyPlayers?: number | undefined /** Whether Expansion items and runes are enabled (default: true). */ readonly isExpansion?: boolean | undefined /** Parsed drop tables. */ readonly dropTables: DropTables } /** * Creates a concrete Item instance for a dropped leaf item. */ export function createDroppedItem( base: ItemBase, qualityTier: ItemQualityTier, options: { readonly uniqueItem?: UniqueItem | undefined readonly setItem?: SetItem | undefined readonly magicAffixes?: GeneratedAffixes | undefined readonly rareAffixes?: GeneratedRareAffixes | undefined readonly ilvl: number readonly dwInitSeed: number readonly durabilityMultiplier?: number | undefined }, ): Item { const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL const rawFlags = ItemFlag.IDENTIFIED let name = base.name let prefix = null let suffix = null const stats: Record = {} if (base.damage > 0) stats.damage = base.damage if (base.defense > 0) stats.defense = base.defense if (qualityTier === 'unique' && options.uniqueItem) { name = options.uniqueItem.index for (const prop of options.uniqueItem.props) { if (prop.code) { stats[prop.code] = (stats[prop.code] ?? 0) + prop.min } } } else if (qualityTier === 'set' && options.setItem) { name = options.setItem.index for (const prop of options.setItem.props) { if (prop.code) { stats[prop.code] = (stats[prop.code] ?? 0) + prop.min } } } else if (qualityTier === 'rare' && options.rareAffixes) { name = options.rareAffixes.name for (const affix of options.rareAffixes.affixes) { for (const mod of affix.mods) { stats[mod.code] = (stats[mod.code] ?? 0) + mod.value } } } else if (qualityTier === 'magic' && options.magicAffixes) { name = options.magicAffixes.name if (options.magicAffixes.prefix) { const p = options.magicAffixes.prefix prefix = { id: p.affix.name, name: p.name, kind: 'prefix' as const, level: p.level, itemTypes: p.affix.itypes, modifiers: p.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })), } for (const mod of p.mods) { stats[mod.code] = (stats[mod.code] ?? 0) + mod.value } } if (options.magicAffixes.suffix) { const s = options.magicAffixes.suffix suffix = { id: s.affix.name, name: s.name, kind: 'suffix' as const, level: s.level, itemTypes: s.affix.itypes, modifiers: s.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })), } for (const mod of s.mods) { stats[mod.code] = (stats[mod.code] ?? 0) + mod.value } } } else if (qualityTier === 'superior') { name = `Superior ${base.name}` } else if (qualityTier === 'low') { name = `Cracked ${base.name}` } const baseDurability = 20 const mult = options.durabilityMultiplier ?? 1 const maxDurability = Math.round(baseDurability * mult) return { base, prefix, suffix, level: options.ilvl, ilvl: options.ilvl, name, stats, invWidth: base.invWidth, invHeight: base.invHeight, stack: 1, value: base.value, rawFlags, flags: decodeItemFlags(rawFlags), version: 0x65, code: formatItemCode(base.id), quality, rarity: qualityTier, uniqueId: options.dwInitSeed, durability: maxDurability, maxDurability, ...(options.uniqueItem ? { uniqueId: options.dwInitSeed, uniqueItemDef: options.uniqueItem } : {}), ...(options.setItem ? { setId: options.setItem.id, setItemDef: options.setItem } : {}), ...(options.rareAffixes ? { rolledRareAffixes: options.rareAffixes } : {}), ...(options.magicAffixes ? { rolledMagicAffixes: options.magicAffixes } : {}), } as Item } /** * Executes the full TreasureClass drop pipeline for a slain monster or container. * * @param options - Drop pipeline parameters. * @returns Array of dropped Item instances (including gold items). */ export function executeDropPipeline( tablesOrOptions: DropTables | DropPipelineOptions, maybeOptions?: Omit, ): Item[] { const options: DropPipelineOptions = 'tcTable' in tablesOrOptions ? { ...maybeOptions!, dropTables: tablesOrOptions } : tablesOrOptions const { tcName, nLevel, monsterType = 1, dropTables, difficulty = 'normal', playerMf = 0, gamePlayers = 1, partyPlayers = 1, isExpansion = true, } = options if (!tcName || tcName.trim() === '') { return [] } // 1. Top-level TC group upgrade (D2Game!6FC32D60) const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, nLevel) const effectiveTcName = resolvedNode?.name ?? tcName // 2. Setup RNG let rng: D2Rng if (options.monsterRng instanceof D2Rng) { rng = options.monsterRng } else if (options.monsterRng instanceof Rng) { rng = new D2Rng(options.monsterRng.int(0, 0x7FFFFFFF)) } else if (typeof options.monsterRng === 'number') { rng = new D2Rng(options.monsterRng >>> 0) } else { rng = new D2Rng((Math.random() * 0xFFFFFFFF) >>> 0, (Math.random() * 0xFFFFFFFF) >>> 0) } // 3. Expand TC tree const dropLeaves: DroppedItemEntry[] = expandTreasureClass(effectiveTcName, { rng, tcTable: dropTables.tcTable, autoTcTable: dropTables.autoTcTable, gamePlayers, partyPlayers, isExpansion, }) // 4. Resolve difficulty odds for base upgrades const diffKey = difficulty === 'hell' ? 'Hell' : difficulty === 'nightmare' ? 'Nightmare' : 'Normal' const odds = dropTables.difficultyLevels.get(diffKey) ?? DEFAULT_DIFFICULTY_LEVEL_ODDS[diffKey]! const isGood = monsterType > 1 const qf4 = isGood ? odds.uberCodeOddsGood : odds.uberCodeOddsNormal const qf5 = isGood ? odds.ultraCodeOddsGood : odds.ultraCodeOddsNormal const drops: Item[] = [] // 5. Process each dropped leaf for (const leaf of dropLeaves) { if (leaf.isGold) { const multiplier = leaf.goldMultiplier ?? 1 const baseGold = rng.randRange(Math.max(1, Math.trunc(nLevel / 2)), Math.max(5, nLevel * 5)) const goldAmount = Math.max(1, baseGold * multiplier) drops.push(goldItem(goldAmount)) continue } const base = dropTables.getBase(leaf.code) if (!base) { continue } // Base upgrade const upgradeResult = upgradeItemBase(base, qf4, qf5, rng, dropTables.getBase) const effectiveBase = upgradeResult.base const isUber = upgradeResult.isUber // Initialize item PRNG const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0) const itemRng = new D2Rng(dwInitSeed) // Check if the item can roll magical qualities (Weapons, Armor, Rings, Amulets, Jewels, Charms) const canHaveQuality = effectiveBase.kind === 'weapon' || effectiveBase.kind === 'armor' || effectiveBase.id === 'rin' || effectiveBase.id === 'amu' || effectiveBase.id === 'jew' || effectiveBase.id === 'cm1' || effectiveBase.id === 'cm2' || effectiveBase.id === 'cm3' // Roll quality let quality: ItemQualityTier if (!canHaveQuality) { quality = 'normal' } else if (leaf.forcedUnique) { quality = 'unique' } else if (leaf.forcedSet) { quality = 'set' } else { quality = rollItemQuality({ ilvl: nLevel, qlvl: effectiveBase.level ?? 1, magicFind: playerMf, tcFactors: { unique: leaf.qualityFactors[0], set: leaf.qualityFactors[1], rare: leaf.qualityFactors[2], magic: leaf.qualityFactors[3], }, table: dropTables.itemRatio, rng: itemRng, uber: isUber ? 1 : 0, classSpecific: 0, }) } let durabilityMultiplier = 1 // Quality branches & fallbacks if (quality === 'unique') { const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= nLevel) if (uniqueCandidates.length > 0) { let chosenUnique = uniqueCandidates[0]! if (uniqueCandidates.length > 1) { const totalRarity = uniqueCandidates.reduce((sum, u) => sum + Math.max(1, u.rarity), 0) let roll = itemRng.rand(totalRarity) for (const u of uniqueCandidates) { const weight = Math.max(1, u.rarity) if (roll < weight) { chosenUnique = u break } roll -= weight } } drops.push(createDroppedItem(effectiveBase, 'unique', { uniqueItem: chosenUnique, ilvl: nLevel, dwInitSeed, })) continue } // Degrade to Rare + 3x durability quality = 'rare' durabilityMultiplier = 3 } if (quality === 'set') { const setCandidates = dropTables.sets.items.entries.filter( s => s.item.toLowerCase() === effectiveBase.id.toLowerCase() && s.lvl <= nLevel, ) if (setCandidates.length > 0) { let chosenSet = setCandidates[0]! if (setCandidates.length > 1) { const totalRarity = setCandidates.reduce((sum, s) => sum + Math.max(1, s.rarity), 0) let roll = itemRng.rand(totalRarity) for (const s of setCandidates) { const weight = Math.max(1, s.rarity) if (roll < weight) { chosenSet = s break } roll -= weight } } drops.push(createDroppedItem(effectiveBase, 'set', { setItem: chosenSet, ilvl: nLevel, dwInitSeed, })) continue } // Degrade to Magic + 2x durability quality = 'magic' durabilityMultiplier = 2 } if (quality === 'rare') { const rareAffixes = rollRareAffixes( effectiveBase, nLevel, dropTables.magicAffixes, dropTables.rareNames, dropTables.isA, itemRng, ) drops.push(createDroppedItem(effectiveBase, 'rare', { rareAffixes, ilvl: nLevel, dwInitSeed, durabilityMultiplier, })) continue } if (quality === 'magic') { const magicAffixes = rollMagicAffixes( effectiveBase, nLevel, dropTables.magicAffixes, dropTables.isA, itemRng, ) drops.push(createDroppedItem(effectiveBase, 'magic', { magicAffixes, ilvl: nLevel, dwInitSeed, durabilityMultiplier, })) continue } if (quality === 'superior') { drops.push(createDroppedItem(effectiveBase, 'superior', { ilvl: nLevel, dwInitSeed, })) continue } if (quality === 'low') { drops.push(createDroppedItem(effectiveBase, 'low', { ilvl: nLevel, dwInitSeed, })) continue } // Default: normal quality drops.push(createDroppedItem(effectiveBase, 'normal', { ilvl: nLevel, dwInitSeed, })) } return drops }