354 lines
11 KiB
TypeScript
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
|
|
}
|