diablo2-web/tests/p0-523-determinism.test.ts

213 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { GameEngine, type EngineInput, type WorldMapProvider } from '../src/game/engine.ts'
import { D2Rng, Rng } from '../src/game/d2-rng.ts'
import { executeDropPipeline, rollHellforgeDrop } from '../src/game/drop-pipeline.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { transmuteCubeItems, resetCubeItemSeqCounter } from '../src/game/runeword-socket.ts'
import { createActiveOverlay, resetOverlaySeqCounter } from '../src/game/skills.ts'
import { simulateRavenPeck } from '../src/game/skills/druid-summon.ts'
import {
calculateHitChance,
calculatePlayerBlockChance,
calculateCriticalChance,
evaluateCriticalAndDeadlyStrike,
} from '../src/game/formulas.ts'
import { DEMO_MONSTERS, DEMO_EXPERIENCE } from '../src/scene/backend/net-simulation.ts'
function stripCommentsAndStrings(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
.replace(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g, '""')
}
function createTestEngine(seed: number): GameEngine {
const terrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: () => 0,
}
return new GameEngine(terrain, {
spawn: { x: 400, y: 400 },
stats: DEMO_MONSTERS,
xpTable: DEMO_EXPERIENCE,
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 180,
playerReach: 64,
playerDamage: 12,
playerCooldownTicks: 10,
playerManaPerAttack: 0,
respawnTicks: 50,
},
lootSeed: seed,
talkRadius: 64,
pickupRadius: 48,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 4,
monsterSpread: 180,
})
}
function makeInput(tick: number): EngineInput {
const phase = Math.floor(tick / 25) % 4
const movement =
phase === 0
? { x: 1, y: 0 }
: phase === 1
? { x: 0, y: 1 }
: phase === 2
? { x: -1, y: 0 }
: { x: 0, y: -1 }
return {
movement,
attacking: tick % 5 === 0,
pickingUp: tick % 20 === 0,
talking: false,
digits: [],
saving: false,
loading: false,
}
}
describe('P0 #523 — Simulation Lockstep Determinism & Unseeded RNG Elimination', () => {
const simulationFiles = [
'src/game/combat.ts',
'src/game/engine.ts',
'src/game/drop-pipeline.ts',
'src/game/formulas.ts',
'src/game/ground-items.ts',
'src/game/runeword-socket.ts',
'src/game/skills.ts',
'src/game/engine/aura-scanner.ts',
'src/game/skills/druid-summon.ts',
'src/game/skills/registry.ts',
]
it('contains zero Math.random(), Date.now(), or performance.now() calls across simulation modules', () => {
for (const relPath of simulationFiles) {
const fullPath = path.resolve(process.cwd(), relPath)
const content = stripCommentsAndStrings(fs.readFileSync(fullPath, 'utf8'))
expect(content, `${relPath} must not call Math.random()`).not.toMatch(/\bMath\.random\s*\(/)
expect(content, `${relPath} must not call Date.now()`).not.toMatch(/\bDate\.now\s*\(/)
expect(content, `${relPath} must not call performance.now()`).not.toMatch(/\bperformance\.now\s*\(/)
}
})
it('maintains bit-exact 1,000-tick state hash parity across two independent GameEngine instances with seed 0x113C5EED', () => {
const engineA = createTestEngine(0x113c5eed)
const engineB = createTestEngine(0x113c5eed)
expect(engineA.computeStateHash()).toBe(engineB.computeStateHash())
for (let t = 1; t <= 1000; t += 1) {
const input = makeInput(t)
engineA.tick(input)
engineB.tick(input)
expect(engineA.computeStateHash()).toBe(engineB.computeStateHash())
}
})
it('preserves state hash determinism across saveSnapshot/loadSnapshot mid-simulation at tick 500', () => {
const engineContinuous = createTestEngine(0x113c5eed)
const engineSnap = createTestEngine(0x113c5eed)
for (let t = 1; t <= 500; t += 1) {
const input = makeInput(t)
engineContinuous.tick(input)
engineSnap.tick(input)
}
expect(engineContinuous.computeStateHash()).toBe(engineSnap.computeStateHash())
const snapshot = engineSnap.saveSnapshot()
const restoredEngine = createTestEngine(0xdeadbeef)
expect(restoredEngine.loadSnapshot(snapshot)).toBe(true)
expect(restoredEngine.computeStateHash()).toBe(engineContinuous.computeStateHash())
for (let t = 501; t <= 1000; t += 1) {
const input = makeInput(t)
engineContinuous.tick(input)
restoredEngine.tick(input)
expect(restoredEngine.computeStateHash()).toBe(engineContinuous.computeStateHash())
}
})
it('produces deterministic outputs in drop-pipeline, runeword-socket, formulas, druid-summon, and skills', () => {
const dropTables = getEmbeddedDropTables()
const dropA = executeDropPipeline({
tcName: 'Andariel',
nLevel: 12,
dropTables,
difficulty: 'normal',
gamePlayers: 1,
playerMf: 100,
monsterRng: new D2Rng(0x113c5eed),
})
const dropB = executeDropPipeline({
tcName: 'Andariel',
nLevel: 12,
dropTables,
difficulty: 'normal',
gamePlayers: 1,
playerMf: 100,
monsterRng: new D2Rng(0x113c5eed),
})
expect(dropA).toEqual(dropB)
const hfA = rollHellforgeDrop('hell', dropTables, new D2Rng(0x113c5eed))
const hfB = rollHellforgeDrop('hell', dropTables, new D2Rng(0x113c5eed))
expect(hfA).toEqual(hfB)
const makeRunePlacement = (col: number) => ({
col,
row: 0,
item: {
id: `el_${String(col)}`,
code: 'r01',
name: 'El Rune',
nameZh: '艾尔符文',
baseNameZh: '艾尔符文',
quality: 'normal' as const,
invWidth: 1,
invHeight: 1,
invFile: 'invr01',
allowedSlots: [] as const,
stats: [] as const,
},
})
resetCubeItemSeqCounter()
const cubeA = transmuteCubeItems([makeRunePlacement(0), makeRunePlacement(1), makeRunePlacement(2)], new Rng(0x113c5eed))
resetCubeItemSeqCounter()
const cubeB = transmuteCubeItems([makeRunePlacement(0), makeRunePlacement(1), makeRunePlacement(2)], new Rng(0x113c5eed))
expect(cubeA).toEqual(cubeB)
expect(cubeA.matched).toBe(true)
const ravenA = simulateRavenPeck(5, { isImmuneToCurse: false }, new D2Rng(0x113c5eed))
const ravenB = simulateRavenPeck(5, { isImmuneToCurse: false }, new D2Rng(0x113c5eed))
expect(ravenA).toEqual(ravenB)
resetOverlaySeqCounter()
const ov1 = createActiveOverlay('cast_fire', { x: 100, y: 120 }, 'caster_1')
const ov2 = createActiveOverlay('cast_fire', { x: 100, y: 120 }, 'caster_1')
expect(ov1.id).toBe('overlay_cast_fire_1')
expect(ov2.id).toBe('overlay_cast_fire_2')
const rng1 = new D2Rng(0x113c5eed)
const rng2 = new D2Rng(0x113c5eed)
const hitChance = calculateHitChance(1500, 1000, 50, 50)
const blockChance = calculatePlayerBlockChance(50, 100, 50)
const critChance = calculateCriticalChance(20, 15)
expect(hitChance).toBeGreaterThan(0)
expect(blockChance).toBeGreaterThan(0)
expect(critChance).toBeGreaterThan(0)
expect(evaluateCriticalAndDeadlyStrike(20, 15, () => rng1.next())).toEqual(
evaluateCriticalAndDeadlyStrike(20, 15, () => rng2.next()),
)
})
})