feat(items): 实现 TreasureClassEx 展开算法 (Issue #101)

This commit is contained in:
troytt 2026-09-18 11:50:29 +00:00
commit b576f634be
2 changed files with 1383 additions and 0 deletions

548
src/game/treasure-engine.ts Normal file
View File

@ -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<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
}

View File

@ -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<string, TreasureClassNode>()
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<string, TreasureClassNode>()
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<string, TreasureClassNode>()
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<string, AutoTcNode>()
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([])
})
})
})