diff --git a/scripts/net-server.ts b/scripts/net-server.ts index c91d8d6..376b0f7 100644 --- a/scripts/net-server.ts +++ b/scripts/net-server.ts @@ -1,22 +1,28 @@ /** * Run the co-op relay. * - * This is the whole of "the server": it forwards bytes between the connected - * peers and knows nothing about the game. Everything that decides who wins a - * fight happens in the two browsers. + * This is the whole of "the server": it forwards validated lockstep bytes + * between the connected peers and binds each sender's assigned peerId. * * Usage: node scripts/net-server.ts [port] */ -import { startRelay } from './net-relay.ts' +import { pathToFileURL } from 'node:url' +import { acceptKey, decodeFrame, decodeFrames, encodeFrame, startRelay, type Relay, type RelayOptions } from './net-relay.ts' -const port = Number(process.argv[2] ?? '8787') -const relay = await startRelay(Number.isFinite(port) ? port : 8787) -console.log(`relay listening on ${relay.url}`) -console.log('open two pages with ?ws=' + relay.url + '&peer=0 and &peer=1') +export { acceptKey, decodeFrame, decodeFrames, encodeFrame, startRelay, type Relay, type RelayOptions } -const stop = async (): Promise => { - await relay.close() - process.exit(0) +const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href + +if (isMain) { + const port = Number(process.argv[2] ?? '8787') + const relay = await startRelay(Number.isFinite(port) && port >= 0 && port <= 65535 ? port : 8787) + console.log(`relay listening on ${relay.url}`) + console.log('open two pages with ?ws=' + relay.url + '&peer=0 and &peer=1') + + const stop = async (): Promise => { + await relay.close() + process.exit(0) + } + process.on('SIGINT', () => { void stop() }) + process.on('SIGTERM', () => { void stop() }) } -process.on('SIGINT', () => { void stop() }) -process.on('SIGTERM', () => { void stop() }) diff --git a/src/net/lockstep-manager.ts b/src/net/lockstep-manager.ts new file mode 100644 index 0000000..2fc3c47 --- /dev/null +++ b/src/net/lockstep-manager.ts @@ -0,0 +1,8 @@ +/** + * Lockstep and netplay manager barrel module. + */ +export * from './lockstep.ts' +export * from './netplay.ts' +export * from './protocol.ts' +export * from './transport.ts' +export { NetplaySession as LockstepManager } from './netplay.ts' diff --git a/src/net/lockstep.ts b/src/net/lockstep.ts index 617a179..d9ca2f7 100644 --- a/src/net/lockstep.ts +++ b/src/net/lockstep.ts @@ -57,6 +57,8 @@ export interface LockstepOptions { readonly inputDelayTicks: number /** How many past hashes to keep for comparison. */ readonly historyTicks?: number + /** Maximum future tick lead accepted relative to currentTick. Defaults to 64. */ + readonly maxFutureTicks?: number } /** What one call to {@link LockstepSession.step} did. */ @@ -73,6 +75,10 @@ export interface DesyncReport { readonly local: number /** The remote hash. */ readonly remote: number + /** Alias for local hash. */ + readonly localHash?: number + /** Alias for remote hash. */ + readonly remoteHash?: number } /** The simulation a session drives. */ @@ -98,7 +104,10 @@ export interface LockstepSimulation { } /** Number of past hashes kept for late comparisons. */ -const DEFAULT_HISTORY = 64 +export const DEFAULT_HISTORY = 64 + +/** Maximum future ticks ahead of currentTick accepted into the inbox. */ +export const MAX_FUTURE_TICKS = 64 /** * One lockstep session: input inbox, tick clock and hash history. @@ -107,6 +116,7 @@ export class LockstepSession { private readonly options: LockstepOptions private readonly simulation: LockstepSimulation private readonly inputs = new Map>() + private readonly remoteHashHistory = new Map>() private readonly hashes: StateHash[] = [] private currentTick = 0 private stallTicks = 0 @@ -119,7 +129,10 @@ export class LockstepSession { 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()) + for (let peer = 0; peer < options.peers; peer += 1) { + this.inputs.set(peer, new Map()) + this.remoteHashHistory.set(peer, new Map()) + } } /** The next tick that has not run yet. */ @@ -142,20 +155,71 @@ export class LockstepSession { return this.hashes } + /** Pending inputs per peer, bounded by MAX_FUTURE_TICKS. */ + get pendingInputs(): ReadonlyMap> { + return this.inputs + } + + /** Recorded remote hashes per peer, bounded by historyTicks + MAX_FUTURE_TICKS. */ + get remoteHashes(): ReadonlyMap> { + return this.remoteHashHistory + } + /** * 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. + * Frames for ticks that already ran, ticks beyond the future window, unknown + * peers, non-unit movement, or duplicate `(peer, tick)` entries are rejected + * without throwing or overwriting previously accepted frames. * * @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) + if (!Number.isInteger(peer)) return false + const peerInputs = this.inputs.get(peer) + if (peerInputs === undefined) return false + if (!Number.isInteger(frame.tick) || frame.tick < this.currentTick) return false + const maxFuture = this.options.maxFutureTicks ?? MAX_FUTURE_TICKS + if (frame.tick > this.currentTick + maxFuture) return false + if ( + !Number.isFinite(frame.movement.x) || + !Number.isFinite(frame.movement.y) || + Math.abs(frame.movement.x) > 1 || + Math.abs(frame.movement.y) > 1 + ) { + return false + } + if (peerInputs.has(frame.tick)) return false + peerInputs.set(frame.tick, frame) + return true + } + + /** + * Record a remote peer's hash for a tick if within the valid window and not a + * duplicate for that `(peer, tick)`. + * + * @param peer - remote peer index. + * @param tick - tick the hash is for. + * @param hash - 32-bit state hash. + * @returns true if accepted (first-write-wins within window). + */ + recordRemoteHash(peer: number, tick: number, hash: number): boolean { + if (!Number.isInteger(peer)) return false + const peerMap = this.remoteHashHistory.get(peer) + if (peerMap === undefined) return false + const window = this.options.historyTicks ?? DEFAULT_HISTORY + const maxFuture = this.options.maxFutureTicks ?? MAX_FUTURE_TICKS + if (!Number.isInteger(tick) || tick < this.currentTick - window || tick > this.currentTick + maxFuture) { + return false + } + if (peerMap.has(tick)) return false + peerMap.set(tick, hash >>> 0) + const oldest = this.currentTick - window + for (const oldTick of peerMap.keys()) { + if (oldTick < oldest) peerMap.delete(oldTick) + } return true } @@ -180,10 +244,28 @@ export class LockstepSession { this.simulation.advance(frames) const hash = this.simulation.hash() + const window = this.options.historyTicks ?? DEFAULT_HISTORY this.hashes.push({ tick, hash }) - if (this.hashes.length > (this.options.historyTicks ?? DEFAULT_HISTORY)) this.hashes.shift() + if (this.hashes.length > window) this.hashes.shift() for (const peer of this.inputs.keys()) this.inputs.get(peer)!.delete(tick) this.currentTick += 1 + const oldest = this.currentTick - window + for (const peerMap of this.remoteHashHistory.values()) { + for (const oldTick of peerMap.keys()) { + if (oldTick < oldest) peerMap.delete(oldTick) + } + const remoteHash = peerMap.get(tick) + if (remoteHash !== undefined && remoteHash !== (hash >>> 0)) { + this.desync ??= { + tick, + local: hash, + remote: remoteHash, + localHash: hash, + remoteHash, + } + return { kind: 'desync', tick, local: hash, remote: remoteHash } + } + } this.stallTicks = 0 return { kind: 'stepped', tick, hash } } @@ -208,7 +290,13 @@ export class LockstepSession { 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 } + this.desync ??= { + tick: remote.tick, + local: local.hash, + remote: remote.hash, + localHash: local.hash, + remoteHash: remote.hash, + } return this.desync } @@ -233,19 +321,26 @@ export class LockstepSession { export interface HashablePlayer { readonly x: number; readonly y: number; readonly hp: number; - readonly xp: number; readonly level: number; readonly alive: boolean; + readonly xp?: number | undefined; readonly level?: number | undefined; readonly alive?: boolean | undefined; + readonly maxHp?: number | undefined; readonly mana?: number | undefined; readonly maxMana?: number | undefined; + readonly cooldown?: number | undefined; } export interface HashableMonster { + readonly id?: number | undefined; + readonly kind?: string | undefined; readonly x: number; readonly y: number; readonly hp: number; - readonly state: string; + readonly maxHp?: number | undefined; readonly cooldown?: number | undefined; readonly deadTicks?: number | undefined; + readonly state?: string | undefined; } export interface HashableWorld { readonly tick: number; readonly kills: number; - readonly players: readonly HashablePlayer[]; + readonly rngState?: number | undefined; readonly xp?: number | undefined; readonly level?: number | undefined; + readonly players?: readonly HashablePlayer[] | undefined; + readonly player?: HashablePlayer | undefined; readonly monsters: readonly HashableMonster[]; } export interface HashableItem { - readonly name: string; readonly stack?: number; readonly value?: number; + readonly name: string; readonly stack?: number | undefined; readonly value?: number | undefined; readonly quality?: string | number | undefined; readonly uniqueId?: number | undefined; readonly dwInitSeed?: number | undefined; @@ -282,20 +377,21 @@ export function computeLockstepHash(world: HashableWorld, inventories: readonly mix(world.tick) mix(world.kills) - for (const player of world.players) { + const playersList = world.players ?? (world.player ? [world.player] : []) + for (const player of playersList) { mix(round(player.x)) mix(round(player.y)) mix(player.hp) - mix(player.xp) - mix(player.level) - mix(player.alive ? 1 : 0) + mix(player.xp ?? world.xp ?? 0) + mix(player.level ?? world.level ?? 1) + mix((player.alive ?? player.hp > 0) ? 1 : 0) } for (const monster of world.monsters) { mix(round(monster.x)) mix(round(monster.y)) mix(monster.hp) - mixString(monster.state) + mixString(monster.state ?? '') } // 2. Hash Inventories @@ -368,3 +464,5 @@ export function computeLockstepHash(world: HashableWorld, inventories: readonly return hash >>> 0 } + +export const computeStateHash = computeLockstepHash diff --git a/src/net/netplay.ts b/src/net/netplay.ts index a857675..920df19 100644 --- a/src/net/netplay.ts +++ b/src/net/netplay.ts @@ -18,13 +18,28 @@ * all is a *timeout*, kept separate from a desync — different problem, different * response (drop the peer versus stop and resync). */ -import { LockstepSession, type DesyncReport, type InputFrame, type LockstepOptions, type LockstepSimulation, type StateHash, type StepOutcome } from './lockstep.ts' -import { decodeMessage, encodeMessage, NO_ACK, ProtocolError, type NetMessage } from './protocol.ts' +import { DEFAULT_HISTORY, LockstepSession, MAX_FUTURE_TICKS, type DesyncReport, type InputFrame, type LockstepOptions, type LockstepSimulation, type StateHash, type StepOutcome } from './lockstep.ts' +import { decodeMessage, encodeMessage, MOVEMENT_SCALE, NO_ACK, ProtocolError, type NetMessage } from './protocol.ts' import type { Transport } from './transport.ts' /** Pumps between repeated handshake attempts, until the peer answers. */ const HANDSHAKE_RETRY_PUMPS = 25 +/** Maximum entries held in pendingRemoteHashes. */ +const MAX_PENDING_REMOTE_HASHES = 128 + +/** + * Validate that a movement vector is finite, within `[-1, 1]` per axis, and + * has magnitude `<= 1` (with 2-LSB fixed-point rounding tolerance for normalized diagonals). + */ +function isUnitMovement(x: number, y: number): boolean { + if (!Number.isFinite(x) || !Number.isFinite(y)) return false + const ix = Math.round(x * MOVEMENT_SCALE) + const iy = Math.round(y * MOVEMENT_SCALE) + if (Math.abs(ix) > MOVEMENT_SCALE || Math.abs(iy) > MOVEMENT_SCALE) return false + return ix * ix + iy * iy <= MOVEMENT_SCALE * MOVEMENT_SCALE + 2000 +} + /** Session configuration beyond the lockstep core's own options. */ export interface NetplayOptions extends LockstepOptions { /** This peer's index. */ @@ -128,7 +143,7 @@ export class NetplaySession { * almost nothing, which is the opposite of what the check is for. They are held * here instead and checked the moment local history catches up. */ - private readonly pendingRemoteHashes = new Map() + private readonly pendingRemoteHashes = new Map() /** Pumps since the peer last said anything. */ private silentTicks = 0 private handshaked = false @@ -376,18 +391,21 @@ export class NetplaySession { */ private drainRemoteHashes(): void { if (this.pendingRemoteHashes.size === 0) return - const window = this.options.historyTicks ?? 64 + const window = this.options.historyTicks ?? DEFAULT_HISTORY const oldest = this.lockstep.tick - window - for (const [tick, hash] of [...this.pendingRemoteHashes]) { + for (const [tick, entry] of [...this.pendingRemoteHashes]) { + const hashes = Array.isArray(entry) ? entry : [entry] if (tick < oldest) { this.pendingRemoteHashes.delete(tick) - this.hashesIgnored += 1 + this.hashesIgnored += hashes.length continue } if (tick >= this.lockstep.tick) continue this.pendingRemoteHashes.delete(tick) - this.hashesCompared += 1 - if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1 + for (const hash of hashes) { + this.hashesCompared += 1 + if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1 + } } } @@ -466,18 +484,35 @@ export class NetplaySession { this.malformed += 1 return } - this.received += 1 - this.silentTicks = 0 + if ( + !Number.isInteger(message.peer) || + message.peer < 0 || + message.peer >= this.options.peers || + message.peer === this.options.peer + ) { + this.malformed += 1 + return + } + + const window = this.options.historyTicks ?? DEFAULT_HISTORY + const maxFuture = this.options.maxFutureTicks ?? MAX_FUTURE_TICKS + switch (message.kind) { case 'hello': { - if (message.peer === this.options.peer || message.peer >= this.options.peers) { - // A hello from ourselves, or from a peer index nobody can have: ignored - // rather than counted, because it says the sender is confused about the - // game it joined. + if ( + message.peers !== this.options.peers || + (message.ackTo !== NO_ACK && (message.ackTo < 0 || message.ackTo >= this.options.peers)) + ) { this.malformed += 1 - break + return } const link = this.linkOf(message.peer) + if (link.heard && link.seed !== message.seed) { + this.malformed += 1 + return + } + this.received += 1 + this.silentTicks = 0 link.heard = true link.seed = message.seed // The seed is checked, not trusted: peers that disagree about it build @@ -496,16 +531,71 @@ export class NetplaySession { this.handshaked = this.ready break } - case 'input': - this.lockstep.submit(message.peer, message.frame) + case 'input': { + if (!isUnitMovement(message.frame.movement.x, message.frame.movement.y)) { + this.malformed += 1 + return + } + if (message.frame.tick > this.lockstep.tick + maxFuture) { + this.malformed += 1 + return + } + if (!this.lockstep.submit(message.peer, message.frame)) { + this.malformed += 1 + return + } + this.received += 1 + this.silentTicks = 0 break - case 'hash': - this.pendingRemoteHashes.set(message.tick, message.hash) + } + case 'hash': { + if (message.tick > this.lockstep.tick + maxFuture) { + this.malformed += 1 + return + } + const oldest = this.lockstep.tick - window + if (message.tick < oldest) { + this.received += 1 + this.silentTicks = 0 + this.hashesIgnored += 1 + break + } + if (!this.lockstep.recordRemoteHash(message.peer, message.tick, message.hash)) { + this.malformed += 1 + return + } + this.received += 1 + this.silentTicks = 0 + const existing = this.pendingRemoteHashes.get(message.tick) + if (existing === undefined) { + if (this.pendingRemoteHashes.size >= MAX_PENDING_REMOTE_HASHES) { + const firstKey = this.pendingRemoteHashes.keys().next().value + if (firstKey !== undefined) this.pendingRemoteHashes.delete(firstKey) + } + this.pendingRemoteHashes.set(message.tick, message.hash) + } else if (Array.isArray(existing)) { + existing.push(message.hash) + } else { + this.pendingRemoteHashes.set(message.tick, [existing, message.hash]) + } this.drainRemoteHashes() break - case 'bye': + } + case 'bye': { + this.received += 1 + this.silentTicks = 0 this.closed = true break + } + case 'heartbeat': { + if (message.tick < this.lockstep.tick - window || message.tick > this.lockstep.tick + maxFuture) { + this.malformed += 1 + return + } + this.received += 1 + this.silentTicks = 0 + break + } /* v8 ignore next -- the message union is closed above. */ default: break diff --git a/src/net/protocol.ts b/src/net/protocol.ts index ed0d40e..4e5913b 100644 --- a/src/net/protocol.ts +++ b/src/net/protocol.ts @@ -26,8 +26,12 @@ export const MESSAGE_TYPE = { hash: 3, /** Peer leaving. */ bye: 4, + /** Liveness heartbeat for a tick. */ + heartbeat: 5, } as const +export const PACKET_TYPE = MESSAGE_TYPE + /** A decoded message. */ export type NetMessage = | { @@ -41,17 +45,20 @@ export type NetMessage = | { readonly kind: 'input'; readonly peer: number; readonly frame: InputFrame } | { readonly kind: 'hash'; readonly peer: number; readonly tick: number; readonly hash: number } | { readonly kind: 'bye'; readonly peer: number } + | { readonly kind: 'heartbeat'; readonly peer: number; readonly tick: number } /** Scaling used for movement components. */ -const MOVEMENT_SCALE = 1000 +export const MOVEMENT_SCALE = 1000 /** Bytes of an `input` message: type, peer, tick, x, y, flags, skill. */ -const INPUT_BYTES = 1 + 1 + 4 + 2 + 2 + 1 + 1 +export const INPUT_BYTES = 1 + 1 + 4 + 2 + 2 + 1 + 1 /** Bytes of a `hash` message. */ -const HASH_BYTES = 1 + 1 + 4 + 4 +export const HASH_BYTES = 1 + 1 + 4 + 4 /** Bytes of a `hello` message: type, peer, peers, seed, flags. */ -const HELLO_BYTES = 1 + 1 + 1 + 4 + 1 +export const HELLO_BYTES = 1 + 1 + 1 + 4 + 1 /** Bytes of a `bye` message. */ -const BYE_BYTES = 1 + 1 +export const BYE_BYTES = 1 + 1 +/** Bytes of a `heartbeat` message: type, peer, tick. */ +export const HEARTBEAT_BYTES = 1 + 1 + 4 /** Input frame flags byte. */ const FLAG_ATTACK = 1 @@ -132,6 +139,14 @@ export function encodeMessage(message: NetMessage): Uint8Array { out[1] = message.peer & 0xff return out } + case 'heartbeat': { + const out = new Uint8Array(HEARTBEAT_BYTES) + const view = new DataView(out.buffer) + out[0] = MESSAGE_TYPE.heartbeat + out[1] = message.peer & 0xff + view.setUint32(2, message.tick >>> 0, true) + return out + } /* v8 ignore next -- the message union is closed above. */ default: throw new ProtocolError('unknown message') } @@ -160,6 +175,9 @@ export function decodeMessage(data: Uint8Array): NetMessage { case MESSAGE_TYPE.input: { if (data.byteLength !== INPUT_BYTES) throw new ProtocolError(`input has ${String(data.byteLength)} bytes, expected ${String(INPUT_BYTES)}`) const flags = data[10] ?? 0 + if ((flags & ~(FLAG_ATTACK | FLAG_PICKUP | FLAG_TALK)) !== 0) { + throw new ProtocolError(`input has invalid flags ${String(flags)}`) + } return { kind: 'input', peer, @@ -181,7 +199,28 @@ export function decodeMessage(data: Uint8Array): NetMessage { if (data.byteLength !== BYE_BYTES) throw new ProtocolError(`bye has ${String(data.byteLength)} bytes, expected ${String(BYE_BYTES)}`) return { kind: 'bye', peer } } + case MESSAGE_TYPE.heartbeat: { + if (data.byteLength !== HEARTBEAT_BYTES) throw new ProtocolError(`heartbeat has ${String(data.byteLength)} bytes, expected ${String(HEARTBEAT_BYTES)}`) + return { kind: 'heartbeat', peer, tick: view.getUint32(2, true) } + } /* v8 ignore next -- unreachable for the checked tags. */ default: throw new ProtocolError(`unknown message type ${String(data[0])}`) } } + +export const encodeFrame = encodeMessage + +/** + * Non-throwing wrapper around {@link decodeMessage}. + * + * @param data - the received bytes. + * @returns the decoded message, or null when malformed. + */ +export function decodeFrame(data: Uint8Array): NetMessage | null { + try { + return decodeMessage(data) + } catch { + return null + } +} + diff --git a/src/net/transport.ts b/src/net/transport.ts index 3d56360..092dae7 100644 --- a/src/net/transport.ts +++ b/src/net/transport.ts @@ -46,7 +46,7 @@ export interface TransportOptions { } /** Largest message accepted by default. */ -const DEFAULT_MAX_MESSAGE = 4096 +export const DEFAULT_MAX_MESSAGE = 4096 /** * Build a connected pair of in-process transports. @@ -398,3 +398,5 @@ export function socketTransport(socket: SocketLike, options: TransportOptions = get open(): boolean { return opened }, } } + +export const webSocketTransport = socketTransport diff --git a/tests/p0-518-lockstep-security.test.ts b/tests/p0-518-lockstep-security.test.ts new file mode 100644 index 0000000..3908c16 --- /dev/null +++ b/tests/p0-518-lockstep-security.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'vitest' +import { + DEFAULT_MAX_MESSAGE, + decodeFrame, + decodeMessage, + encodeFrame, + encodeMessage, + LockstepManager, + LockstepSession, + MAX_FUTURE_TICKS, + memoryTransportPair, + MESSAGE_TYPE, + NetplaySession, + NO_ACK, + PACKET_TYPE, + ProtocolError, + type InputFrame, +} from '../src/net/lockstep-manager.ts' + +describe('#518 Lockstep & Netplay input security and bounds', () => { + test('exports expected constants and barrel symbols from lockstep-manager', () => { + expect(MAX_FUTURE_TICKS).toBe(64) + expect(DEFAULT_MAX_MESSAGE).toBe(4096) + expect(PACKET_TYPE.heartbeat).toBe(5) + expect(LockstepManager).toBe(NetplaySession) + + const hbBytes = encodeFrame({ kind: 'heartbeat', peer: 1, tick: 42 }) + const hbDecoded = decodeFrame(hbBytes) + expect(hbDecoded).toEqual({ kind: 'heartbeat', peer: 1, tick: 42 }) + expect(decodeFrame(new Uint8Array([0xff]))).toBeNull() + }) + + test('prevents self-peer spoofing from overwriting local player inputs', () => { + const [t0, t1] = memoryTransportPair() + const seenBy0: InputFrame[][] = [] + const s0 = new NetplaySession( + { peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: inputs => { seenBy0.push([...inputs]) }, hash: () => seenBy0.length }, + t0, + ) + const s1 = new NetplaySession( + { peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: () => {}, hash: () => 0 }, + t1, + ) + s0.start() + s1.start() + s0.pump() + s1.pump() + t0.flush() + t0.flush() + + // Remote peer 1 injects forged frames claiming peer=0 with movement.x = -1 for ticks 0..10 + for (let tick = 0; tick <= 10; tick += 1) { + t1.send(encodeMessage({ + kind: 'input', + peer: 0, // SPOOFED self peer! + frame: { tick, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 9 }, + })) + } + t1.flush() + + expect(s0.stats.malformed).toBe(11) + + for (let tick = 0; tick < 10; tick += 1) { + s0.setIntent({ movement: { x: 0.5, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }) + s1.setIntent({ movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }) + s0.pump() + s1.pump() + t0.flush() + } + + expect(seenBy0.length).toBeGreaterThanOrEqual(6) + for (const tickInputs of seenBy0) { + expect(tickInputs[0]!.movement.x).toBeCloseTo(0.5, 3) + expect(tickInputs[0]!.attack).toBe(false) + expect(tickInputs[0]!.skill).toBe(0) + } + }) + + test('rejects out-of-range peer IDs without throwing in LockstepSession or NetplaySession', () => { + const [t0, t1] = memoryTransportPair() + const s0 = new NetplaySession( + { peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: () => {}, hash: () => 0 }, + t0, + ) + s0.start() + + // Direct LockstepSession.submit with invalid peer IDs returns false instead of throwing + expect( + s0.lockstep.submit(7, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }), + ).toBe(false) + expect( + s0.lockstep.submit(-1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }), + ).toBe(false) + + // Wire injection with peer=7 and peer=99 never throws out of transport.flush() + expect(() => { + t1.send(encodeMessage({ + kind: 'input', + peer: 7, + frame: { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }, + })) + t1.send(encodeMessage({ + kind: 'hash', + peer: 99, + tick: 0, + hash: 123, + })) + t1.flush() + }).not.toThrow() + + expect(s0.stats.malformed).toBe(2) + }) + + test('rejects far-future ticks (> currentTick + MAX_FUTURE_TICKS) and bounds pendingRemoteHashes', () => { + const [t0, t1] = memoryTransportPair() + const s0 = new NetplaySession( + { peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: () => {}, hash: () => 0 }, + t0, + ) + s0.start() + + // Direct LockstepSession far-future submission is rejected + const farFutureAccepted = s0.lockstep.submit(1, { + tick: 0xffff_fff0, + movement: { x: 0, y: 0 }, + attack: false, + pickup: false, + talk: false, + skill: 0, + }) + expect(farFutureAccepted).toBe(false) + + // Wire injection of far-future input and 1,000 future hash messages + t1.send(encodeMessage({ + kind: 'input', + peer: 1, + frame: { tick: 1_000_000, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }, + })) + for (let i = 0; i < 1000; i += 1) { + t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 5000 + i, hash: 0xdeadbeef })) + } + t1.flush() + + const pendingHashesSize = (s0 as unknown as { pendingRemoteHashes: Map }).pendingRemoteHashes.size + expect(pendingHashesSize).toBeLessThanOrEqual(128) + expect(pendingHashesSize).toBe(0) + expect(s0.lockstep.pendingInputs.get(1)?.size ?? 0).toBe(0) + expect(s0.stats.malformed).toBe(1001) + }) + + test('enforces first-write-wins on duplicate (peer, tick) inputs and hashes', () => { + const [t0, t1] = memoryTransportPair() + const seenBy0: InputFrame[][] = [] + const s0 = new NetplaySession( + { peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: inputs => { seenBy0.push([...inputs]) }, hash: () => 1234 }, + t0, + ) + const s1 = new NetplaySession( + { peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: () => {}, hash: () => 1234 }, + t1, + ) + s0.start() + s1.start() + s0.pump() + s1.pump() + t0.flush() + t0.flush() + + // Peer 1 sends a legitimate input for tick 0 (x = 1), then tries to overwrite tick 0 with (x = -1) + t1.send(encodeMessage({ + kind: 'input', + peer: 1, + frame: { tick: 0, movement: { x: 1, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }, + })) + t1.send(encodeMessage({ + kind: 'input', + peer: 1, + frame: { tick: 0, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 }, + })) + t1.flush() + + expect(s0.stats.malformed).toBe(1) + expect(s0.lockstep.pendingInputs.get(1)?.get(0)?.movement.x).toBe(1) + + // Duplicate hash for the same (peer, tick) is also rejected + t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 1234 })) + t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 9999 })) + t1.flush() + expect(s0.stats.malformed).toBe(2) + }) + + test('rejects non-unit movement vectors and undefined control flag bits', () => { + // Undefined flag bit 0x80 is rejected by decodeMessage + const badFlags = encodeMessage({ + kind: 'input', + peer: 1, + frame: { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }, + }) + badFlags[10] = 0x80 + expect(() => decodeMessage(badFlags)).toThrow(ProtocolError) + + // Non-unit movement (|v| > 1) is rejected by NetplaySession + const [t0, t1] = memoryTransportPair() + const s0 = new NetplaySession( + { peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 }, + { advance: () => {}, hash: () => 0 }, + t0, + ) + s0.start() + + // Speed-hack movement (x = 5, y = 0) and unnormalized diagonal (x = 1, y = 1 -> |v| = 1.414) + t1.send(encodeMessage({ + kind: 'input', + peer: 1, + frame: { tick: 0, movement: { x: 5, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }, + })) + t1.send(encodeMessage({ + kind: 'input', + peer: 1, + frame: { tick: 0, movement: { x: 1, y: 1 }, attack: false, pickup: false, talk: false, skill: 0 }, + })) + t1.flush() + + expect(s0.stats.malformed).toBe(2) + expect(s0.lockstep.pendingInputs.get(1)?.size ?? 0).toBe(0) + }) +})