diablo2-web/scripts/verify-m5.ts

356 lines
15 KiB
TypeScript

/**
* M5 checks: snapshots, character files and deterministic lockstep, headless.
*
* A save is verified the only way that means anything: restore it, keep
* simulating, and require the restored copy to stay *identical* to the original
* tick for tick. A field left out of the save shows up here as a divergence a few
* hundred ticks later, which is exactly how such a bug appears in a real game.
*
* Lockstep is verified by running two independent sessions over identical input
* and comparing their per-tick hashes, then by tampering with one peer's input and
* requiring the divergence to be *detected* — a desync detector that never fires
* is worse than none.
*
* Usage: node scripts/verify-m5.ts
*/
import { createWorld, spawnMonsters, tickCombat, damageMonster } from '../src/game/combat.ts'
import type { CombatOptions, MonsterStats } from '../src/game/combat.ts'
import { Inventory, goldItem, rollDrop } from '../src/game/items.ts'
import type { Affix, ItemBase } from '../src/game/items.ts'
import { QuestLog } from '../src/game/quests.ts'
import type { QuestDef } from '../src/game/quests.ts'
import { Rng } from '../src/game/rng.ts'
import {
captureSnapshot, createD2s, d2sChecksum, parseSnapshot, readD2s, restoreSnapshot, serializeSnapshot, writeD2s,
} from '../src/game/save.ts'
import { LockstepSession } from '../src/net/lockstep.ts'
import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts'
import { parseTable } from '../src/game/tables.ts'
import { monsterStatsFromTable } from '../src/game/combat.ts'
const problems: string[] = []
let checks = 0
/**
* Assert one condition.
*
* @param condition - the condition to hold.
* @param description - what it means.
*/
function expect(condition: boolean, description: string): void {
checks += 1
if (!condition) problems.push(description)
}
// --- a small game to save and to simulate ------------------------------------
const stats: MonsterStats[] = monsterStatsFromTable(parseTable([
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
'fallen\tFallen\t12\t3\t24\t36\t400\t80\t8',
'zombie\tZombie\t30\t6\t32\t40\t300\t50\t15',
].join('\n')))
const bases: ItemBase[] = [
{ id: 'swd', name: 'Short Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 30, damage: 5, defense: 0, tags: ['weap'], level: 1 },
{ id: 'potion', name: 'Potion', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 5, value: 20, damage: 0, defense: 0, tags: ['misc'], level: 1 },
]
const prefixes: Affix[] = [{ id: 'cruel', name: 'Cruel', kind: 'prefix', level: 1, itemTypes: ['weap'], modifiers: [{ stat: 'maxdamage', min: 2, max: 6 }] }]
const suffixes: Affix[] = [{ id: 'might', name: 'of Might', kind: 'suffix', level: 1, itemTypes: [], modifiers: [{ stat: 'strength', min: 1, max: 3 }] }]
const quests: QuestDef[] = [{ id: 'den', name: 'Den', description: '', monsterId: '*', killCount: 3, rewardXp: 40, rewardGold: 60 }]
const options: CombatOptions = {
playerSpeed: 180, playerReach: 48, playerCooldownTicks: 12, playerDamage: 6,
playerManaPerAttack: 2, respawnTicks: 40,
}
const xpTable: readonly number[] = [0, 0, 1000]
/** The part of the game a save has to carry. */
interface Game {
world: ReturnType<typeof createWorld>
rng: Rng
inventory: Inventory
quests: QuestLog
ground: { x: number; y: number; item: ReturnType<typeof goldItem> }[]
}
/**
* Build a fresh game.
*
* @param seed - random seed and world seed.
* @returns the game.
*/
function newGame(seed: number): Game {
const world = createWorld(0, 0)
spawnMonsters(world, stats, 5, { x: 200, y: 0 }, 200, { overlap: () => 0 })
return { world, rng: new Rng(seed), inventory: new Inventory(10, 4), quests: new QuestLog(quests), ground: [] }
}
/**
* The input script: deterministic in the tick number, so both runs and both
* sessions see exactly the same thing.
*
* @param tick - the tick.
* @returns movement and intent.
*/
function scriptedInput(tick: number): { movement: { x: number; y: number }; attack: boolean } {
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, attack: tick % 5 === 0 }
}
/**
* Advance a game one tick, including loot and quest progress.
*
* @param game - the game.
* @param tick - the tick number.
*/
function stepGame(game: Game, tick: number): void {
const input = scriptedInput(tick)
tickCombat(game.world, input, options, { overlap: () => 0 }, xpTable)
for (const event of game.world.events) {
if (event.kind !== 'kill') continue
for (const reward of game.quests.recordKill(event.subjectId ?? '')) {
game.world.player.xp += reward.xp
game.inventory.add(goldItem(reward.gold))
}
const drop = rollDrop(bases, prefixes, suffixes, game.rng, {
level: game.world.player.level + 1, dropChance: 0.9, goldChance: 0.2, goldRange: [3, 25],
})
// Items land on the ground first, like in the scene; a few are then picked up,
// so the save has both ground and bag contents to carry.
if (drop.kind === 'item') game.ground.push({ x: event.x, y: event.y, item: drop.item })
else if (drop.kind === 'gold') game.inventory.add(goldItem(drop.amount))
}
if (game.ground.length > 0 && tick % 17 === 0) {
const entry = game.ground.shift()
if (entry !== undefined) game.inventory.add(entry.item)
}
// The player swings at whatever is in reach, and the damage goes through the
// shared entry point so kills behave as they do in the scene.
if (input.attack && game.world.player.cooldown === 0) {
game.world.monsters.forEach((monster, index) => {
if (monster.state === 'dead') return
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
damageMonster(game.world, index, options.playerDamage)
})
}
}
/**
* Digest everything a continued simulation depends on.
*
* @param game - the game.
* @returns a digest string.
*/
function digest(game: Game): string {
const round = (value: number): number => Math.round(value * 1000)
return JSON.stringify({
tick: game.world.tick,
kills: game.world.kills,
rng: game.rng.seed,
player: {
x: round(game.world.player.x), y: round(game.world.player.y),
hp: game.world.player.hp, mana: game.world.player.mana, xp: game.world.player.xp,
level: game.world.player.level, cooldown: game.world.player.cooldown,
},
monsters: game.world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state, m.cooldown, m.hitFlash, m.corpseTicks]),
inventory: game.inventory.contents.map(entry => [entry.x, entry.y, entry.item.name, entry.item.stack, entry.item.value]),
ground: game.ground.map(entry => [round(entry.x), round(entry.y), entry.item.name]),
quests: game.quests.all.map(entry => [entry.def.id, entry.status, entry.kills]),
})
}
// --- snapshots ---------------------------------------------------------------
const original = newGame(0x1234)
for (let tick = 0; tick < 200; tick += 1) stepGame(original, tick)
const snapshot = captureSnapshot(original)
const text = serializeSnapshot(snapshot)
const parsed = parseSnapshot(text)
expect(parsed.version === snapshot.version, 'a serialized snapshot parses back')
expect(parsed.rngState === original.rng.seed, 'the random stream position is saved')
expect(parsed.inventory.placed.length === original.inventory.contents.length, 'every carried item is saved')
expect(parsed.quests[0]?.kills === original.quests.all[0]?.kills, 'quest progress is saved')
expect(parsed.ground.length === original.ground.length, 'items on the ground are saved')
const restored = restoreSnapshot(parsed, {
inventory: (width, height, placed) => Inventory.restore(width, height, placed),
quests: states => QuestLog.restore(quests, states),
})
const restoredGame: Game = {
ground: restored.ground.map(entry => ({ ...entry })),
world: {
...original.world,
...restored.world,
events: [],
monsters: restored.world.monsters.map(monster => ({ ...monster })),
},
rng: new Rng(restored.rngState),
inventory: restored.inventory,
quests: restored.quests,
}
expect(restoredGame.inventory.contents.length === original.inventory.contents.length, 'the restored bag holds the same items')
expect(
restoredGame.inventory.contents.every((entry, index) => entry.x === original.inventory.contents[index]?.x
&& entry.y === original.inventory.contents[index]?.y),
'restored items sit in the slots they were saved in',
)
// The real test: keep simulating both and require them to stay identical.
for (let tick = 200; tick < 400; tick += 1) {
stepGame(original, tick)
stepGame(restoredGame, tick)
}
expect(digest(original) === digest(restoredGame), 'a restored game continues identically for 200 more ticks')
// A snapshot missing a field must be rejected, not half-applied.
let rejected = false
try {
parseSnapshot(JSON.stringify({ version: 1, rngState: 1 }))
} catch {
rejected = true
}
expect(rejected, 'a malformed save is rejected')
let versionRejected = false
try {
parseSnapshot(JSON.stringify({ ...snapshot, version: 99 }))
} catch {
versionRejected = true
}
expect(versionRejected, 'a save from a future version is rejected')
// --- character files ---------------------------------------------------------
const character = createD2s('Deckard', 1, 12, 0x80)
const readBack = readD2s(character)
expect(readBack.name === 'Deckard', 'the character name round-trips')
expect(readBack.classIndex === 1 && readBack.level === 12, 'class and level round-trip')
expect(readBack.version === 96, 'the version word is written')
const tampered = new Uint8Array(character)
tampered[0x40] = (tampered[0x40]! + 1) & 0xff
let checksumRejected = false
try {
readD2s(tampered)
} catch {
checksumRejected = true
}
expect(checksumRejected, 'a corrupted character file fails its checksum')
const notAFile = new Uint8Array(character)
notAFile[0] = 0
let signatureRejected = false
try {
readD2s(writeD2s({ ...readBack, raw: notAFile }))
} catch {
signatureRejected = true
}
expect(!signatureRejected, 'writing fixes the signature')
expect(d2sChecksum(character) === new DataView(character.buffer).getUint32(0x0c, true), 'the stored checksum is the computed one')
// --- lockstep ----------------------------------------------------------------
/**
* Wrap a game as a lockstep simulation: peer 0 drives it, and the hash covers the
* whole state.
*
* @param game - the game to drive.
* @returns the simulation.
*/
function asSimulation(game: Game): LockstepSimulation {
return {
advance: (inputs) => {
const frame = inputs[0]!
tickCombat(game.world, { movement: frame.movement, attack: frame.attack }, options, { overlap: () => 0 }, xpTable)
if (frame.attack && game.world.player.cooldown === 0) {
game.world.monsters.forEach((monster, index) => {
if (monster.state === 'dead') return
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
damageMonster(game.world, index, options.playerDamage)
})
}
},
hash: () => LockstepSession.digest(digest(game)),
}
}
/**
* Build a frame for a tick.
*
* @param tick - the tick.
* @param attack - attack flag override.
* @returns the frame.
*/
function frameFor(tick: number, attack?: boolean): InputFrame {
const input = scriptedInput(tick)
return {
tick,
movement: input.movement,
attack: attack ?? input.attack,
pickup: false,
talk: false,
skill: 0,
}
}
const sessionA = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, asSimulation(newGame(7)))
const sessionB = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, asSimulation(newGame(7)))
const hashesA: number[] = []
const hashesB: number[] = []
for (let tick = 0; tick < 300; tick += 1) {
sessionA.submit(0, frameFor(tick))
sessionB.submit(0, frameFor(tick))
const a = sessionA.step()
const b = sessionB.step()
if (a.kind === 'stepped') hashesA.push(a.hash)
if (b.kind === 'stepped') hashesB.push(b.hash)
}
expect(hashesA.length === 300 && hashesB.length === 300, 'both sessions ran every tick')
expect(hashesA.join(',') === hashesB.join(','), 'the same inputs produce the same hashes tick for tick')
expect(new Set(hashesA).size > 10, 'the hash actually changes as the world does')
// A session must wait rather than run ahead on partial input.
const waitingSession = new LockstepSession({ peers: 2, inputDelayTicks: 0 }, asSimulation(newGame(3)))
waitingSession.submit(0, frameFor(0))
const stalled = waitingSession.step()
expect(stalled.kind === 'waiting', 'a tick with a missing peer input does not run')
expect(stalled.kind === 'waiting' && stalled.missing.join(',') === '1', 'the wait names the peer that is missing')
expect(waitingSession.tick === 0 && waitingSession.stalls === 1, 'the tick does not advance and the stall is counted')
waitingSession.submit(1, frameFor(0))
expect(waitingSession.step().kind === 'stepped', 'the tick runs once the input arrives')
// Input delay: a frame submitted now is due later, which is the latency budget.
expect(waitingSession.inputDueTick === 1, 'with no delay a frame is due for the next tick')
const delayedSession = new LockstepSession({ peers: 1, inputDelayTicks: 5 }, asSimulation(newGame(3)))
expect(delayedSession.inputDueTick === 5, 'input delay pushes the due tick out')
// Stale input is refused rather than rewinding history.
const staleSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(3)))
staleSession.submit(0, frameFor(0))
staleSession.step()
expect(!staleSession.submit(0, frameFor(0)), 'input for a tick that already ran is dropped')
expect(staleSession.submit(0, frameFor(1)), 'input for the next tick is accepted')
// Desync detection: one peer sees different input, and the hashes must disagree.
const honestSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(11)))
const tamperedSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(11)))
// The tamper has to be a real difference: `frameFor(0)` already attacks (the
// script attacks every fifth tick), so the honest peer is given the opposite.
honestSession.submit(0, frameFor(0, false))
const honestHash = honestSession.step()
tamperedSession.submit(0, frameFor(0, true))
const tamperedHash = tamperedSession.step()
expect(honestHash.kind === 'stepped' && tamperedHash.kind === 'stepped', 'both peers ran tick 0')
if (honestHash.kind === 'stepped' && tamperedHash.kind === 'stepped') {
const report = honestSession.compare({ tick: 0, hash: tamperedHash.hash })
expect(report !== null, 'a diverging input is detected as a desync')
expect(report?.tick === 0, 'the report names the tick that diverged')
expect(honestSession.compare({ tick: 0, hash: honestHash.hash }) === null, 'matching hashes are not a desync')
expect(honestSession.desyncReport !== null, 'the first desync is remembered')
}
expect(honestSession.compare({ tick: 999, hash: 1 }) === null, 'a hash for an unknown tick is ignored, not guessed at')
console.log(`checks ${String(checks)}`)
console.log(`problems ${String(problems.length)}`)
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
console.log(problems.length === 0 ? 'RESULT save, snapshot and lockstep behaviours hold' : 'RESULT FAILED')
process.exit(problems.length === 0 ? 0 : 1)