413 lines
16 KiB
TypeScript
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)')
|
|
})
|
|
})
|
|
})
|