252 lines
9.4 KiB
TypeScript
252 lines
9.4 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
})
|