diablo2-web/tests/loot.test.ts

413 lines
16 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest'
import {
calculateDirectProbabilities,
calculateCompoundProbability,
calculateAnalyticProbabilities,
buildReverseLookupIndex,
reverseLookup,
runMonteCarloSimulation,
loadCanonicalTreasureClasses,
CANONICAL_ITEM_NAMES,
MONSTER_PRESETS,
getMonsterPresetTc,
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<string, ReverseLookupEntry[]>
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')
})
})
describe('5. Monster Preset TC Resolution & Accuracy (Issue #121)', () => {
it('verifies that all MONSTER_PRESETS define valid TCs that exist in tcTable', () => {
expect(MONSTER_PRESETS.length).toBeGreaterThanOrEqual(15)
for (const m of MONSTER_PRESETS) {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const tcName = m.tc[diff]
expect(tcName).toBeDefined()
expect(tcName.length).toBeGreaterThan(0)
const node = tcTable.get(tcName) ?? autoTcs.get(tcName)
expect(node, `Monster ${m.id} difficulty ${diff} TC "${tcName}" must exist in tcTable`).toBeDefined()
if (m.questTc) {
const qtcName = m.questTc[diff]
expect(qtcName).toBeDefined()
const qnode = tcTable.get(qtcName) ?? autoTcs.get(qtcName)
expect(qnode, `Monster ${m.id} quest TC "${qtcName}" must exist in tcTable`).toBeDefined()
}
}
}
})
it('resolves Travincal Council (Council) to Council TCs, not Baal (H) (Issue #121 bug reproduction)', () => {
const council = MONSTER_PRESETS.find(m => m.id === 'council')!
expect(council).toBeDefined()
// In Nightmare difficulty, Council must resolve to 'Council (N)', NOT 'Baal (H)'
const tcNightmare = getMonsterPresetTc(council, 'nightmare', false)
expect(tcNightmare).toBe('Council (N)')
expect(tcNightmare).not.toBe('Baal (H)')
// In Hell difficulty, Council must resolve to 'Council (H)', NOT 'Baal (H)'
const tcHell = getMonsterPresetTc(council, 'hell', false)
expect(tcHell).toBe('Council (H)')
expect(tcHell).not.toBe('Baal (H)')
// In Normal difficulty, Council must resolve to 'Council'
const tcNormal = getMonsterPresetTc(council, 'normal', false)
expect(tcNormal).toBe('Council')
})
it('resolves distinct TCs for every monster preset without leaking Baal (H)', () => {
for (const m of MONSTER_PRESETS) {
if (m.id === 'baal') continue
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const tc = getMonsterPresetTc(m, diff, false)
expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal (H)`).not.toBe('Baal (H)')
expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal`).not.toBe('Baal')
expect(tc, `Monster ${m.id} [${diff}] should never resolve to Baal (N)`).not.toBe('Baal (N)')
}
}
})
it('resolves quest TCs correctly when quest drop is enabled', () => {
const andariel = MONSTER_PRESETS.find(m => m.id === 'andariel')!
expect(getMonsterPresetTc(andariel, 'hell', true)).toBe('Andarielq (H)')
expect(getMonsterPresetTc(andariel, 'nightmare', true)).toBe('Andarielq (N)')
expect(getMonsterPresetTc(andariel, 'normal', true)).toBe('Andarielq')
const mephisto = MONSTER_PRESETS.find(m => m.id === 'mephisto')!
expect(getMonsterPresetTc(mephisto, 'hell', true)).toBe('Mephistoq (H)')
const baal = MONSTER_PRESETS.find(m => m.id === 'baal')!
expect(getMonsterPresetTc(baal, 'hell', true)).toBe('Baalq (H)')
})
it('respects customOverride when explicitly provided, but monster selection clears it', () => {
const council = MONSTER_PRESETS.find(m => m.id === 'council')!
// When custom override is active
expect(getMonsterPresetTc(council, 'nightmare', false, 'Runes 17')).toBe('Runes 17')
// When custom override is empty or null, returns monster TC
expect(getMonsterPresetTc(council, 'nightmare', false, null)).toBe('Council (N)')
expect(getMonsterPresetTc(council, 'nightmare', false, '')).toBe('Council (N)')
})
})
})