369 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|