diablo2-web/src/net/lockstep.ts

354 lines
11 KiB
TypeScript

/**
* 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<number, Map<number, InputFrame>>()
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
}