/** * 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 rng: Rng inventory: Inventory quests: QuestLog ground: { x: number; y: number; item: ReturnType }[] } /** * 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)