import { describe, it, expect, beforeAll } from 'vitest' import { calculateDirectProbabilities, calculateCompoundProbability, calculateAnalyticProbabilities, buildReverseLookupIndex, reverseLookup, runMonteCarloSimulation, loadCanonicalTreasureClasses, CANONICAL_ITEM_NAMES, type ReverseLookupEntry, } from '../src/loot.ts' describe('Loot Drop Simulator & Exact Probability Table (Issue #110)', () => { const { tcTable, autoTcs } = loadCanonicalTreasureClasses() describe('1. Analytic Expansion Canonical Probabilities (Act 1 H2H A)', () => { it('matches canonical probabilities for Act 1 H2H A direct drop rates', () => { // Act 1 H2H A base parameters: // picks: 1, NoDrop: 100, TotalProb: 60, TotalWeight: 160 // Expected direct probabilities: // NoDrop: 100 / 160 = 0.625 (62.5%) // gld: 21 / 160 = 0.13125 (13.125%) // Act 1 Equip A: 16 / 160 = 0.10 (10%) // Act 1 Junk: 21 / 160 = 0.13125 (13.125%) // Act 1 Good: 2 / 160 = 0.0125 (1.25%) const direct = calculateDirectProbabilities('Act 1 H2H A', { tcTable, autoTcs, gamePlayers: 1, partyPlayers: 1, }) expect(direct.picks).toBe(1) expect(direct.noDrop).toBe(100) expect(direct.totalWeight).toBe(160) const pNoDrop = direct.probabilities.get('NoDrop')! const pGld = direct.probabilities.get('gld')! const pEquip = direct.probabilities.get('Act 1 Equip A')! const pJunk = direct.probabilities.get('Act 1 Junk')! const pGood = direct.probabilities.get('Act 1 Good')! expect(pNoDrop).toBeCloseTo(0.625, 6) // 62.5% expect(pGld).toBeCloseTo(0.13125, 6) // 13.125% expect(pEquip).toBeCloseTo(0.10, 6) // 10.0% expect(pJunk).toBeCloseTo(0.13125, 6) // 13.125% expect(pGood).toBeCloseTo(0.0125, 6) // 1.25% // Sum of all direct branch probabilities must equal exactly 1.0 (100%) const sum = pNoDrop + pGld + pEquip + pJunk + pGood expect(sum).toBeCloseTo(1.0, 6) }) it('propagates direct probabilities to analytic result object', () => { const analytic = calculateAnalyticProbabilities('Act 1 H2H A', { tcTable, autoTcs, gamePlayers: 1, partyPlayers: 1, }) expect(analytic.direct.get('NoDrop')).toBeCloseTo(0.625, 6) expect(analytic.direct.get('gld')).toBeCloseTo(0.13125, 6) expect(analytic.direct.get('Act 1 Equip A')).toBeCloseTo(0.10, 6) expect(analytic.direct.get('Act 1 Junk')).toBeCloseTo(0.13125, 6) expect(analytic.direct.get('Act 1 Good')).toBeCloseTo(0.0125, 6) expect(analytic.noDropSingleProb).toBeCloseTo(0.625, 6) expect(analytic.noDropCompoundProb).toBeCloseTo(0.625, 6) // Leaf items include gld with single probability 0.13125 const gldLeaf = analytic.leafMap.get('gld') expect(gldLeaf).toBeDefined() expect(gldLeaf?.singleProbability).toBeCloseTo(0.13125, 6) expect(gldLeaf?.compoundProbability).toBeCloseTo(0.13125, 6) }) }) describe('2. Compound Probability Calculation for picks > 1', () => { it('calculates compound probability 1 - (1 - p)^picks accurately', () => { // 7 picks with p = 0.1: 1 - 0.9^7 = 0.5217031 expect(calculateCompoundProbability(0.1, 7)).toBeCloseTo(1 - Math.pow(0.9, 7), 8) // 7 picks with p = 0.5: 1 - 0.5^7 = 0.9921875 expect(calculateCompoundProbability(0.5, 7)).toBeCloseTo(1 - Math.pow(0.5, 7), 8) // 1 pick equals single probability expect(calculateCompoundProbability(0.25, 1)).toBe(0.25) // Boundary values expect(calculateCompoundProbability(0, 7)).toBe(0) expect(calculateCompoundProbability(1, 7)).toBe(1) expect(calculateCompoundProbability(0.5, -2)).toBe(1) // deterministic picks }) it('never exceeds 1.0 across extensive parameter sweeps', () => { const pickCounts = [1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 50, 100, 1000] const probabilities = [0.0001, 0.001, 0.01, 0.05, 0.1, 0.2, 0.33, 0.5, 0.75, 0.9, 0.99, 0.9999, 1.0, 1.5] for (const picks of pickCounts) { for (const p of probabilities) { const cp = calculateCompoundProbability(p, picks) expect(cp).toBeGreaterThanOrEqual(0) expect(cp).toBeLessThanOrEqual(1.0) } } }) it('strictly exhibits monotonicity with respect to pick count for p in (0, 1)', () => { const p = 0.15 let prev = calculateCompoundProbability(p, 1) for (let picks = 2; picks <= 20; picks++) { const curr = calculateCompoundProbability(p, picks) expect(curr).toBeGreaterThan(prev) expect(curr).toBeLessThanOrEqual(1.0) prev = curr } }) }) describe('3. Monte Carlo Convergence within 99% Confidence Interval', () => { it('converges to analytic drop probabilities within 99% CI for Act 1 H2H A', async () => { const runs = 30000 const seed = 424242 const result = await runMonteCarloSimulation({ tcName: 'Act 1 H2H A', runs, gamePlayers: 1, partyPlayers: 1, seed, tcTable, autoTcs, }) expect(result.totalRuns).toBe(runs) // True analytic probabilities const pExpectedNoDrop = 0.625 // 100 / 160 const pExpectedGld = 0.13125 // 21 / 160 // Observed sample proportions const pObservedNoDrop = result.emptyRuns / runs const gldSummary = result.itemSummaries.find(it => it.code === 'gld') const pObservedGld = (gldSummary?.count ?? 0) / runs // 99% Confidence Interval Critical Value z = 2.575829 const z99 = 2.575829 const seNoDrop = Math.sqrt((pExpectedNoDrop * (1 - pExpectedNoDrop)) / runs) const seGld = Math.sqrt((pExpectedGld * (1 - pExpectedGld)) / runs) const marginNoDrop = z99 * seNoDrop const marginGld = z99 * seGld const diffNoDrop = Math.abs(pObservedNoDrop - pExpectedNoDrop) const diffGld = Math.abs(pObservedGld - pExpectedGld) expect(diffNoDrop).toBeLessThanOrEqual(marginNoDrop) expect(diffGld).toBeLessThanOrEqual(marginGld) }) it('verifies player scaling reduces empty runs under /players 8', async () => { const runs = 10000 const seed = 999111 const resultSolo8 = await runMonteCarloSimulation({ tcName: 'Act 1 H2H A', runs, gamePlayers: 8, partyPlayers: 1, seed, tcTable, autoTcs, }) // Under gamePlayers = 8, partyPlayers = 1: // effectivePlayers = 1 + trunc(7/2) = 4 // Scaled NoDrop = trunc(60 * (100/160)^4 / (1 - (100/160)^4)) = 10 // Total = 70. Expected empty rate = 10 / 70 ~ 14.28% const observedRate8 = resultSolo8.emptyRuns / runs const expectedRate8 = 10 / 70 const se8 = Math.sqrt((expectedRate8 * (1 - expectedRate8)) / runs) expect(Math.abs(observedRate8 - expectedRate8)).toBeLessThanOrEqual(2.576 * se8) expect(observedRate8).toBeLessThan(0.20) }) it('simulates drops without seed (undefined seed) without PRNG zero-lock', async () => { // Regression test for Issue #110 bug: When seed is omitted (default UI state), // PRNG must not lock into (0, 0) state which produced 100% NoDrop across 1000 runs. const runs = 1000 const result = await runMonteCarloSimulation({ tcName: 'Baal (H)', runs, gamePlayers: 1, partyPlayers: 1, tcTable, autoTcs, }) expect(result.totalRuns).toBe(runs) expect(result.itemsDropped).toBeGreaterThan(2000) // Baal rolls up to 7 picks, usually ~5-6 items per run expect(result.emptyRuns).toBeLessThan(10) // Baal has extremely low NoDrop expect(result.itemSummaries.length).toBeGreaterThan(10) expect(result.qualityCounts.magic + result.qualityCounts.rare + result.qualityCounts.set + result.qualityCounts.unique).toBeGreaterThan(0) }) it('simulates drops with seed=0 safely mapping to non-zero seed', async () => { const runs = 1000 const result = await runMonteCarloSimulation({ tcName: 'Baal (H)', runs, seed: 0, gamePlayers: 1, partyPlayers: 1, tcTable, autoTcs, }) expect(result.totalRuns).toBe(runs) expect(result.itemsDropped).toBeGreaterThan(2000) expect(result.emptyRuns).toBeLessThan(10) }) it('supports isQuestDrop option routing to Baalq (H)', async () => { const runs = 500 const result = await runMonteCarloSimulation({ tcName: 'Baal (H)', runs, isQuestDrop: true, gamePlayers: 1, partyPlayers: 1, tcTable, autoTcs, }) expect(result.totalRuns).toBe(runs) expect(result.itemsDropped).toBeGreaterThan(1000) // Quest drop has 0 NoDrop expect(result.emptyRuns).toBe(0) }) it('handles negative picks analytic probabilities without producing 100% compound for rare items', () => { // Find a TC with negative picks in tcTable let negativeTcName: string | undefined for (const node of tcTable.all) { if (node.picks < 0 && node.items.length > 1) { negativeTcName = node.name break } } if (negativeTcName) { const analytic = calculateAnalyticProbabilities(negativeTcName, { tcTable, autoTcs, gamePlayers: 1, partyPlayers: 1, }) for (const leaf of analytic.leaves) { if (leaf.singleProbability < 0.5) { expect(leaf.compoundProbability).toBeLessThan(1.0) } } } }) }) describe('4. Reverse Lookup Indexing All Eligible Items', () => { let reverseIndex: Map beforeAll(() => { reverseIndex = buildReverseLookupIndex(tcTable, autoTcs) }) it('indexes all eligible unique leaf items', () => { // 605 distinct item codes (604 items + unified gold) expect(reverseIndex.size).toBe(605) expect(reverseIndex.has('gld')).toBe(true) expect(reverseIndex.has('uap')).toBe(true) expect(reverseIndex.has('r33')).toBe(true) expect(reverseIndex.has('cap')).toBe(true) }) it('finds dropping TCs when searching for Shako / uap', () => { const byCode = reverseLookup(reverseIndex, 'uap', CANONICAL_ITEM_NAMES) expect(byCode.length).toBeGreaterThan(0) const uapEntry = byCode.find(r => r.matchedCode === 'uap')! expect(uapEntry).toBeDefined() expect(uapEntry.entries.length).toBeGreaterThan(50) // Bosses like Mephisto (H) and Baal (H) must be in the drop list for Shako const tcNames = uapEntry.entries.map(e => e.tcName) expect(tcNames).toContain('Mephisto (H)') expect(tcNames).toContain('Baal (H)') // Name search for "Shako" const byName = reverseLookup(reverseIndex, 'Shako', CANONICAL_ITEM_NAMES) expect(byName.some(r => r.matchedCode === 'uap')).toBe(true) }) it('finds dropping TCs when searching for high rune Zod / r33', () => { const byCode = reverseLookup(reverseIndex, 'r33', CANONICAL_ITEM_NAMES) expect(byCode.length).toBeGreaterThan(0) const r33Entry = byCode.find(r => r.matchedCode === 'r33')! expect(r33Entry).toBeDefined() const tcNames = r33Entry.entries.map(e => e.tcName) expect(tcNames).toContain('Runes 17') expect(tcNames).toContain('Baal (H)') expect(tcNames).toContain('Cow (H)') // Name search for "Zod" const byName = reverseLookup(reverseIndex, 'zod', CANONICAL_ITEM_NAMES) expect(byName.some(r => r.matchedCode === 'r33')).toBe(true) }) it('finds dropping TCs when searching for cap (armo3)', () => { const byCode = reverseLookup(reverseIndex, 'cap', CANONICAL_ITEM_NAMES) expect(byCode.length).toBeGreaterThan(0) const capEntry = byCode.find(r => r.matchedCode === 'cap')! expect(capEntry).toBeDefined() const tcNames = capEntry.entries.map(e => e.tcName) expect(tcNames).toContain('armo3') expect(tcNames).toContain('Act 1 Equip A') expect(tcNames).toContain('Act 1 H2H A') }) }) })