diablo2-web/src/game/drop-pipeline.ts

1199 lines
37 KiB
TypeScript

/**
* 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,
type RolledItemProp,
} from './items.ts'
import {
type MonsterKind,
readMonsterKinds,
getMonsterTreasureClass,
type Difficulty,
readSuperUniques,
type SuperUnique,
} from './monsters.ts'
import {
rollMagicAffixes,
rollRareAffixes,
getItemTypeCode,
NON_RANDOM_RANGE_CODES,
type GeneratedAffixes,
type GeneratedRareAffixes,
} from './affix-generator.ts'
import {
type AutoMagicTable,
loadAutoMagic,
CANONICAL_AUTOMAGIC_TABLE,
rollAutoMagic,
type RolledAutoMagic,
type RolledAutoMagicMod,
resolveAutoPrefixGroup,
ITEM_TYPE_AUTO_PREFIX_MAP,
rollStaffMods,
resolveStaffModsClass,
ITEM_TYPE_STAFF_MODS_MAP,
STAFF_MOD_CLASS_SKILL_START,
} from './automagic.ts'
export {
rollAutoMagic,
type RolledAutoMagic,
type RolledAutoMagicMod,
resolveAutoPrefixGroup,
ITEM_TYPE_AUTO_PREFIX_MAP,
rollStaffMods,
resolveStaffModsClass,
ITEM_TYPE_STAFF_MODS_MAP,
STAFF_MOD_CLASS_SKILL_START,
}
/**
* Complete collection of parsed game data tables required for the drop pipeline.
*/
export interface DropTables {
readonly tcTable: TreasureClassTable
readonly autoTcTable: Map<string, TreasureClassNode>
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 autoMagic?: AutoMagicTable | undefined
readonly itemTypes: ItemTypeTable
readonly monsterKinds: Map<string, MonsterKind>
readonly superUniques: Map<string, SuperUnique>
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<DropTables> {
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'),
])
let autoMagic: AutoMagicTable = CANONICAL_AUTOMAGIC_TABLE
try {
autoMagic = await loadAutoMagic(archives)
} catch {
autoMagic = CANONICAL_AUTOMAGIC_TABLE
}
const rawAutoTcTable = generateAutoTreasureClasses(itemTypes, armor, weapons)
const autoTcTable = new Map<string, TreasureClassNode>()
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<string, SuperUnique>()
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,
autoMagic,
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
}
/**
* Checks whether an item base can spawn as Rare in Diablo II 1.13c.
*
* Ground truth rules (ItemTypes.txt & D2Common.dll):
* - Charms (cm1, cm2, cm3 / 'char') NEVER spawn as Rare.
* - Items with `rare === false` in ItemTypes.txt cannot be Rare.
* - Items with `normal === true` in ItemTypes.txt (potions, scrolls, runes, gems, keys) cannot be Rare.
* - Weapons, Armor, Rings, Amulets, Jewels CAN spawn as Rare.
*/
export function canItemBeRare(
base: ItemBase,
itemTypes?: ItemTypeTable,
isAPredicate?: Function,
): boolean {
if (base.id === 'cm1' || base.id === 'cm2' || base.id === 'cm3') {
return false
}
const typeCode = getItemTypeCode(base).toLowerCase()
if (typeCode === 'char' || typeCode === 'cm1' || typeCode === 'cm2' || typeCode === 'cm3' || typeCode === 'scha' || typeCode === 'mcha' || typeCode === 'lcha') {
return false
}
if (isAPredicate && (isAPredicate(typeCode, 'char') || isAPredicate(typeCode, 'cm1') || isAPredicate(typeCode, 'cm2') || isAPredicate(typeCode, 'cm3'))) {
return false
}
if (itemTypes) {
const def = itemTypes.byCode.get(typeCode)
if (def) {
if (def.charm || !def.rare || def.normal) {
return false
}
}
}
return true
}
/**
* Resolved ItemTypes.txt quality and class flags for an item base (D2 1.13c Ground Truth).
*/
export interface ItemTypeFlags {
readonly isNormalOnly: boolean
readonly isAlwaysMagic: boolean
readonly canBeRare: boolean
readonly isClassSpecific: boolean
}
const ALWAYS_MAGIC_BASE_IDS = new Set(['rin', 'amu', 'jew', 'cm1', 'cm2', 'cm3'])
const ALWAYS_MAGIC_TYPE_CODES = new Set(['ring', 'amul', 'jewl', 'char', 'scha', 'mcha', 'lcha', 'mboq', 'mxbq'])
const CLASS_SPECIFIC_TYPE_CODES = new Set([
'clas',
'amaz',
'barb',
'necr',
'pala',
'sorc',
'assn',
'drui',
'abow',
'aspe',
'ajav',
'orb',
'head',
'voodoo',
'ashd',
'phlm',
'pelt',
'cloa',
'h2h',
'h2h2',
])
/**
* Resolves quality availability and class-specific flags from `ItemTypes.txt` for a given item base.
*
* Ground truth rules (ItemTypes.txt & D2Game!6FC2FC40):
* - `isNormalOnly`: `def.normal === true` (and not ring/amu/jew/cm1..3/equippable weapon/armor).
* - `isAlwaysMagic`: `def.magic === true` or `base.id` in `rin`/`amu`/`jew`/`cm1`/`cm2`/`cm3`.
* Minimum quality for these items is always Magic (never Superior/Normal/Low).
* - `canBeRare`: delegates to `canItemBeRare(base, itemTypes, isAPredicate)`.
* - `isClassSpecific`: `def.class !== ''` or any `equiv1`/`equiv2` ancestor has `class !== ''`.
*/
export function resolveItemTypeFlags(
base: ItemBase,
itemTypes?: ItemTypeTable,
isAPredicate?: (type: string, target: string) => boolean,
): ItemTypeFlags {
const baseId = (base.id ?? '').trim().toLowerCase()
const typeCode = getItemTypeCode(base).trim().toLowerCase()
const def = itemTypes?.byCode.get(typeCode)
const isAlwaysMagic = Boolean(
def?.magic ||
def?.charm ||
ALWAYS_MAGIC_BASE_IDS.has(baseId) ||
ALWAYS_MAGIC_TYPE_CODES.has(typeCode),
)
const isEquippableWeaponOrArmor =
(base.kind === 'weapon' || base.kind === 'armor') &&
typeCode !== 'tpot' &&
typeCode !== 'bowq' &&
typeCode !== 'xboq'
const isNormalOnly = !isAlwaysMagic && !isEquippableWeaponOrArmor && Boolean(def ? def.normal : true)
const canBeRare = canItemBeRare(base, itemTypes, isAPredicate)
let isClassSpecific = CLASS_SPECIFIC_TYPE_CODES.has(typeCode)
if (!isClassSpecific && itemTypes) {
const visited = new Set<string>()
const queue: string[] = [typeCode]
while (queue.length > 0) {
const curr = queue.shift()!.trim().toLowerCase()
if (!curr || visited.has(curr)) continue
visited.add(curr)
if (CLASS_SPECIFIC_TYPE_CODES.has(curr)) {
isClassSpecific = true
break
}
const currDef = itemTypes.byCode.get(curr)
if (!currDef) continue
if (currDef.class && currDef.class.trim() !== '') {
isClassSpecific = true
break
}
if (currDef.equiv1) queue.push(currDef.equiv1)
if (currDef.equiv2) queue.push(currDef.equiv2)
}
}
if (!isClassSpecific && isAPredicate) {
if (isAPredicate(typeCode, 'clas')) {
isClassSpecific = true
}
}
return {
isNormalOnly,
isAlwaysMagic,
canBeRare,
isClassSpecific,
}
}
/**
* Canonical 1.13c Low Quality prefixes (4 subtypes: 0..3).
*/
export const LOW_QUALITY_PREFIXES = ['Low Quality', 'Cracked', 'Crude', 'Damaged'] as const
export interface EtherealAndSocketsRoll {
readonly ethereal: boolean
readonly sockets: number
}
/**
* Rolls Ethereal status (1/20 chance) and Socket count (1/3 chance for normal/superior/low)
* according to Diablo II 1.13c rules (D2Game!6FC30090).
*/
export function rollItemEtherealAndSockets(
base: ItemBase,
qualityTier: ItemQualityTier,
ilvl: number,
itemRng: D2Rng,
options?: {
readonly uniqueItem?: UniqueItem | undefined
readonly itemTypes?: ItemTypeTable | undefined
readonly rolledProps?: readonly RolledItemProp[] | undefined
},
): EtherealAndSocketsRoll {
let ethereal = false
let sockets = 0
const isWeaponOrArmor = base.kind === 'weapon' || base.kind === 'armor'
const rawDur = Number((base as any).durability) || 0
const noDur = Boolean((base as any).nodurability) || Boolean((base as any).indestructible)
const hasFixedIndestruct = Boolean(
options?.uniqueItem?.props.some(p => p.code.trim().toLowerCase() === 'indestruct') ||
options?.rolledProps?.some(p => p.code.trim().toLowerCase() === 'indestruct'),
)
const hasFixedEthereal = Boolean(
options?.uniqueItem?.props.some(p => p.code.trim().toLowerCase() === 'ethereal') ||
options?.rolledProps?.some(p => p.code.trim().toLowerCase() === 'ethereal'),
)
if (hasFixedEthereal) {
ethereal = true
} else if (isWeaponOrArmor && rawDur > 0 && !noDur && qualityTier !== 'set' && !hasFixedIndestruct) {
if (itemRng.rand(20) === 0) {
ethereal = true
}
}
if (
isWeaponOrArmor &&
(qualityTier === 'normal' || qualityTier === 'superior' || qualityTier === 'low')
) {
const baseMaxSockets = Number((base as any).gemsockets) || 0
if (baseMaxSockets > 0) {
const typeCode = getItemTypeCode(base).trim().toLowerCase()
const def = options?.itemTypes?.byCode.get(typeCode)
const typeMaxSockets = def
? ilvl >= 40
? def.maxSock40
: ilvl >= 25
? def.maxSock25
: def.maxSock1
: baseMaxSockets
const maxAllowedSockets = Math.min(baseMaxSockets, typeMaxSockets)
if (maxAllowedSockets > 0 && itemRng.rand(3) === 0) {
sockets = itemRng.randRange(1, maxAllowedSockets)
}
}
}
return { ethereal, sockets }
}
/**
* 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 autoMagicAffix?: RolledAutoMagic | null | undefined
readonly staffMods?: readonly RolledItemProp[] | undefined
readonly rolledProps?: readonly RolledItemProp[] | undefined
readonly ethereal?: boolean | undefined
readonly sockets?: number | undefined
readonly itemTypes?: ItemTypeTable | undefined
readonly ilvl: number
readonly dwInitSeed: number
readonly durabilityMultiplier?: number | undefined
readonly itemRng?: D2Rng | undefined
},
): Item {
const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL
let rawFlags = ItemFlag.IDENTIFIED
let name = base.name
let prefix = null
let suffix = null
let qualitySubtype: number | undefined = undefined
const stats: Record<string, number> = {}
const rolledProps: RolledItemProp[] = []
if (base.damage > 0) stats.damage = base.damage
if (base.defense > 0) stats.defense = base.defense
const propRng = options.itemRng ?? new D2Rng(options.dwInitSeed)
const resolveItemPropValue = (prop: { code: string; par?: string | number; min: number; max: number }): number => {
const codeLower = prop.code.toLowerCase()
if (NON_RANDOM_RANGE_CODES.has(codeLower)) {
return prop.min
}
if (prop.min === prop.max) {
return prop.min
}
return propRng.randRange(prop.min, prop.max)
}
if (options.rolledProps && options.rolledProps.length > 0) {
for (const p of options.rolledProps) {
rolledProps.push(p)
stats[p.code] = (stats[p.code] ?? 0) + p.value
}
}
if (qualityTier === 'unique' && options.uniqueItem) {
name = options.uniqueItem.index
if (!options.rolledProps || options.rolledProps.length === 0) {
for (const prop of options.uniqueItem.props) {
if (prop.code) {
const val = resolveItemPropValue(prop)
rolledProps.push({
code: prop.code,
param: prop.par,
min: prop.min,
max: prop.max,
value: val,
})
stats[prop.code] = (stats[prop.code] ?? 0) + val
}
}
}
} else if (qualityTier === 'set' && options.setItem) {
name = options.setItem.index
if (!options.rolledProps || options.rolledProps.length === 0) {
for (const prop of options.setItem.props) {
if (prop.code) {
const val = resolveItemPropValue(prop)
rolledProps.push({
code: prop.code,
param: prop.par,
min: prop.min,
max: prop.max,
value: val,
})
stats[prop.code] = (stats[prop.code] ?? 0) + val
}
}
}
} else if (qualityTier === 'rare' && options.rareAffixes) {
name = options.rareAffixes.name
for (const affix of options.rareAffixes.affixes) {
for (const mod of affix.mods) {
rolledProps.push({
code: mod.code,
param: mod.param,
min: mod.min,
max: mod.max,
value: mod.value,
})
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) {
rolledProps.push({
code: mod.code,
param: mod.param,
min: mod.min,
max: mod.max,
value: mod.value,
})
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) {
rolledProps.push({
code: mod.code,
param: mod.param,
min: mod.min,
max: mod.max,
value: mod.value,
})
stats[mod.code] = (stats[mod.code] ?? 0) + mod.value
}
}
} else if (qualityTier === 'superior') {
name = `Superior ${base.name}`
if (!options.rolledProps || options.rolledProps.length === 0) {
if (base.kind === 'armor') {
const acPct = propRng.randRange(5, 15)
rolledProps.push({
code: 'ac%',
min: 5,
max: 15,
value: acPct,
})
stats['ac%'] = (stats['ac%'] ?? 0) + acPct
if (propRng.rand(2) === 0) {
const durPct = propRng.randRange(10, 15)
rolledProps.push({
code: 'dur%',
min: 10,
max: 15,
value: durPct,
})
stats['dur%'] = (stats['dur%'] ?? 0) + durPct
}
} else if (base.kind === 'weapon') {
const dmgPct = propRng.randRange(5, 15)
rolledProps.push({
code: 'dmg%',
min: 5,
max: 15,
value: dmgPct,
})
stats['dmg%'] = (stats['dmg%'] ?? 0) + dmgPct
if (propRng.rand(2) === 0) {
const attVal = propRng.randRange(1, 3)
rolledProps.push({
code: 'att',
min: 1,
max: 3,
value: attVal,
})
stats['att'] = (stats['att'] ?? 0) + attVal
}
}
}
} else if (qualityTier === 'low') {
qualitySubtype = propRng.rand(4)
const lowPrefix = LOW_QUALITY_PREFIXES[qualitySubtype] ?? 'Cracked'
name = `${lowPrefix} ${base.name}`
}
let effectiveAutoMagic = options.autoMagicAffix
if (effectiveAutoMagic === undefined && qualityTier !== 'unique' && qualityTier !== 'set') {
const autoPrefixGroup = resolveAutoPrefixGroup(base)
if (autoPrefixGroup !== undefined && autoPrefixGroup > 0) {
effectiveAutoMagic = rollAutoMagic(
base,
options.ilvl,
CANONICAL_AUTOMAGIC_TABLE,
(a, b) => a === b,
propRng,
{ group: autoPrefixGroup, isRare: qualityTier === 'rare' },
)
}
}
if (qualityTier !== 'unique' && qualityTier !== 'set') {
if (effectiveAutoMagic) {
for (const mod of effectiveAutoMagic.mods) {
rolledProps.push({
code: mod.code,
param: mod.param,
min: mod.min,
max: mod.max,
value: mod.value,
})
stats[mod.code] = (stats[mod.code] ?? 0) + mod.value
}
}
if (options.staffMods && options.staffMods.length > 0) {
for (const sm of options.staffMods) {
rolledProps.push({
code: sm.code,
param: sm.param,
min: sm.min,
max: sm.max,
value: sm.value,
})
stats[sm.code] = (stats[sm.code] ?? 0) + sm.value
}
}
}
const isEthereal = Boolean(
options.ethereal || rolledProps.some(p => p.code.trim().toLowerCase() === 'ethereal'),
)
const socketCount = options.sockets !== undefined && options.sockets > 0 ? options.sockets : 0
if (isEthereal) {
rawFlags |= ItemFlag.ETHEREAL
stats.ethereal = 1
}
if (socketCount > 0) {
rawFlags |= ItemFlag.SOCKETED
}
const rawDurability = (base as any).durability ?? 0
const mult = options.durabilityMultiplier ?? 1
let durability: number | undefined = undefined
let maxDurability: number | undefined = undefined
if (typeof rawDurability === 'number' && rawDurability > 0) {
if (qualityTier === 'low') {
maxDurability = Math.max(1, Math.floor(rawDurability / 3))
} else {
maxDurability = Math.round(rawDurability * mult)
}
durability = maxDurability
}
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,
...(qualitySubtype !== undefined ? { qualitySubtype } : {}),
...(isEthereal ? { ethereal: true } : {}),
...(socketCount > 0 ? { sockets: socketCount, totalSockets: socketCount } : {}),
...(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 } : {}),
...(effectiveAutoMagic ? { rolledAutoMagic: effectiveAutoMagic } : {}),
...(options.staffMods && options.staffMods.length > 0 ? { staffMods: options.staffMods } : {}),
...(rolledProps.length > 0 ? { rolledProps } : {}),
} 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<DropPipelineOptions, 'dropTables'>,
): 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,
})
if (
dropLeaves.length === 0 &&
!dropTables.tcTable.get(effectiveTcName) &&
!dropTables.autoTcTable.get(effectiveTcName) &&
dropTables.getBase(effectiveTcName)
) {
dropLeaves.push({
code: effectiveTcName,
item: effectiveTcName,
isGold: false,
qualityFactors: [0, 0, 0, 0],
flags: 0,
forcedUnique: false,
forcedSet: false,
})
}
// 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)
// Resolve ItemTypes.txt quality & class-specific flags
const typeFlags = resolveItemTypeFlags(effectiveBase, dropTables.itemTypes, dropTables.isA)
// Check if the item can roll magical qualities
const canHaveQuality =
!typeFlags.isNormalOnly &&
(effectiveBase.kind === 'weapon' ||
effectiveBase.kind === 'armor' ||
typeFlags.isAlwaysMagic)
// 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: typeFlags.isClassSpecific ? 1 : 0,
allowRare: typeFlags.canBeRare,
allowSuperior: !typeFlags.isAlwaysMagic,
})
if (
typeFlags.isAlwaysMagic &&
(quality === 'normal' || quality === 'low' || quality === 'superior')
) {
quality = 'magic'
}
}
let durabilityMultiplier = 1
// Quality branches & fallbacks (D2 1.13c Ground Truth)
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
}
}
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'unique', nLevel, itemRng, {
uniqueItem: chosenUnique,
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'unique', {
uniqueItem: chosenUnique,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
itemRng,
}))
continue
}
// Unique check failed (no eligible unique exists or ilvl < unique lvl)
// D2 1.13c Fallback:
// If the item can be Rare: Degrade to Rare + 3x durability (Triple Durability)
// If the item CANNOT be Rare (e.g. Charms): Degrade to Magic + 2x durability (Double Durability)
if (canItemBeRare(effectiveBase, dropTables.itemTypes, dropTables.isA)) {
quality = 'rare'
durabilityMultiplier = 3
} else {
quality = 'magic'
durabilityMultiplier = 2
}
}
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
}
}
// 1.13c Ground Truth: Set items NEVER spawn Ethereal
drops.push(createDroppedItem(effectiveBase, 'set', {
setItem: chosenSet,
ethereal: false,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
itemRng,
}))
continue
}
// Set check failed (no eligible set exists or ilvl < set lvl, e.g. Charms)
// D2 1.13c Fallback: Degrade to Magic + 2x durability (Double Durability)
quality = 'magic'
durabilityMultiplier = 2
}
if (quality === 'rare') {
// Check if item can actually be rare (e.g. charms can NEVER be rare)
if (!canItemBeRare(effectiveBase, dropTables.itemTypes, dropTables.isA)) {
quality = 'magic'
durabilityMultiplier = Math.max(durabilityMultiplier, 2)
} else {
const rareAffixes = rollRareAffixes(
effectiveBase,
nLevel,
dropTables.magicAffixes,
dropTables.rareNames,
dropTables.isA,
itemRng,
)
if (rareAffixes && rareAffixes.affixes && rareAffixes.affixes.length > 0) {
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
const autoMagicAffix =
autoPrefixGroup !== undefined
? rollAutoMagic(
effectiveBase,
nLevel,
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
dropTables.isA,
itemRng,
{ group: autoPrefixGroup, isRare: true },
)
: null
const staffMods =
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
: undefined
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'rare', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'rare', {
rareAffixes,
autoMagicAffix,
staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
durabilityMultiplier,
itemRng,
}))
continue
}
// Rare affix roll failed: Degrade to Magic + 2x durability
quality = 'magic'
durabilityMultiplier = Math.max(durabilityMultiplier, 2)
}
}
if (quality === 'magic') {
const magicAffixes = rollMagicAffixes(
effectiveBase,
nLevel,
dropTables.magicAffixes,
dropTables.isA,
itemRng,
)
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
const autoMagicAffix =
autoPrefixGroup !== undefined
? rollAutoMagic(
effectiveBase,
nLevel,
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
dropTables.isA,
itemRng,
{ group: autoPrefixGroup, isRare: false },
)
: null
const staffMods =
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
: undefined
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'magic', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'magic', {
magicAffixes,
autoMagicAffix,
staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
durabilityMultiplier,
itemRng,
}))
continue
}
const autoPrefixGroup = resolveAutoPrefixGroup(effectiveBase, dropTables.itemTypes, dropTables.isA)
const autoMagicAffix =
autoPrefixGroup !== undefined
? rollAutoMagic(
effectiveBase,
nLevel,
dropTables.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE,
dropTables.isA,
itemRng,
{ group: autoPrefixGroup, isRare: false },
)
: null
const staffMods =
resolveStaffModsClass(effectiveBase, dropTables.itemTypes) !== undefined
? rollStaffMods(effectiveBase, nLevel, dropTables.itemTypes, itemRng)
: undefined
if (quality === 'superior') {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'superior', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'superior', {
autoMagicAffix,
staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
itemRng,
}))
continue
}
if (quality === 'low') {
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'low', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'low', {
autoMagicAffix,
staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
itemRng,
}))
continue
}
// Default: normal quality
const ethAndSock = rollItemEtherealAndSockets(effectiveBase, 'normal', nLevel, itemRng, {
itemTypes: dropTables.itemTypes,
})
drops.push(createDroppedItem(effectiveBase, 'normal', {
autoMagicAffix,
staffMods,
ethereal: ethAndSock.ethereal,
sockets: ethAndSock.sockets,
itemTypes: dropTables.itemTypes,
ilvl: nLevel,
dwInitSeed,
itemRng,
}))
}
return drops
}