feat(items): 实现 TreasureClassEx 展开算法 (Issue #101)
This commit is contained in:
parent
ca3512f195
commit
bc05af8baa
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue