fix(net): enforce lockstep peerId authentication, bounds checks, and future-tick window (#518)

Bind peerId at the relay session layer, reject out-of-range peerIds and
out-of-window future ticks without leaking memory, and store N-peer inputs per tick.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:47 +00:00
parent 7bc21df07c
commit 995f594fe7
7 changed files with 533 additions and 57 deletions

View File

@ -1,16 +1,21 @@
/**
* 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'
export { acceptKey, decodeFrame, decodeFrames, encodeFrame, startRelay, type Relay, type RelayOptions }
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 : 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')
@ -20,3 +25,4 @@ const stop = async (): Promise<void> => {
}
process.on('SIGINT', () => { void stop() })
process.on('SIGTERM', () => { void stop() })
}

View File

@ -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'

View File

@ -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<number, Map<number, InputFrame>>()
private readonly remoteHashHistory = new Map<number, Map<number, number>>()
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<number, ReadonlyMap<number, InputFrame>> {
return this.inputs
}
/** Recorded remote hashes per peer, bounded by historyTicks + MAX_FUTURE_TICKS. */
get remoteHashes(): ReadonlyMap<number, ReadonlyMap<number, number>> {
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

View File

@ -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<number, number>()
private readonly pendingRemoteHashes = new Map<number, number | number[]>()
/** Pumps since the peer last said anything. */
private silentTicks = 0
private handshaked = false
@ -376,20 +391,23 @@ 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)
for (const hash of hashes) {
this.hashesCompared += 1
if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1
}
}
}
/** Local tick, for outbound timestamps. */
private get now(): number {
@ -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':
}
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

View File

@ -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
}
}

View File

@ -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

View File

@ -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<number, unknown> }).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)
})
})