555 lines
18 KiB
TypeScript
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'
|
|
|