diablo2-web/src/game/treasure-engine.ts

555 lines
18 KiB
TypeScript

/**
* 1.13c Diablo II TreasureClassEx Expansion Engine.
*
* Gold standard reference:
* - D2Game!6FC32380 (TreasureClass roll execution and enumeration)
* - D2Game!6FC32D60 (TreasureClass drop entry point / stack frame expansion)
* - D2Game!6FC32690 (NoDrop player count scaling and item probability calculation)
*
* Architecture:
* 1. 64-frame explicit stack:
* Avoids JavaScript call-stack overflow, enforces strict depth limits (<= 64),
* and supports deterministic pause/resume across nested sub-TC branches.
* 2. Step budget guard:
* Terminates execution if total engine steps exceed MAX_STEP_BUDGET (10,000),
* preventing infinite loops from circular/self-referential custom tables.
* 3. Precomputed prefix sum binary search:
* O(log K) interval sampling using precomputed cumulative probability arrays.
* 4. Tail call reuse:
* When a parent frame has remaining picks == 0, its stack slot is reused
* directly for the child sub-TC, preventing unnecessary stack growth.
* 5. Quality factor propagation:
* Child frames inherit quality modifiers (Unique, Set, Rare, Magic) using:
* child.qf[i] = (parent.qf[i] == 0) ? entry.qf[i] : Math.max(parent.qf[i], entry.qf[i])
* 6. Deterministic enumeration for negative picks (`Picks < 0`):
* Bypasses NoDrop and RNG completely, iterating through items up to TotalProb.
* 7. Double-precision NoDrop player count scaling (`Picks >= 0`):
* Calculated on EVERY pick:
* n = effectivePlayers = p + Math.trunc((g - p) / 2)
* if (NoDrop > 0 && n > 1):
* r = NoDrop / (NoDrop + TotalProb)
* NoDrop' = Math.trunc(TotalProb * (r ** n) / (1 - (r ** n)))
* else: NoDrop' = NoDrop
* 8. Gold and forced quality flags:
* - `gld`: drops gold with multiplier 1.
* - `gld,mul=N`: drops gold with multiplier `N >> 8` (or `N / 256`).
* - `flags & 1`: forced unique item quality (skips quality roll).
* - `flags & 2`: forced set item quality (skips quality roll).
*/
import type { D2Rng } from './d2-rng.ts'
import {
type TreasureClassTable,
type TreasureClassNode,
type TreasureClassItem,
type TreasureClassItemKind,
isAutoTC,
isClassicValidItem,
} from './treasure-class.ts'
/** Maximum explicit stack depth allowed before throwing stack overflow error. */
export const MAX_STACK_DEPTH = 64
/** Maximum execution step budget allowed before aborting infinite recursion. */
export const MAX_STEP_BUDGET = 10000
/**
* Quality factors array: [unique, set, rare, magic] (fractions of 1024).
* Matches columns: Unique, Set, Rare, Magic in `TreasureClassEx.txt`.
*/
export type QualityFactors = [number, number, number, number]
/**
* Virtual node representing an auto-TreasureClass (e.g. weap3..87, armo3..87, bow3..87, mele3..39).
*/
export interface AutoTcNode {
readonly name: string
readonly level?: number | undefined
readonly picks?: number | undefined
readonly unique?: number | undefined
readonly set?: number | undefined
readonly rare?: number | undefined
readonly magic?: number | undefined
readonly noDrop?: number | undefined
readonly items: readonly (TreasureClassItem | {
readonly item: string
readonly prob: number
readonly kind?: TreasureClassItemKind | undefined
readonly isDangling?: boolean | undefined
readonly isClassic?: boolean | undefined
readonly multiplier?: number | undefined
})[]
readonly totalProbClassic?: number | undefined
readonly totalProbExpansion?: number | undefined
readonly cumulativeProbExpansion?: readonly number[] | undefined
readonly flags?: number | undefined
}
/**
* Execution context for rolling drops from a TreasureClass.
*/
export interface DropContext {
/** 1.13c Diablo II Linear Congruential PRNG instance. */
rng: D2Rng
/** Total number of players in the game (defaults to 1). */
gamePlayers?: number | undefined
/** Number of party members nearby / in area (defaults to 1). */
partyPlayers?: number | undefined
/** Indexed TreasureClassEx table. */
tcTable: TreasureClassTable
/** Optional virtual auto-TreasureClass table (e.g. weap3, armo87). */
autoTcTable?: Map<string, AutoTcNode> | undefined
/** Whether Expansion (Lord of Destruction) items/runes are enabled (defaults to true). */
isExpansion?: boolean | undefined
/** Forced quality bitmask: 1 = forced unique, 2 = forced set. */
flags?: number | undefined
}
/**
* A single emitted item drop from TreasureClass expansion.
*/
export interface DroppedItemEntry {
/** The item code (e.g. 'swd', 'rin', 'r01', 'gld'). */
readonly code: string
/** Alias for code matching item property. */
readonly item: string
/** Whether this drop represents gold. */
readonly isGold: boolean
/** Multiplier for gold drops if applicable (e.g. 5 for 1280 >> 8). */
readonly goldMultiplier?: number | undefined
/** Inherited quality factors [unique, set, rare, magic]. */
readonly qualityFactors: QualityFactors
/** Effective forced quality flags bitmask. */
readonly flags: number
/** Forced quality classification if set: 'unique' | 'set'. */
readonly forcedQuality?: 'unique' | 'set' | undefined
/** True if forced unique (flags & 1). */
readonly forcedUnique: boolean
/** True if forced set (flags & 2). */
readonly forcedSet: boolean
}
/**
* Calculates effective player count for NoDrop scaling:
* n = effectivePlayers = p + Math.trunc((g - p) / 2)
*
* @param gamePlayers - Total players in the game.
* @param partyPlayers - Partied players nearby.
* @returns Integer effective player count (>= 1).
*/
export function computeEffectivePlayers(gamePlayers?: number, partyPlayers?: number): number {
const g = Math.max(1, Math.trunc(gamePlayers ?? 1))
const p = Math.max(1, Math.min(g, Math.trunc(partyPlayers ?? 1)))
return p + Math.trunc((g - p) / 2)
}
/**
* Calculates player-scaled NoDrop weight using double precision floating point:
* if (NoDrop > 0 && n > 1):
* r = NoDrop / (NoDrop + TotalProb)
* NoDrop' = Math.trunc(TotalProb * (r ** n) / (1 - (r ** n)))
* else: NoDrop' = NoDrop
*
* @param noDrop - Base NoDrop weight from table row.
* @param totalProb - Sum of probability weights for eligible items.
* @param effectivePlayers - Effective player count (n).
* @returns Scaled NoDrop weight (NoDrop').
*/
export function computeScaledNoDrop(noDrop: number, totalProb: number, effectivePlayers: number): number {
if (noDrop <= 0 || effectivePlayers <= 1 || totalProb <= 0) {
return Math.max(0, noDrop)
}
const r = noDrop / (noDrop + totalProb)
const rn = Math.pow(r, effectivePlayers)
if (rn >= 1) return 0
const denom = 1 - rn
if (denom <= 0) return 0
return Math.trunc(totalProb * rn / denom)
}
/**
* Propagates quality factors from parent frame to child frame:
* child.qf[i] = (parent.qf[i] == 0) ? entry.qf[i] : Math.max(parent.qf[i], entry.qf[i])
*
* @param parentQf - Quality factors of parent frame.
* @param entryQf - Quality factors defined on child TC row.
* @returns Newly combined QualityFactors tuple.
*/
export function propagateQualityFactors(parentQf: QualityFactors, entryQf: QualityFactors): QualityFactors {
const result: QualityFactors = [0, 0, 0, 0]
for (let i = 0; i < 4; i++) {
result[i] = parentQf[i] === 0 ? entryQf[i] : Math.max(parentQf[i], entryQf[i])
}
return result
}
/**
* Binary searches precomputed cumulative prefix sums for target value:
* Finds the smallest index `i` such that `cumulativeProb[i] > target`.
*
* @param cumulativeProb - Prefix sum array [prob0, prob0+prob1, ...].
* @param target - Target value in [0, TotalProb - 1].
* @returns Index of matching item.
*/
export function binarySearchPrefixSum(cumulativeProb: readonly number[], target: number): number {
let low = 0
let high = cumulativeProb.length - 1
let result = high
while (low <= high) {
const mid = (low + high) >> 1
if (cumulativeProb[mid] > target) {
result = mid
high = mid - 1
} else {
low = mid + 1
}
}
return result
}
/** Clean surrounding whitespace and quotes from an item or TC name. */
function cleanName(name: string): string {
let s = name.trim()
if (s.startsWith('"') && s.endsWith('"')) {
s = s.slice(1, -1).trim()
}
return s
}
/** Case-insensitive lookup in autoTcTable. */
function findAutoTc(autoTcTable: Map<string, AutoTcNode> | undefined, name: string): AutoTcNode | undefined {
if (!autoTcTable) return undefined
const direct = autoTcTable.get(name)
if (direct !== undefined) return direct
const lower = name.trim().toLowerCase()
for (const [k, v] of autoTcTable.entries()) {
if (k.trim().toLowerCase() === lower) return v
}
return undefined
}
/** Helper to construct a DroppedItemEntry complying with exactOptionalPropertyTypes. */
function buildDroppedItem(
code: string,
isGold: boolean,
qualityFactors: QualityFactors,
flags: number,
goldMultiplier?: number | undefined,
forcedQualityOverride?: 'unique' | 'set' | undefined,
): DroppedItemEntry {
const forcedUnique = forcedQualityOverride === 'unique' || (flags & 1) !== 0
const forcedSet = !forcedUnique && (forcedQualityOverride === 'set' || (flags & 2) !== 0)
const forcedQuality: 'unique' | 'set' | undefined = forcedUnique ? 'unique' : forcedSet ? 'set' : undefined
return {
code,
item: code,
isGold,
qualityFactors,
flags,
forcedUnique,
forcedSet,
...(goldMultiplier !== undefined ? { goldMultiplier } : {}),
...(forcedQuality !== undefined ? { forcedQuality } : {}),
}
}
/** Internal item representation ready for sampling or enumeration. */
interface ActiveItemEntry {
readonly item: string
readonly prob: number
readonly multiplier?: number | undefined
}
/** A single frame on the explicit 64-frame stack. */
interface StackFrame {
readonly node: TreasureClassNode | AutoTcNode
readonly qf: QualityFactors
readonly flags: number
readonly isNegativePicks: boolean
picksRemaining: number
negativePickIndex: number
// Negative picks enumeration list:
readonly negativeItems?: readonly ActiveItemEntry[] | undefined
// Positive picks precomputed items and prefix sums:
readonly eligibleItems?: readonly ActiveItemEntry[] | undefined
readonly totalProb?: number | undefined
readonly cumulativeProb?: readonly number[] | undefined
}
/** Constructs an initialized stack frame for a TC node. */
function createFrame(
node: TreasureClassNode | AutoTcNode,
qf: QualityFactors,
isExpansion: boolean,
flags: number,
): StackFrame {
const picks = node.picks ?? 1
const nodeFlags = (node as any).flags ?? 0
const combinedFlags = flags | nodeFlags
if (picks < 0) {
// Picks < 0: Deterministic enumeration up to TotalProb (each item repeated item.prob times)
const negativeItems: ActiveItemEntry[] = []
for (const it of node.items) {
const itemCode = cleanName(typeof it === 'string' ? it : it.item)
const prob = it.prob ?? 0
if (prob <= 0) continue
if (!isExpansion && !isClassicValidItem(itemCode)) continue
const multiplier = (it as any).multiplier
for (let k = 0; k < prob; k++) {
negativeItems.push({
item: itemCode,
prob,
...(multiplier !== undefined ? { multiplier } : {}),
})
}
}
return {
node,
qf,
flags: combinedFlags,
isNegativePicks: true,
picksRemaining: negativeItems.length,
negativePickIndex: 0,
negativeItems,
}
}
// Picks >= 0: Probabilistic rolls
let eligibleItems: ActiveItemEntry[]
let totalProb: number
let cumulativeProb: readonly number[]
if (
isExpansion &&
'cumulativeProbExpansion' in node &&
node.cumulativeProbExpansion &&
node.cumulativeProbExpansion.length === node.items.length
) {
eligibleItems = node.items.map(it => ({
item: cleanName(it.item),
prob: it.prob,
...((it as any).multiplier !== undefined ? { multiplier: (it as any).multiplier } : {}),
}))
totalProb = node.totalProbExpansion ?? 0
cumulativeProb = node.cumulativeProbExpansion
} else {
eligibleItems = []
const cProb: number[] = []
let sum = 0
for (const it of node.items) {
const itemCode = cleanName(typeof it === 'string' ? it : it.item)
const prob = it.prob ?? 0
if (prob <= 0) continue
if (!isExpansion && !isClassicValidItem(itemCode)) continue
const multiplier = (it as any).multiplier
eligibleItems.push({
item: itemCode,
prob,
...(multiplier !== undefined ? { multiplier } : {}),
})
sum += prob
cProb.push(sum)
}
totalProb = sum
cumulativeProb = cProb
}
return {
node,
qf,
flags: combinedFlags,
isNegativePicks: false,
picksRemaining: Math.max(0, picks),
negativePickIndex: 0,
eligibleItems,
totalProb,
cumulativeProb,
}
}
/**
* Rolls a TreasureClass and expands all nested drops down to base item codes or gold.
*
* Implements:
* - 64-frame explicit stack (stack overflow protection).
* - Step budget guard (10,000 steps max).
* - Quality factor propagation across frames.
* - Tail call reuse when parent frame has remaining picks == 0.
* - Deterministic enumeration for negative picks (`picks < 0`).
* - Floating-point player-scaled NoDrop recalculation on every pick (`picks >= 0`).
* - Gold multiplier handling (`gld,mul=N` -> `N >> 8`).
* - Forced quality flags (`flags & 1` -> forced unique, `flags & 2` -> forced set).
*
* @param tcName - Name of the initial TreasureClass to roll.
* @param context - Drop execution context (RNG, players, TC tables, mode).
* @returns Array of dropped item entries.
*/
export function rollTreasureClass(tcName: string, context: DropContext): DroppedItemEntry[] {
const isExpansion = context.isExpansion !== false
const initialFlags = context.flags ?? 0
const cleanedName = cleanName(tcName)
const rootNode = context.tcTable.get(cleanedName) ?? findAutoTc(context.autoTcTable, cleanedName)
if (!rootNode) {
return []
}
// Initial root quality factors: propagate from [0, 0, 0, 0] to root's own factors
const rootEntryQf: QualityFactors = [
rootNode.unique ?? 0,
rootNode.set ?? 0,
rootNode.rare ?? 0,
rootNode.magic ?? 0,
]
const rootQf = propagateQualityFactors([0, 0, 0, 0], rootEntryQf)
const rootFrame = createFrame(rootNode, rootQf, isExpansion, initialFlags)
const stack: StackFrame[] = [rootFrame]
const drops: DroppedItemEntry[] = []
let steps = 0
while (stack.length > 0) {
steps++
if (steps > MAX_STEP_BUDGET) {
throw new Error(`TreasureClass step budget exceeded: ${MAX_STEP_BUDGET} steps`)
}
const frame = stack[stack.length - 1]!
if (frame.picksRemaining <= 0) {
stack.pop()
continue
}
// Decrement remaining picks for this frame
frame.picksRemaining--
const remainingPicks = frame.picksRemaining
let selectedItem: string
let itemMultiplier: number | undefined
if (frame.isNegativePicks) {
// Deterministic enumeration: bypass NoDrop and RNG completely
const entry = frame.negativeItems![frame.negativePickIndex++]!
selectedItem = entry.item
itemMultiplier = entry.multiplier
} else {
// Positive picks: calculate NoDrop' with player scaling
const effectivePlayers = computeEffectivePlayers(context.gamePlayers, context.partyPlayers)
const baseNoDrop = frame.node.noDrop ?? 0
const totalProb = frame.totalProb ?? 0
const scaledNoDrop = computeScaledNoDrop(baseNoDrop, totalProb, effectivePlayers)
const totalWeight = scaledNoDrop + totalProb
if (totalWeight <= 0) {
// Nothing can drop
if (remainingPicks === 0) {
stack.pop()
}
continue
}
const roll = context.rng.rand(totalWeight)
if (roll < scaledNoDrop) {
// NoDrop roll: this pick drops nothing
if (remainingPicks === 0) {
stack.pop()
}
continue
}
// Hit an item: binary search on prefix sums for roll - scaledNoDrop
const target = roll - scaledNoDrop
const itemIndex = binarySearchPrefixSum(frame.cumulativeProb!, target)
const entry = frame.eligibleItems![itemIndex]!
selectedItem = entry.item
itemMultiplier = entry.multiplier
}
// Effective flags for this item
let effectiveFlags = frame.flags
if (selectedItem === 'Annihilus') {
effectiveFlags |= 1
}
// Classify node: Gold, Sub-TC, Auto-TC, or Base item
if (selectedItem === 'gld') {
drops.push(buildDroppedItem('gld', true, frame.qf, effectiveFlags, 1))
if (remainingPicks === 0) {
stack.pop()
}
continue
}
if (selectedItem.startsWith('gld,mul=')) {
let rawMul = itemMultiplier
if (rawMul === undefined) {
const parsed = parseInt(selectedItem.slice('gld,mul='.length), 10)
rawMul = isNaN(parsed) ? 256 : parsed
}
const goldMultiplier = rawMul >> 8
drops.push(buildDroppedItem('gld', true, frame.qf, effectiveFlags, goldMultiplier))
if (remainingPicks === 0) {
stack.pop()
}
continue
}
// Check if sub-TC in tcTable or auto-TC in autoTcTable
const childNode = context.tcTable.get(selectedItem) ?? findAutoTc(context.autoTcTable, selectedItem)
if (childNode !== undefined) {
// Sub-TC or Auto-TC: propagate quality factors and push/tail-call frame
const childEntryQf: QualityFactors = [
childNode.unique ?? 0,
childNode.set ?? 0,
childNode.rare ?? 0,
childNode.magic ?? 0,
]
const childQf = propagateQualityFactors(frame.qf, childEntryQf)
const childFrame = createFrame(childNode, childQf, isExpansion, effectiveFlags)
if (remainingPicks === 0) {
// Tail call reuse: reuse parent frame's slot
stack[stack.length - 1] = childFrame
} else {
// Push to stack: check max depth limit
if (stack.length >= MAX_STACK_DEPTH) {
throw new Error(`TreasureClass stack overflow: exceeded maximum depth of ${MAX_STACK_DEPTH}`)
}
stack.push(childFrame)
}
continue
}
// Leaf item drop (Base item code, Annihilus, or unexpanded auto-TC reference)
drops.push(
buildDroppedItem(
selectedItem,
false,
frame.qf,
effectiveFlags,
undefined,
selectedItem === 'Annihilus' ? 'unique' : undefined,
),
)
if (remainingPicks === 0) {
stack.pop()
}
}
return drops
}
/** Alias for rollTreasureClass conforming to spec nomenclature. */
export const expandTreasureClass = rollTreasureClass
export { resolveTreasureClassGroup } from './treasure-class.ts'