/** * Deterministic lockstep session. * * Multiplayer in Diablo II is not server-authoritative: every peer simulates the * whole world and they only exchange *inputs*. That only works if the simulation * is a pure function of (previous state, this tick's inputs) — which is why the * random number generator has been seeded and explicit since the item system, and * why nothing in the simulation reads a clock. * * This module owns the three things that make lockstep work: * * 1. **A tick runs only when every peer's input for it has arrived.** A peer that * guesses, or runs ahead on partial input, is a peer that diverges. Waiting is * the correct behaviour, so {@link LockstepSession.step} reports `waiting` * rather than advancing. * 2. **Input delay.** Real networks deliver late, so each tick's inputs are due * `inputDelayTicks` ahead of the tick that consumes them — the standard way to * trade a little response latency for never stalling. * 3. **Per-tick state hashes.** Peers exchange hashes and compare them; the first * tick whose hash differs is a desync, and knowing *which* tick diverged is * what makes the bug findable at all. * * The transport is deliberately an interface, not an implementation: the tests * drive it in memory, and a browser build can back it with a WebSocket or an * RTCDataChannel without this file changing. */ /** One tick's input from one peer. */ export interface InputFrame { /** Tick the frame is for. */ readonly tick: number /** Movement request, screen axes. */ readonly movement: { readonly x: number; readonly y: number } /** Attack control. */ readonly attack: boolean /** Pickup control. */ readonly pickup: boolean /** Talk control. */ readonly talk: boolean /** Selected skill slot. */ readonly skill: number } /** A peer's claim about a tick's resulting state. */ export interface StateHash { /** The tick the hash is for. */ readonly tick: number /** The peer's state digest for that tick. */ readonly hash: number } /** Session configuration. */ export interface LockstepOptions { /** How many peers must supply input. */ readonly peers: number /** Ticks of input delay, i.e. the latency budget. */ readonly inputDelayTicks: number /** How many past hashes to keep for comparison. */ readonly historyTicks?: number } /** What one call to {@link LockstepSession.step} did. */ export type StepOutcome = | { readonly kind: 'stepped'; readonly tick: number; readonly hash: number } | { readonly kind: 'waiting'; readonly tick: number; readonly missing: readonly number[] } | { readonly kind: 'desync'; readonly tick: number; readonly local: number; readonly remote: number } /** A desync report. */ export interface DesyncReport { /** The first tick whose hashes disagree. */ readonly tick: number /** The local hash. */ readonly local: number /** The remote hash. */ readonly remote: number } /** The simulation a session drives. */ export interface LockstepSimulation { /** * Advance the world by exactly one tick. * * Must be a pure function of the previous state and these inputs: no clocks, no * unseeded randomness, no host queries. * * @param inputs - one frame per peer, in peer order. */ advance: (inputs: readonly InputFrame[]) => void /** * Digest the world's state. * * Must cover everything peers could disagree about; a field left out is a * divergence that goes unnoticed until it affects something visible. * * @returns a 32-bit digest. */ hash: () => number } /** Number of past hashes kept for late comparisons. */ const DEFAULT_HISTORY = 64 /** * One lockstep session: input inbox, tick clock and hash history. */ export class LockstepSession { private readonly options: LockstepOptions private readonly simulation: LockstepSimulation private readonly inputs = new Map>() private readonly hashes: StateHash[] = [] private currentTick = 0 private stallTicks = 0 private desync: DesyncReport | null = null /** * @param options - session configuration. * @param simulation - the simulation to drive. */ constructor(options: LockstepOptions, simulation: LockstepSimulation) { this.options = options this.simulation = simulation for (let peer = 0; peer < options.peers; peer += 1) this.inputs.set(peer, new Map()) } /** The next tick that has not run yet. */ get tick(): number { return this.currentTick } /** How many consecutive calls could not advance for want of input. */ get stalls(): number { return this.stallTicks } /** The first desync seen, if any. */ get desyncReport(): DesyncReport | null { return this.desync } /** Recent per-tick hashes, oldest first. */ get history(): readonly StateHash[] { return this.hashes } /** * Accept an input frame from a peer. * * Frames for ticks that already ran are dropped: replaying them would change * history, and a peer that sends stale input is late, not authoritative. * * @param peer - the peer's index. * @param frame - the frame. * @returns true when the frame was accepted. */ submit(peer: number, frame: InputFrame): boolean { if (!this.inputs.has(peer)) throw new Error(`unknown peer ${String(peer)}`) if (frame.tick < this.currentTick) return false this.inputs.get(peer)!.set(frame.tick, frame) return true } /** * Run one tick if every peer's input for it is present. * * @returns what happened. */ step(): StepOutcome { const tick = this.currentTick const missing: number[] = [] const frames: InputFrame[] = [] for (let peer = 0; peer < this.options.peers; peer += 1) { const frame = this.inputs.get(peer)?.get(tick) if (frame === undefined) { missing.push(peer); continue } frames.push(frame) } if (missing.length > 0) { this.stallTicks += 1 return { kind: 'waiting', tick, missing } } this.simulation.advance(frames) const hash = this.simulation.hash() this.hashes.push({ tick, hash }) if (this.hashes.length > (this.options.historyTicks ?? DEFAULT_HISTORY)) this.hashes.shift() for (const peer of this.inputs.keys()) this.inputs.get(peer)!.delete(tick) this.currentTick += 1 this.stallTicks = 0 return { kind: 'stepped', tick, hash } } /** * The input delay a peer should target when submitting. * * @returns the tick a frame submitted now should be for. */ get inputDueTick(): number { return this.currentTick + this.options.inputDelayTicks } /** * Compare a peer's hash for a tick against the local one. * * @param remote - the peer's claim. * @returns the desync report when they disagree, null when they agree or the * tick is not in local history. */ compare(remote: StateHash): DesyncReport | null { const local = this.hashes.find(entry => entry.tick === remote.tick) if (local === undefined) return null if (local.hash === remote.hash) return null this.desync ??= { tick: remote.tick, local: local.hash, remote: remote.hash } return this.desync } /** * Digest a byte string into the same 32-bit space as the state hashes, so a * caller can hash whatever it likes consistently. * * @param text - the input. * @returns the digest. */ static digest(text: string): number { let hash = 2166136261 for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index) hash = Math.imul(hash, 16777619) >>> 0 } return hash >>> 0 } } export interface HashablePlayer { readonly x: number; readonly y: number; readonly hp: number; readonly xp: number; readonly level: number; readonly alive: boolean; } export interface HashableMonster { readonly x: number; readonly y: number; readonly hp: number; readonly state: string; } export interface HashableWorld { readonly tick: number; readonly kills: number; readonly players: readonly HashablePlayer[]; readonly monsters: readonly HashableMonster[]; } export interface HashableItem { readonly name: string; readonly stack?: number; readonly value?: number; } export interface HashablePlacedItem { readonly x: number; readonly y: number; readonly item: HashableItem; } export interface HashableInventory { readonly contents: readonly HashablePlacedItem[]; } export interface HashableGroundItem { readonly x: number; readonly y: number; readonly item: HashableItem; } export interface HashableQuest { readonly def: { readonly id: string }; readonly status: string; readonly kills: number; } export function computeLockstepHash(world: HashableWorld, inventories: readonly HashableInventory[], ground: readonly HashableGroundItem[], quests: readonly HashableQuest[]): number { let hash = 2166136261 function mix(value: number) { hash ^= (value & 0xFFFFFFFF) hash = Math.imul(hash, 16777619) >>> 0 } function mixString(text: string) { for (let index = 0; index < text.length; index += 1) { hash ^= text.charCodeAt(index) hash = Math.imul(hash, 16777619) >>> 0 } } const round = (val: number) => Math.round(val * 1000) // 1. Hash World mix(world.tick) mix(world.kills) for (const player of world.players) { mix(round(player.x)) mix(round(player.y)) mix(player.hp) mix(player.xp) mix(player.level) mix(player.alive ? 1 : 0) } for (const monster of world.monsters) { mix(round(monster.x)) mix(round(monster.y)) mix(monster.hp) mixString(monster.state) } // 2. Hash Inventories if (inventories) { for (const inventory of inventories) { let invHash = 0 if (inventory && inventory.contents) { for (const entry of inventory.contents) { let itemHash = 2166136261 const mixItem = (v: number) => { itemHash ^= (v & 0xFFFFFFFF); itemHash = Math.imul(itemHash, 16777619) >>> 0 } const mixItemString = (t: string) => { for (let i = 0; i < t.length; i++) { itemHash ^= t.charCodeAt(i); itemHash = Math.imul(itemHash, 16777619) >>> 0 } } mixItem(entry.x) mixItem(entry.y) mixItemString(entry.item.name) mixItem(entry.item.stack ?? 1) mixItem(entry.item.value ?? 0) invHash = (invHash + itemHash) >>> 0 } } mix(invHash) } } // 3. Hash Ground Loot (order-independent sum) if (ground) { let groundHash = 0 for (const entry of ground) { let elHash = 2166136261 const mixEl = (v: number) => { elHash ^= (v & 0xFFFFFFFF); elHash = Math.imul(elHash, 16777619) >>> 0 } const mixElString = (t: string) => { for (let i = 0; i < t.length; i++) { elHash ^= t.charCodeAt(i); elHash = Math.imul(elHash, 16777619) >>> 0 } } mixEl(round(entry.x)) mixEl(round(entry.y)) mixElString(entry.item.name) groundHash = (groundHash + elHash) >>> 0 } mix(groundHash) } // 4. Hash Quests (order-independent sum) if (quests) { let questsHash = 0 for (const entry of quests) { let qHash = 2166136261 const mixQ = (v: number) => { qHash ^= (v & 0xFFFFFFFF); qHash = Math.imul(qHash, 16777619) >>> 0 } const mixQString = (t: string) => { for (let i = 0; i < t.length; i++) { qHash ^= t.charCodeAt(i); qHash = Math.imul(qHash, 16777619) >>> 0 } } mixQString(entry.def.id) mixQString(entry.status) mixQ(entry.kills) questsHash = (questsHash + qHash) >>> 0 } mix(questsHash) } return hash >>> 0 }