diablo2-web/tests/challenger-m2-statistical-s...

369 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
import {
expandTreasureClass,
computeEffectivePlayers,
computeScaledNoDrop,
} from '../src/game/treasure-engine.ts'
import { D2Rng } from '../src/game/d2-rng.ts'
import { Rng } from '../src/game/rng.ts'
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts'
import { createWorld, spawnMonsters, tickCombat, damageMonster, monsterStatsFromTable } from '../src/game/combat.ts'
import { parseTable } from '../src/game/tables.ts'
import { Inventory, goldItem, ItemQuality } from '../src/game/items.ts'
import { QuestLog } from '../src/game/quests.ts'
import { LockstepSession, type InputFrame, type LockstepSimulation } from '../src/net/lockstep.ts'
const dropTables = getEmbeddedDropTables()
const dummyTerrain: WorldMapProvider = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
describe('Milestone 2 Challenger 2: Empirical Stress & Statistical Parity', () => {
describe('1. Player Scaling Statistical Monte Carlo (Act 1 H2H A)', () => {
it('verifies /players 1 NoDrop rate converges to 62.5% (yield 37.5%)', () => {
const trials = 20000
const rng = new D2Rng(1001)
let empty = 0
for (let i = 0; i < trials; i++) {
const drops = expandTreasureClass('Act 1 H2H A', {
rng,
tcTable: dropTables.tcTable,
autoTcTable: dropTables.autoTcTable,
gamePlayers: 1,
partyPlayers: 1,
})
if (drops.length === 0) empty++
}
const noDropRate = empty / trials
expect(noDropRate).toBeGreaterThanOrEqual(0.625 - 0.015)
expect(noDropRate).toBeLessThanOrEqual(0.625 + 0.015)
})
it('verifies /players 3 NoDrop rate converges to 38.78% (yield 61.22%)', () => {
const trials = 20000
const rng = new D2Rng(3003)
let empty = 0
for (let i = 0; i < trials; i++) {
const drops = expandTreasureClass('Act 1 H2H A', {
rng,
tcTable: dropTables.tcTable,
autoTcTable: dropTables.autoTcTable,
gamePlayers: 3,
partyPlayers: 1,
})
if (drops.length === 0) empty++
}
const noDropRate = empty / trials
const expected = 38 / 98 // ~0.387755
expect(noDropRate).toBeGreaterThanOrEqual(expected - 0.015)
expect(noDropRate).toBeLessThanOrEqual(expected + 0.015)
})
it('verifies /players 5 NoDrop rate converges to 24.05% (yield 75.95%)', () => {
const trials = 20000
const rng = new D2Rng(5005)
let empty = 0
for (let i = 0; i < trials; i++) {
const drops = expandTreasureClass('Act 1 H2H A', {
rng,
tcTable: dropTables.tcTable,
autoTcTable: dropTables.autoTcTable,
gamePlayers: 5,
partyPlayers: 1,
})
if (drops.length === 0) empty++
}
const noDropRate = empty / trials
const expected = 19 / 79 // ~0.240506
expect(noDropRate).toBeGreaterThanOrEqual(expected - 0.015)
expect(noDropRate).toBeLessThanOrEqual(expected + 0.015)
})
it('verifies /players 7 NoDrop rate converges to 14.29% (yield 85.71%)', () => {
const trials = 20000
const rng = new D2Rng(7007)
let empty = 0
for (let i = 0; i < trials; i++) {
const drops = expandTreasureClass('Act 1 H2H A', {
rng,
tcTable: dropTables.tcTable,
autoTcTable: dropTables.autoTcTable,
gamePlayers: 7,
partyPlayers: 1,
})
if (drops.length === 0) empty++
}
const noDropRate = empty / trials
const expected = 10 / 70 // ~0.142857
expect(noDropRate).toBeGreaterThanOrEqual(expected - 0.015)
expect(noDropRate).toBeLessThanOrEqual(expected + 0.015)
})
it('verifies /players 8 solo matches /players 7 (14.29%) while /players 8 partied drops to 1.64%', () => {
const trials = 20000
// 8 players solo (effective = 4)
const rngSolo = new D2Rng(8008)
let emptySolo = 0
for (let i = 0; i < trials; i++) {
const drops = expandTreasureClass('Act 1 H2H A', {
rng: rngSolo,
tcTable: dropTables.tcTable,
autoTcTable: dropTables.autoTcTable,
gamePlayers: 8,
partyPlayers: 1,
})
if (drops.length === 0) emptySolo++
}
const soloNoDrop = emptySolo / trials
expect(soloNoDrop).toBeGreaterThanOrEqual(10 / 70 - 0.015)
expect(soloNoDrop).toBeLessThanOrEqual(10 / 70 + 0.015)
// 8 players partied (effective = 8)
const rngParty = new D2Rng(8888)
let emptyParty = 0
for (let i = 0; i < trials; i++) {
const drops = expandTreasureClass('Act 1 H2H A', {
rng: rngParty,
tcTable: dropTables.tcTable,
autoTcTable: dropTables.autoTcTable,
gamePlayers: 8,
partyPlayers: 8,
})
if (drops.length === 0) emptyParty++
}
const partyNoDrop = emptyParty / trials
expect(partyNoDrop).toBeGreaterThanOrEqual(1 / 61 - 0.01)
expect(partyNoDrop).toBeLessThanOrEqual(1 / 61 + 0.01)
})
it('verifies GameEngine.setPlayers() updates drop pipeline yield in end-to-end execution', () => {
const engine = new GameEngine(dummyTerrain, {
spawn: { x: 100, y: 100 },
stats: [],
dropTables,
difficulty: 'normal',
gamePlayers: 1,
partyPlayers: 1,
npcDefs: [],
questDefs: [],
inventoryCols: 10,
inventoryRows: 4,
xpTable: [0, 0, 1000],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
combatOptions: {
playerSpeed: 180,
playerReach: 48,
playerCooldownTicks: 12,
playerDamage: 6,
playerManaPerAttack: 2,
respawnTicks: 40,
},
talkRadius: 80,
pickupRadius: 60,
})
const trials = 5000
let dropsP1 = 0
for (let i = 0; i < trials; i++) {
const items = executeDropPipeline(dropTables, {
tcName: 'Act 1 H2H A',
nLevel: 1,
monsterType: 1,
difficulty: 'normal',
gamePlayers: engine.gamePlayers,
partyPlayers: engine.partyPlayers,
monsterRng: new D2Rng(20000 + i),
})
if (items.length > 0) dropsP1++
}
engine.setPlayers(8)
expect(engine.gamePlayers).toBe(8)
let dropsP8 = 0
for (let i = 0; i < trials; i++) {
const items = executeDropPipeline(dropTables, {
tcName: 'Act 1 H2H A',
nLevel: 1,
monsterType: 1,
difficulty: 'normal',
gamePlayers: engine.gamePlayers,
partyPlayers: engine.partyPlayers,
monsterRng: new D2Rng(20000 + i),
})
if (items.length > 0) dropsP8++
}
const yieldP1 = dropsP1 / trials
const yieldP8 = dropsP8 / trials
expect(yieldP1).toBeGreaterThanOrEqual(0.375 - 0.02)
expect(yieldP1).toBeLessThanOrEqual(0.375 + 0.02)
expect(yieldP8).toBeGreaterThanOrEqual(0.857 - 0.02)
expect(yieldP8).toBeLessThanOrEqual(0.857 + 0.02)
expect(yieldP8).toBeGreaterThan(yieldP1 + 0.40)
})
})
describe('2. Multi-Seed PRNG Stream Determinism Across 50 Seeds', () => {
const SEEDS = [
1, 2, 3, 7, 13, 42, 100, 255, 256, 1000,
10007, 104729, 1299709, 0x1234, 0x5678, 0xabcd, 0xbeef,
0x10000000, 0x55555555, 0xAAAAAAAA, 0x7FFFFFFF, 0xFFFFFFFF,
123456789, 987654321, 314159265, 271828182, 161803398,
...Array.from({ length: 23 }, (_, i) => ((i * 1664525 + 1013904223) >>> 0) & 0x7FFFFFFF),
]
it('verifies exact bit-for-bit drop pipeline replay across 50 diverse seeds with /players set', () => {
const tcs = ['Act 1 H2H A', 'Act 1 Champ A', 'Act 1 Unique A', 'Countess', 'Andariel', 'Mephisto']
for (let sIdx = 0; sIdx < SEEDS.length; sIdx++) {
const seed = SEEDS[sIdx]!
const players = 1 + (sIdx % 8)
// Run 1
const rng1 = new D2Rng(seed)
const run1: any[] = []
for (const tc of tcs) {
const drops = executeDropPipeline(dropTables, {
tcName: tc,
nLevel: 30,
monsterType: tc === 'Andariel' || tc === 'Mephisto' ? 4 : tc.includes('Unique') ? 3 : tc.includes('Champ') ? 2 : 1,
difficulty: 'normal',
gamePlayers: players,
partyPlayers: 1,
playerMf: 120,
monsterRng: rng1,
})
run1.push(drops.map(d => ({
code: d.code,
quality: d.quality,
rarity: d.rarity,
uniqueId: d.uniqueId,
durability: d.durability,
stats: d.stats,
})))
}
// Run 2
const rng2 = new D2Rng(seed)
const run2: any[] = []
for (const tc of tcs) {
const drops = executeDropPipeline(dropTables, {
tcName: tc,
nLevel: 30,
monsterType: tc === 'Andariel' || tc === 'Mephisto' ? 4 : tc.includes('Unique') ? 3 : tc.includes('Champ') ? 2 : 1,
difficulty: 'normal',
gamePlayers: players,
partyPlayers: 1,
playerMf: 120,
monsterRng: rng2,
})
run2.push(drops.map(d => ({
code: d.code,
quality: d.quality,
rarity: d.rarity,
uniqueId: d.uniqueId,
durability: d.durability,
stats: d.stats,
})))
}
expect(run1).toEqual(run2)
expect(rng1.getSeed()).toEqual(rng2.getSeed())
}
})
})
describe('3. Execution Robustness Under Load (10,000 Drops in Unit Test)', () => {
it('executes 10,000 drops across all difficulties, monster ranks, and MF setups with 0 errors', () => {
const diffs = ['normal', 'nightmare', 'hell'] as const
const ranks = ['normal', 'champion', 'unique', 'minion', 'boss'] as const
const mfs = [0, 50, 150, 350, 1000, -50, -100, -250]
const rng = new D2Rng(0xdeadc0de)
let totalDrops = 0
let totalItems = 0
for (let i = 0; i < 10000; i++) {
const diff = diffs[i % diffs.length]!
const rank = ranks[i % ranks.length]!
const mf = mfs[i % mfs.length]!
const pCount = 1 + (i % 8)
const isBoss = rank === 'boss' || i % 15 === 0
const tc = isBoss ? 'Andariel' : 'Act 1 H2H A'
const monsterType = isBoss ? 4 : (rank === 'unique' || rank === 'minion' ? 3 : (rank === 'champion' ? 2 : 1))
const nLevel = 10 + (i % 80)
const items = executeDropPipeline(dropTables, {
tcName: tc,
nLevel,
monsterType,
difficulty: diff,
gamePlayers: pCount,
partyPlayers: 1,
playerMf: mf,
isBoss,
monsterRng: rng,
})
totalDrops++
totalItems += items.length
for (const item of items) {
expect(item.base).toBeDefined()
const isGold = item.base?.id === 'gold' || item.code?.trim() === 'gld'
if (isGold) {
const amt = item.stack ?? item.value ?? 0
expect(amt).toBeGreaterThan(0)
continue
}
expect(item.ilvl).toBeGreaterThanOrEqual(1)
expect(item.ilvl).toBeLessThanOrEqual(99)
if (item.stats) {
for (const [_, val] of Object.entries(item.stats)) {
if (typeof val === 'number') {
expect(isNaN(val)).toBe(false)
}
}
}
if (mf <= -100) {
if (item.base.kind === 'weapon' || item.base.kind === 'armor') {
expect(['magic', 'rare', 'set', 'unique']).not.toContain(item.rarity)
} else {
expect(['rare', 'set', 'unique']).not.toContain(item.rarity)
}
}
}
}
expect(totalDrops).toBe(10000)
expect(totalItems).toBeGreaterThan(0)
})
})
})