diff --git a/src/game/treasure-engine.ts b/src/game/treasure-engine.ts new file mode 100644 index 0000000..ed29ed3 --- /dev/null +++ b/src/game/treasure-engine.ts @@ -0,0 +1,548 @@ +/** + * 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 | 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 | 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 +} diff --git a/tests/treasure-engine.test.ts b/tests/treasure-engine.test.ts new file mode 100644 index 0000000..ef3ab15 --- /dev/null +++ b/tests/treasure-engine.test.ts @@ -0,0 +1,835 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'node:fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { parseTable } from '../src/game/acts.ts' +import { D2Rng } from '../src/game/d2-rng.ts' +import { + loadTreasureClasses, + parseTreasureClassTable, + TREASURE_CLASS_EX_TABLE_PATH, + type TreasureClassTable, + type TreasureClassNode, +} from '../src/game/treasure-class.ts' +import { + rollTreasureClass, + computeEffectivePlayers, + computeScaledNoDrop, + propagateQualityFactors, + binarySearchPrefixSum, + MAX_STACK_DEPTH, + MAX_STEP_BUDGET, + type AutoTcNode, + type DropContext, + type QualityFactors, +} from '../src/game/treasure-engine.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('TreasureClassEx Expansion Engine (Issue #101)', () => { + let table: TreasureClassTable + + beforeAll(async () => { + if (!hasD2) { + throw new Error('samples/d2 MPQ files required for TreasureClassEx test') + } + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + const bytes = await archives.read(TREASURE_CLASS_EX_TABLE_PATH) + table = parseTreasureClassTable(parseTable(bytes)) + }) + + describe('Mathematical Helper Functions', () => { + it('verifies computeEffectivePlayers scaling across party and game counts', () => { + // 1 player + expect(computeEffectivePlayers(1, 1)).toBe(1) + expect(computeEffectivePlayers()).toBe(1) + expect(computeEffectivePlayers(undefined, undefined)).toBe(1) + + // Solo players in multi-player games: n = 1 + trunc((g - 1) / 2) + expect(computeEffectivePlayers(2, 1)).toBe(1) // /players 2 solo -> n=1 + expect(computeEffectivePlayers(3, 1)).toBe(2) // /players 3 solo -> n=2 + expect(computeEffectivePlayers(4, 1)).toBe(2) // /players 4 solo -> n=2 + expect(computeEffectivePlayers(5, 1)).toBe(3) // /players 5 solo -> n=3 + expect(computeEffectivePlayers(6, 1)).toBe(3) // /players 6 solo -> n=3 + expect(computeEffectivePlayers(7, 1)).toBe(4) // /players 7 solo -> n=4 + expect(computeEffectivePlayers(8, 1)).toBe(4) // /players 8 solo -> n=4 + + // Partied players nearby: n = p + trunc((g - p) / 2) + expect(computeEffectivePlayers(8, 8)).toBe(8) + expect(computeEffectivePlayers(8, 6)).toBe(7) // 6 + trunc(2/2) = 7 + expect(computeEffectivePlayers(8, 4)).toBe(6) // 4 + trunc(4/2) = 6 + expect(computeEffectivePlayers(8, 2)).toBe(5) // 2 + trunc(6/2) = 5 + + // Boundary clamps + expect(computeEffectivePlayers(0, 0)).toBe(1) + expect(computeEffectivePlayers(2, 5)).toBe(2) // p clamped to g + }) + + it('verifies computeScaledNoDrop double precision scaling', () => { + // Act 1 H2H A: NoDrop=100, TotalProb=60 + // 1 player (n=1): NoDrop' = 100 + expect(computeScaledNoDrop(100, 60, 1)).toBe(100) + + // 8 players solo (n=4): + // r = 100 / 160 = 0.625 + // r^4 = 0.152587890625 + // 60 * r^4 / (1 - r^4) = 10.8037... -> Math.trunc -> 10 + expect(computeScaledNoDrop(100, 60, 4)).toBe(10) + + // 8 players party (n=8): + // r^8 = 0.023283064365386963 + // 60 * r^8 / (1 - r^8) = 1.43027... -> Math.trunc -> 1 + expect(computeScaledNoDrop(100, 60, 8)).toBe(1) + + // NoDrop = 0 stays 0 + expect(computeScaledNoDrop(0, 60, 8)).toBe(0) + + // TotalProb = 0 returns NoDrop + expect(computeScaledNoDrop(100, 0, 8)).toBe(100) + }) + + it('verifies propagateQualityFactors formula across frames', () => { + // Root frame: parent is [0, 0, 0, 0] -> inherits entry factors + const rootQf = propagateQualityFactors([0, 0, 0, 0], [883, 883, 883, 1024]) + expect(rootQf).toEqual([883, 883, 883, 1024]) + + // Child with zero factors inherits parent non-zero factors + const childQf = propagateQualityFactors([883, 883, 883, 1024], [0, 0, 0, 0]) + expect(childQf).toEqual([883, 883, 883, 1024]) + + // Child with non-zero factors uses Math.max(parent, entry) + const maxQf = propagateQualityFactors([500, 900, 0, 0], [800, 800, 800, 1024]) + expect(maxQf).toEqual([800, 900, 800, 1024]) + }) + + it('verifies binarySearchPrefixSum interval mapping', () => { + // Act 1 H2H A prefix sums: [21, 37, 58, 60] (4 items) + const cProb = [21, 37, 58, 60] + + // Item 0: target in [0, 20] + expect(binarySearchPrefixSum(cProb, 0)).toBe(0) + expect(binarySearchPrefixSum(cProb, 20)).toBe(0) + + // Item 1: target in [21, 36] + expect(binarySearchPrefixSum(cProb, 21)).toBe(1) + expect(binarySearchPrefixSum(cProb, 36)).toBe(1) + + // Item 2: target in [37, 57] + expect(binarySearchPrefixSum(cProb, 37)).toBe(2) + expect(binarySearchPrefixSum(cProb, 57)).toBe(2) + + // Item 3: target in [58, 59] + expect(binarySearchPrefixSum(cProb, 58)).toBe(3) + expect(binarySearchPrefixSum(cProb, 59)).toBe(3) + }) + }) + + describe('NoDrop & Player Scaling Drop Rates (Act 1 H2H A)', () => { + it('verifies Act 1 H2H A 1-player empty drop rate is 100/160 = 62.5%', () => { + const act1H2HA = table.get('Act 1 H2H A')! + expect(act1H2HA.noDrop).toBe(100) + expect(act1H2HA.totalProbExpansion).toBe(60) + + const rng = new D2Rng(123456789) + const iterations = 20000 + let emptyCount = 0 + + for (let i = 0; i < iterations; i++) { + const drops = rollTreasureClass('Act 1 H2H A', { + rng, + tcTable: table, + gamePlayers: 1, + partyPlayers: 1, + }) + if (drops.length === 0) { + emptyCount++ + } + } + + const emptyRate = emptyCount / iterations + const expectedRate = 100 / 160 // 0.625 + + // Within statistical tolerance (+/- 1.5%) + expect(emptyRate).toBeGreaterThanOrEqual(expectedRate - 0.015) + expect(emptyRate).toBeLessThanOrEqual(expectedRate + 0.015) + }) + + it('verifies players=8 drops NoDrop significantly (solo: ~14.3%, party: ~1.6%)', () => { + const iterations = 20000 + + // Case A: 8 players solo (effectivePlayers = 4, NoDrop' = 10, total = 70) + const rngSolo = new D2Rng(987654321) + let emptySolo = 0 + for (let i = 0; i < iterations; i++) { + const drops = rollTreasureClass('Act 1 H2H A', { + rng: rngSolo, + tcTable: table, + gamePlayers: 8, + partyPlayers: 1, + }) + if (drops.length === 0) { + emptySolo++ + } + } + const soloRate = emptySolo / iterations + const expectedSoloRate = 10 / 70 // ~0.142857 + expect(soloRate).toBeGreaterThanOrEqual(expectedSoloRate - 0.015) + expect(soloRate).toBeLessThanOrEqual(expectedSoloRate + 0.015) + + // Case B: 8 players party nearby (effectivePlayers = 8, NoDrop' = 1, total = 61) + const rngParty = new D2Rng(555666777) + let emptyParty = 0 + for (let i = 0; i < iterations; i++) { + const drops = rollTreasureClass('Act 1 H2H A', { + rng: rngParty, + tcTable: table, + gamePlayers: 8, + partyPlayers: 8, + }) + if (drops.length === 0) { + emptyParty++ + } + } + const partyRate = emptyParty / iterations + const expectedPartyRate = 1 / 61 // ~0.016393 + expect(partyRate).toBeGreaterThanOrEqual(expectedPartyRate - 0.01) + expect(partyRate).toBeLessThanOrEqual(expectedPartyRate + 0.01) + + // Clear monotonic drop: 62.5% -> 14.3% -> 1.6% + expect(soloRate).toBeLessThan(0.625) + expect(partyRate).toBeLessThan(soloRate) + }) + }) + + describe('Negative Picks (Picks < 0) Deterministic Enumeration', () => { + it('drops exact counts deterministically with 0 RNG consumption for leaf items', () => { + // Mock TC table with deterministic negative picks: picks = -3 + // Item 1: 'swd' (prob: 1), Item 2: 'axe' (prob: 2). TotalProb = 3. + const mockTable: TreasureClassTable = { + all: [], + byName: new Map(), + byGroup: new Map(), + danglingReferences: new Set(), + autoTCDanglingReferences: new Set(), + baseItemDanglingReferences: new Set(), + get: (name: string) => { + if (name === 'TestNeg') { + return { + name: 'TestNeg', + level: 1, + picks: -3, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 100, // NoDrop should be completely ignored! + items: [ + { + item: 'swd', + prob: 1, + kind: 'base-item', + isDangling: true, + isClassic: true, + }, + { + item: 'axe', + prob: 2, + kind: 'base-item', + isDangling: true, + isClassic: true, + }, + ], + totalProbClassic: 3, + totalProbExpansion: 3, + cumulativeProbExpansion: [1, 3], + } as TreasureClassNode + } + return undefined + }, + } + + const rng = new D2Rng(0x12345678, 0x87654321) + const initialSeed = rng.getSeed() + + const drops = rollTreasureClass('TestNeg', { + rng, + tcTable: mockTable, + }) + + // Exactly 3 drops in deterministic sequence: 1 swd, 2 axe + expect(drops.length).toBe(3) + expect(drops[0]?.code).toBe('swd') + expect(drops[1]?.code).toBe('axe') + expect(drops[2]?.code).toBe('axe') + + // 0 RNG consumption: seed is completely unchanged + expect(rng.getSeed()).toEqual(initialSeed) + }) + + it('verifies Countess (picks = -2) enumerates Countess Item and Countess Rune deterministically', () => { + const countess = table.get('Countess')! + expect(countess.picks).toBe(-2) + expect(countess.items.length).toBe(2) + expect(countess.items[0]?.item).toBe('Countess Item') + expect(countess.items[1]?.item).toBe('Countess Rune') + + // Mock sub-TC table where Countess Item and Countess Rune drop leaf items directly + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'Countess') return table.get('Countess') + if (name === 'Countess Item') { + return { + name: 'Countess Item', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [ + { item: 'swd', prob: 1, kind: 'base-item', isDangling: true, isClassic: true }, + ], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + if (name === 'Countess Rune') { + return { + name: 'Countess Rune', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [ + { item: 'r01', prob: 1, kind: 'base-item', isDangling: true, isClassic: true }, + ], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return table.get(name) + }, + } + + const rng = new D2Rng(11223344) + const drops = rollTreasureClass('Countess', { + rng, + tcTable: mockTable, + }) + + // Must contain both Countess Item output ('swd') and Countess Rune output ('r01') + expect(drops.length).toBe(2) + expect(drops[0]?.code).toBe('swd') + expect(drops[1]?.code).toBe('r01') + + // Countess quality factors [883, 883, 883, 1024] propagated to both items + expect(drops[0]?.qualityFactors).toEqual([883, 883, 883, 1024]) + expect(drops[1]?.qualityFactors).toEqual([883, 883, 883, 1024]) + }) + }) + + describe('Deterministic Replay & Seed Independence', () => { + it('replays identical drops given a fixed D2Rng seed', () => { + const seed = 0xdeadbeef + const rng1 = new D2Rng(seed) + const rng2 = new D2Rng(seed) + + const drops1 = rollTreasureClass('Act 1 Equip A', { + rng: rng1, + tcTable: table, + gamePlayers: 1, + }) + + const drops2 = rollTreasureClass('Act 1 Equip A', { + rng: rng2, + tcTable: table, + gamePlayers: 1, + }) + + expect(drops1).toEqual(drops2) + expect(rng1.getSeed()).toEqual(rng2.getSeed()) + }) + + it('produces different outcomes with different seeds', () => { + const rngA = new D2Rng(1001) + const rngB = new D2Rng(2002) + + // Collect 100 drops from both + const dropsA = Array.from({ length: 100 }, () => + rollTreasureClass('Act 1 H2H A', { rng: rngA, tcTable: table }), + ) + const dropsB = Array.from({ length: 100 }, () => + rollTreasureClass('Act 1 H2H A', { rng: rngB, tcTable: table }), + ) + + expect(dropsA).not.toEqual(dropsB) + }) + }) + + describe('Stack Safety & Step Budget Guards', () => { + it('throws stack overflow when recursion depth exceeds 64 frames (non-tail calls)', () => { + // Create a chain of 65 TCs where picks = 2 (so remainingPicks > 0, tail call cannot be reused) + const chainNodes = new Map() + for (let i = 0; i < 66; i++) { + const name = `Chain_${i}` + const nextName = `Chain_${i + 1}` + chainNodes.set(name, { + name, + level: 1, + picks: 2, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [ + { item: nextName, prob: 1, kind: 'tc', isDangling: false, isClassic: true }, + ], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode) + } + + const chainTable: TreasureClassTable = { + all: [], + byName: chainNodes, + byGroup: new Map(), + danglingReferences: new Set(), + autoTCDanglingReferences: new Set(), + baseItemDanglingReferences: new Set(), + get: (name: string) => chainNodes.get(name), + } + + const rng = new D2Rng(1) + expect(() => + rollTreasureClass('Chain_0', { + rng, + tcTable: chainTable, + }), + ).toThrowError(/TreasureClass stack overflow: exceeded maximum depth of 64/) + }) + + it('allows deep recursion beyond 64 levels when tail call reuse applies', () => { + // 100 levels where picks = 1 -> tail call reuse on each frame + const tailNodes = new Map() + for (let i = 0; i < 100; i++) { + const name = `Tail_${i}` + const nextItem = i === 99 ? 'swd' : `Tail_${i + 1}` + tailNodes.set(name, { + name, + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [ + { item: nextItem, prob: 1, kind: i === 99 ? 'base-item' : 'tc', isDangling: i === 99, isClassic: true }, + ], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode) + } + + const tailTable: TreasureClassTable = { + all: [], + byName: tailNodes, + byGroup: new Map(), + danglingReferences: new Set(), + autoTCDanglingReferences: new Set(), + baseItemDanglingReferences: new Set(), + get: (name: string) => tailNodes.get(name), + } + + const rng = new D2Rng(1) + const drops = rollTreasureClass('Tail_0', { + rng, + tcTable: tailTable, + }) + + // Successfully resolved through 100 frames via tail call slot reuse! + expect(drops.length).toBe(1) + expect(drops[0]?.code).toBe('swd') + }) + + it('throws step budget error when custom TC has an infinite circular reference', () => { + // Circular reference: LoopA -> LoopB -> LoopA + const loopNodes = new Map() + loopNodes.set('LoopA', { + name: 'LoopA', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'LoopB', prob: 1, kind: 'tc', isDangling: false, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode) + loopNodes.set('LoopB', { + name: 'LoopB', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'LoopA', prob: 1, kind: 'tc', isDangling: false, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode) + + const loopTable: TreasureClassTable = { + all: [], + byName: loopNodes, + byGroup: new Map(), + danglingReferences: new Set(), + autoTCDanglingReferences: new Set(), + baseItemDanglingReferences: new Set(), + get: (name: string) => loopNodes.get(name), + } + + const rng = new D2Rng(1) + expect(() => + rollTreasureClass('LoopA', { + rng, + tcTable: loopTable, + }), + ).toThrowError(/TreasureClass step budget exceeded: 10000 steps/) + }) + }) + + describe('Gold and Multiplier Drops', () => { + it('handles standard gold drops (gld) with multiplier 1', () => { + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'GoldTest') { + return { + name: 'GoldTest', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'gld', prob: 1, kind: 'gold', isDangling: true, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('GoldTest', { + rng: new D2Rng(1), + tcTable: mockTable, + }) + + expect(drops.length).toBe(1) + expect(drops[0]?.isGold).toBe(true) + expect(drops[0]?.code).toBe('gld') + expect(drops[0]?.goldMultiplier).toBe(1) + }) + + it('handles boss gold multiplier drops (gld,mul=N -> N >> 8)', () => { + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'BossGold') { + return { + name: 'BossGold', + level: 1, + picks: 3, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [ + { item: 'gld,mul=1280', prob: 1, kind: 'gold-multiplier', multiplier: 1280, isDangling: true, isClassic: true }, + { item: 'gld,mul=1536', prob: 1, kind: 'gold-multiplier', multiplier: 1536, isDangling: true, isClassic: true }, + { item: 'gld,mul=2048', prob: 1, kind: 'gold-multiplier', multiplier: 2048, isDangling: true, isClassic: true }, + ], + totalProbClassic: 3, + totalProbExpansion: 3, + cumulativeProbExpansion: [1, 2, 3], + } as TreasureClassNode + } + return undefined + }, + } + + // Test negative picks version to deterministically test all 3 + const negMockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'BossGoldNeg') { + return { + name: 'BossGoldNeg', + level: 1, + picks: -3, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [ + { item: 'gld,mul=1280', prob: 1, kind: 'gold-multiplier', multiplier: 1280, isDangling: true, isClassic: true }, + { item: 'gld,mul=1536', prob: 1, kind: 'gold-multiplier', multiplier: 1536, isDangling: true, isClassic: true }, + { item: 'gld,mul=2048', prob: 1, kind: 'gold-multiplier', multiplier: 2048, isDangling: true, isClassic: true }, + ], + totalProbClassic: 3, + totalProbExpansion: 3, + cumulativeProbExpansion: [1, 2, 3], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('BossGoldNeg', { + rng: new D2Rng(1), + tcTable: negMockTable, + }) + + expect(drops.length).toBe(3) + // 1280 >> 8 = 5 + expect(drops[0]?.isGold).toBe(true) + expect(drops[0]?.goldMultiplier).toBe(5) + // 1536 >> 8 = 6 + expect(drops[1]?.isGold).toBe(true) + expect(drops[1]?.goldMultiplier).toBe(6) + // 2048 >> 8 = 8 + expect(drops[2]?.isGold).toBe(true) + expect(drops[2]?.goldMultiplier).toBe(8) + }) + }) + + describe('Forced Quality Flags & Annihilus', () => { + it('sets forced unique when flags & 1 is present in context', () => { + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'ForcedTC') { + return { + name: 'ForcedTC', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'swd', prob: 1, kind: 'base-item', isDangling: true, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('ForcedTC', { + rng: new D2Rng(1), + tcTable: mockTable, + flags: 1, // forced unique + }) + + expect(drops.length).toBe(1) + expect(drops[0]?.forcedUnique).toBe(true) + expect(drops[0]?.forcedSet).toBe(false) + expect(drops[0]?.forcedQuality).toBe('unique') + expect(drops[0]?.flags & 1).toBe(1) + }) + + it('sets forced set when flags & 2 is present in context', () => { + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'ForcedTC') { + return { + name: 'ForcedTC', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'cap', prob: 1, kind: 'base-item', isDangling: true, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('ForcedTC', { + rng: new D2Rng(1), + tcTable: mockTable, + flags: 2, // forced set + }) + + expect(drops.length).toBe(1) + expect(drops[0]?.forcedUnique).toBe(false) + expect(drops[0]?.forcedSet).toBe(true) + expect(drops[0]?.forcedQuality).toBe('set') + expect(drops[0]?.flags & 2).toBe(2) + }) + + it('automatically marks Annihilus drops with forced unique quality', () => { + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'AnniTC') { + return { + name: 'AnniTC', + level: 1, + picks: 1, + unique: 1024, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'Annihilus', prob: 1, kind: 'unique-item', isDangling: true, isClassic: false }], + totalProbClassic: 0, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('AnniTC', { + rng: new D2Rng(1), + tcTable: mockTable, + }) + + expect(drops.length).toBe(1) + expect(drops[0]?.code).toBe('Annihilus') + expect(drops[0]?.forcedUnique).toBe(true) + expect(drops[0]?.forcedQuality).toBe('unique') + expect(drops[0]?.flags & 1).toBe(1) + }) + }) + + describe('Auto-TreasureClass Virtual Nodes (autoTcTable)', () => { + it('expands virtual auto-TC nodes supplied in autoTcTable', () => { + const autoTcTable = new Map() + autoTcTable.set('weap3', { + name: 'weap3', + picks: 1, + noDrop: 0, + items: [ + { item: 'sst', prob: 1, kind: 'base-item' }, + { item: 'wnd', prob: 1, kind: 'base-item' }, + ], + totalProbExpansion: 2, + cumulativeProbExpansion: [1, 2], + }) + + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'EquipTest') { + return { + name: 'EquipTest', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'weap3', prob: 1, kind: 'auto-tc', isDangling: true, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('EquipTest', { + rng: new D2Rng(1), + tcTable: mockTable, + autoTcTable, + }) + + expect(drops.length).toBe(1) + expect(['sst', 'wnd']).toContain(drops[0]?.code) + }) + + it('emits auto-TC reference as leaf item if not in autoTcTable', () => { + const mockTable: TreasureClassTable = { + ...table, + get: (name: string) => { + if (name === 'EquipTest') { + return { + name: 'EquipTest', + level: 1, + picks: 1, + unique: 0, + set: 0, + rare: 0, + magic: 0, + noDrop: 0, + items: [{ item: 'armo87', prob: 1, kind: 'auto-tc', isDangling: true, isClassic: true }], + totalProbClassic: 1, + totalProbExpansion: 1, + cumulativeProbExpansion: [1], + } as TreasureClassNode + } + return undefined + }, + } + + const drops = rollTreasureClass('EquipTest', { + rng: new D2Rng(1), + tcTable: mockTable, + }) + + expect(drops.length).toBe(1) + expect(drops[0]?.code).toBe('armo87') + }) + }) + + describe('Non-Existent TreasureClass Handling', () => { + it('returns empty array when TC name is not found in tcTable or autoTcTable', () => { + const drops = rollTreasureClass('NoSuchTCExists', { + rng: new D2Rng(1), + tcTable: table, + }) + + expect(drops).toEqual([]) + }) + }) +})