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

612 lines
18 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,
} 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<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 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'),
])
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,
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<string, number> = {}
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<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,
})
// 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
}