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:
parent
7bc21df07c
commit
995f594fe7
|
|
@ -1,22 +1,28 @@
|
||||||
/**
|
/**
|
||||||
* Run the co-op relay.
|
* Run the co-op relay.
|
||||||
*
|
*
|
||||||
* This is the whole of "the server": it forwards bytes between the connected
|
* This is the whole of "the server": it forwards validated lockstep bytes
|
||||||
* peers and knows nothing about the game. Everything that decides who wins a
|
* between the connected peers and binds each sender's assigned peerId.
|
||||||
* fight happens in the two browsers.
|
|
||||||
*
|
*
|
||||||
* Usage: node scripts/net-server.ts [port]
|
* 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')
|
export { acceptKey, decodeFrame, decodeFrames, encodeFrame, startRelay, type Relay, type RelayOptions }
|
||||||
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')
|
|
||||||
|
|
||||||
const stop = async (): Promise<void> => {
|
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<void> => {
|
||||||
await relay.close()
|
await relay.close()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', () => { void stop() })
|
||||||
|
process.on('SIGTERM', () => { void stop() })
|
||||||
}
|
}
|
||||||
process.on('SIGINT', () => { void stop() })
|
|
||||||
process.on('SIGTERM', () => { void stop() })
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -57,6 +57,8 @@ export interface LockstepOptions {
|
||||||
readonly inputDelayTicks: number
|
readonly inputDelayTicks: number
|
||||||
/** How many past hashes to keep for comparison. */
|
/** How many past hashes to keep for comparison. */
|
||||||
readonly historyTicks?: number
|
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. */
|
/** What one call to {@link LockstepSession.step} did. */
|
||||||
|
|
@ -73,6 +75,10 @@ export interface DesyncReport {
|
||||||
readonly local: number
|
readonly local: number
|
||||||
/** The remote hash. */
|
/** The remote hash. */
|
||||||
readonly remote: number
|
readonly remote: number
|
||||||
|
/** Alias for local hash. */
|
||||||
|
readonly localHash?: number
|
||||||
|
/** Alias for remote hash. */
|
||||||
|
readonly remoteHash?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The simulation a session drives. */
|
/** The simulation a session drives. */
|
||||||
|
|
@ -98,7 +104,10 @@ export interface LockstepSimulation {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of past hashes kept for late comparisons. */
|
/** 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.
|
* One lockstep session: input inbox, tick clock and hash history.
|
||||||
|
|
@ -107,6 +116,7 @@ export class LockstepSession {
|
||||||
private readonly options: LockstepOptions
|
private readonly options: LockstepOptions
|
||||||
private readonly simulation: LockstepSimulation
|
private readonly simulation: LockstepSimulation
|
||||||
private readonly inputs = new Map<number, Map<number, InputFrame>>()
|
private readonly inputs = new Map<number, Map<number, InputFrame>>()
|
||||||
|
private readonly remoteHashHistory = new Map<number, Map<number, number>>()
|
||||||
private readonly hashes: StateHash[] = []
|
private readonly hashes: StateHash[] = []
|
||||||
private currentTick = 0
|
private currentTick = 0
|
||||||
private stallTicks = 0
|
private stallTicks = 0
|
||||||
|
|
@ -119,7 +129,10 @@ export class LockstepSession {
|
||||||
constructor(options: LockstepOptions, simulation: LockstepSimulation) {
|
constructor(options: LockstepOptions, simulation: LockstepSimulation) {
|
||||||
this.options = options
|
this.options = options
|
||||||
this.simulation = simulation
|
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. */
|
/** The next tick that has not run yet. */
|
||||||
|
|
@ -142,20 +155,71 @@ export class LockstepSession {
|
||||||
return this.hashes
|
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.
|
* Accept an input frame from a peer.
|
||||||
*
|
*
|
||||||
* Frames for ticks that already ran are dropped: replaying them would change
|
* Frames for ticks that already ran, ticks beyond the future window, unknown
|
||||||
* history, and a peer that sends stale input is late, not authoritative.
|
* 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 peer - the peer's index.
|
||||||
* @param frame - the frame.
|
* @param frame - the frame.
|
||||||
* @returns true when the frame was accepted.
|
* @returns true when the frame was accepted.
|
||||||
*/
|
*/
|
||||||
submit(peer: number, frame: InputFrame): boolean {
|
submit(peer: number, frame: InputFrame): boolean {
|
||||||
if (!this.inputs.has(peer)) throw new Error(`unknown peer ${String(peer)}`)
|
if (!Number.isInteger(peer)) return false
|
||||||
if (frame.tick < this.currentTick) return false
|
const peerInputs = this.inputs.get(peer)
|
||||||
this.inputs.get(peer)!.set(frame.tick, frame)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,10 +244,28 @@ export class LockstepSession {
|
||||||
|
|
||||||
this.simulation.advance(frames)
|
this.simulation.advance(frames)
|
||||||
const hash = this.simulation.hash()
|
const hash = this.simulation.hash()
|
||||||
|
const window = this.options.historyTicks ?? DEFAULT_HISTORY
|
||||||
this.hashes.push({ tick, hash })
|
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)
|
for (const peer of this.inputs.keys()) this.inputs.get(peer)!.delete(tick)
|
||||||
this.currentTick += 1
|
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
|
this.stallTicks = 0
|
||||||
return { kind: 'stepped', tick, hash }
|
return { kind: 'stepped', tick, hash }
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +290,13 @@ export class LockstepSession {
|
||||||
const local = this.hashes.find(entry => entry.tick === remote.tick)
|
const local = this.hashes.find(entry => entry.tick === remote.tick)
|
||||||
if (local === undefined) return null
|
if (local === undefined) return null
|
||||||
if (local.hash === remote.hash) 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
|
return this.desync
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,19 +321,26 @@ export class LockstepSession {
|
||||||
|
|
||||||
export interface HashablePlayer {
|
export interface HashablePlayer {
|
||||||
readonly x: number; readonly y: number; readonly hp: number;
|
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 {
|
export interface HashableMonster {
|
||||||
|
readonly id?: number | undefined;
|
||||||
|
readonly kind?: string | undefined;
|
||||||
readonly x: number; readonly y: number; readonly hp: number;
|
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 {
|
export interface HashableWorld {
|
||||||
readonly tick: number; readonly kills: number;
|
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[];
|
readonly monsters: readonly HashableMonster[];
|
||||||
}
|
}
|
||||||
export interface HashableItem {
|
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 quality?: string | number | undefined;
|
||||||
readonly uniqueId?: number | undefined;
|
readonly uniqueId?: number | undefined;
|
||||||
readonly dwInitSeed?: number | undefined;
|
readonly dwInitSeed?: number | undefined;
|
||||||
|
|
@ -282,20 +377,21 @@ export function computeLockstepHash(world: HashableWorld, inventories: readonly
|
||||||
mix(world.tick)
|
mix(world.tick)
|
||||||
mix(world.kills)
|
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.x))
|
||||||
mix(round(player.y))
|
mix(round(player.y))
|
||||||
mix(player.hp)
|
mix(player.hp)
|
||||||
mix(player.xp)
|
mix(player.xp ?? world.xp ?? 0)
|
||||||
mix(player.level)
|
mix(player.level ?? world.level ?? 1)
|
||||||
mix(player.alive ? 1 : 0)
|
mix((player.alive ?? player.hp > 0) ? 1 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const monster of world.monsters) {
|
for (const monster of world.monsters) {
|
||||||
mix(round(monster.x))
|
mix(round(monster.x))
|
||||||
mix(round(monster.y))
|
mix(round(monster.y))
|
||||||
mix(monster.hp)
|
mix(monster.hp)
|
||||||
mixString(monster.state)
|
mixString(monster.state ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hash Inventories
|
// 2. Hash Inventories
|
||||||
|
|
@ -368,3 +464,5 @@ export function computeLockstepHash(world: HashableWorld, inventories: readonly
|
||||||
|
|
||||||
return hash >>> 0
|
return hash >>> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const computeStateHash = computeLockstepHash
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,28 @@
|
||||||
* all is a *timeout*, kept separate from a desync — different problem, different
|
* all is a *timeout*, kept separate from a desync — different problem, different
|
||||||
* response (drop the peer versus stop and resync).
|
* 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 { DEFAULT_HISTORY, LockstepSession, MAX_FUTURE_TICKS, 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 { decodeMessage, encodeMessage, MOVEMENT_SCALE, NO_ACK, ProtocolError, type NetMessage } from './protocol.ts'
|
||||||
import type { Transport } from './transport.ts'
|
import type { Transport } from './transport.ts'
|
||||||
|
|
||||||
/** Pumps between repeated handshake attempts, until the peer answers. */
|
/** Pumps between repeated handshake attempts, until the peer answers. */
|
||||||
const HANDSHAKE_RETRY_PUMPS = 25
|
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. */
|
/** Session configuration beyond the lockstep core's own options. */
|
||||||
export interface NetplayOptions extends LockstepOptions {
|
export interface NetplayOptions extends LockstepOptions {
|
||||||
/** This peer's index. */
|
/** 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
|
* 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.
|
* 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. */
|
/** Pumps since the peer last said anything. */
|
||||||
private silentTicks = 0
|
private silentTicks = 0
|
||||||
private handshaked = false
|
private handshaked = false
|
||||||
|
|
@ -376,20 +391,23 @@ export class NetplaySession {
|
||||||
*/
|
*/
|
||||||
private drainRemoteHashes(): void {
|
private drainRemoteHashes(): void {
|
||||||
if (this.pendingRemoteHashes.size === 0) return
|
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
|
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) {
|
if (tick < oldest) {
|
||||||
this.pendingRemoteHashes.delete(tick)
|
this.pendingRemoteHashes.delete(tick)
|
||||||
this.hashesIgnored += 1
|
this.hashesIgnored += hashes.length
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (tick >= this.lockstep.tick) continue
|
if (tick >= this.lockstep.tick) continue
|
||||||
this.pendingRemoteHashes.delete(tick)
|
this.pendingRemoteHashes.delete(tick)
|
||||||
|
for (const hash of hashes) {
|
||||||
this.hashesCompared += 1
|
this.hashesCompared += 1
|
||||||
if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1
|
if (this.lockstep.compare({ tick, hash }) === null) this.hashesAgreed += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Local tick, for outbound timestamps. */
|
/** Local tick, for outbound timestamps. */
|
||||||
private get now(): number {
|
private get now(): number {
|
||||||
|
|
@ -466,18 +484,35 @@ export class NetplaySession {
|
||||||
this.malformed += 1
|
this.malformed += 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.received += 1
|
if (
|
||||||
this.silentTicks = 0
|
!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) {
|
switch (message.kind) {
|
||||||
case 'hello': {
|
case 'hello': {
|
||||||
if (message.peer === this.options.peer || message.peer >= this.options.peers) {
|
if (
|
||||||
// A hello from ourselves, or from a peer index nobody can have: ignored
|
message.peers !== this.options.peers ||
|
||||||
// rather than counted, because it says the sender is confused about the
|
(message.ackTo !== NO_ACK && (message.ackTo < 0 || message.ackTo >= this.options.peers))
|
||||||
// game it joined.
|
) {
|
||||||
this.malformed += 1
|
this.malformed += 1
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
const link = this.linkOf(message.peer)
|
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.heard = true
|
||||||
link.seed = message.seed
|
link.seed = message.seed
|
||||||
// The seed is checked, not trusted: peers that disagree about it build
|
// The seed is checked, not trusted: peers that disagree about it build
|
||||||
|
|
@ -496,16 +531,71 @@ export class NetplaySession {
|
||||||
this.handshaked = this.ready
|
this.handshaked = this.ready
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'input':
|
case 'input': {
|
||||||
this.lockstep.submit(message.peer, message.frame)
|
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
|
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)
|
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()
|
this.drainRemoteHashes()
|
||||||
break
|
break
|
||||||
case 'bye':
|
}
|
||||||
|
case 'bye': {
|
||||||
|
this.received += 1
|
||||||
|
this.silentTicks = 0
|
||||||
this.closed = true
|
this.closed = true
|
||||||
break
|
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. */
|
/* v8 ignore next -- the message union is closed above. */
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,12 @@ export const MESSAGE_TYPE = {
|
||||||
hash: 3,
|
hash: 3,
|
||||||
/** Peer leaving. */
|
/** Peer leaving. */
|
||||||
bye: 4,
|
bye: 4,
|
||||||
|
/** Liveness heartbeat for a tick. */
|
||||||
|
heartbeat: 5,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export const PACKET_TYPE = MESSAGE_TYPE
|
||||||
|
|
||||||
/** A decoded message. */
|
/** A decoded message. */
|
||||||
export type NetMessage =
|
export type NetMessage =
|
||||||
| {
|
| {
|
||||||
|
|
@ -41,17 +45,20 @@ export type NetMessage =
|
||||||
| { readonly kind: 'input'; readonly peer: number; readonly frame: InputFrame }
|
| { readonly kind: 'input'; readonly peer: number; readonly frame: InputFrame }
|
||||||
| { readonly kind: 'hash'; readonly peer: number; readonly tick: number; readonly hash: number }
|
| { readonly kind: 'hash'; readonly peer: number; readonly tick: number; readonly hash: number }
|
||||||
| { readonly kind: 'bye'; readonly peer: number }
|
| { readonly kind: 'bye'; readonly peer: number }
|
||||||
|
| { readonly kind: 'heartbeat'; readonly peer: number; readonly tick: number }
|
||||||
|
|
||||||
/** Scaling used for movement components. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** Input frame flags byte. */
|
||||||
const FLAG_ATTACK = 1
|
const FLAG_ATTACK = 1
|
||||||
|
|
@ -132,6 +139,14 @@ export function encodeMessage(message: NetMessage): Uint8Array {
|
||||||
out[1] = message.peer & 0xff
|
out[1] = message.peer & 0xff
|
||||||
return out
|
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. */
|
/* v8 ignore next -- the message union is closed above. */
|
||||||
default: throw new ProtocolError('unknown message')
|
default: throw new ProtocolError('unknown message')
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +175,9 @@ export function decodeMessage(data: Uint8Array): NetMessage {
|
||||||
case MESSAGE_TYPE.input: {
|
case MESSAGE_TYPE.input: {
|
||||||
if (data.byteLength !== INPUT_BYTES) throw new ProtocolError(`input has ${String(data.byteLength)} bytes, expected ${String(INPUT_BYTES)}`)
|
if (data.byteLength !== INPUT_BYTES) throw new ProtocolError(`input has ${String(data.byteLength)} bytes, expected ${String(INPUT_BYTES)}`)
|
||||||
const flags = data[10] ?? 0
|
const flags = data[10] ?? 0
|
||||||
|
if ((flags & ~(FLAG_ATTACK | FLAG_PICKUP | FLAG_TALK)) !== 0) {
|
||||||
|
throw new ProtocolError(`input has invalid flags ${String(flags)}`)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: 'input',
|
kind: 'input',
|
||||||
peer,
|
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)}`)
|
if (data.byteLength !== BYE_BYTES) throw new ProtocolError(`bye has ${String(data.byteLength)} bytes, expected ${String(BYE_BYTES)}`)
|
||||||
return { kind: 'bye', peer }
|
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. */
|
/* v8 ignore next -- unreachable for the checked tags. */
|
||||||
default: throw new ProtocolError(`unknown message type ${String(data[0])}`)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export interface TransportOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Largest message accepted by default. */
|
/** Largest message accepted by default. */
|
||||||
const DEFAULT_MAX_MESSAGE = 4096
|
export const DEFAULT_MAX_MESSAGE = 4096
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a connected pair of in-process transports.
|
* Build a connected pair of in-process transports.
|
||||||
|
|
@ -398,3 +398,5 @@ export function socketTransport(socket: SocketLike, options: TransportOptions =
|
||||||
get open(): boolean { return opened },
|
get open(): boolean { return opened },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const webSocketTransport = socketTransport
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue