213 lines
7.3 KiB
TypeScript
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()),
|
|
)
|
|
})
|
|
})
|