861 lines
40 KiB
TypeScript
861 lines
40 KiB
TypeScript
/**
|
|
* Network checks: wire protocol, transports, and two peers playing over a socket.
|
|
*
|
|
* The claim being tested is not "messages encode" — it is that two independently
|
|
* simulated worlds, driven only by messages that crossed a real byte pipe, stay
|
|
* bit-identical; that a peer which goes quiet *stalls* the world instead of
|
|
* corrupting it; and that a peer whose input is altered in flight is *detected*.
|
|
*
|
|
* The last section runs the same session code over a real TCP socket and real
|
|
* WebSocket framing (through the relay in `scripts/net-relay.ts`), because an
|
|
* in-memory pipe cannot prove that the browser transport works: it never frames,
|
|
* never splits a message across TCP segments, and never delivers asynchronously.
|
|
*
|
|
* Usage: node scripts/verify-net.ts
|
|
*/
|
|
import { addPlayer, createWorld, damageMonster, spawnMonsters, tickCombat, tickCombatMulti } from '../src/game/combat.ts'
|
|
import type { CombatOptions, CombatWorld, MonsterStats } from '../src/game/combat.ts'
|
|
import { Rng } from '../src/game/rng.ts'
|
|
import { LockstepSession } from '../src/net/lockstep.ts'
|
|
import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts'
|
|
import { NetplaySession } from '../src/net/netplay.ts'
|
|
import { decodeMessage, encodeMessage, NO_ACK, ProtocolError } from '../src/net/protocol.ts'
|
|
import type { NetMessage } from '../src/net/protocol.ts'
|
|
import { MemoryHub, memoryTransportPair, socketTransport, MemoryTransport } from '../src/net/transport.ts'
|
|
import type { Transport } from '../src/net/transport.ts'
|
|
import { parseTable } from '../src/game/tables.ts'
|
|
import { monsterStatsFromTable } from '../src/game/combat.ts'
|
|
import { startRelay } from './net-relay.ts'
|
|
|
|
const problems: string[] = []
|
|
let checks = 0
|
|
|
|
/**
|
|
* Assert one condition.
|
|
*
|
|
* @param condition - the condition to hold.
|
|
* @param description - what it means.
|
|
*/
|
|
function expect(condition: boolean, description: string): void {
|
|
checks += 1
|
|
if (!condition) problems.push(description)
|
|
}
|
|
|
|
// --- the wire codec ----------------------------------------------------------
|
|
|
|
const messages: NetMessage[] = [
|
|
{ kind: 'hello', peer: 1, peers: 2, seed: 0xdeadbeef, ackTo: NO_ACK },
|
|
{ kind: 'hello', peer: 0, peers: 2, seed: 0xdeadbeef, ackTo: 1 },
|
|
{ kind: 'input', peer: 0, frame: { tick: 1234, movement: { x: -1, y: 0.5 }, attack: true, pickup: false, talk: true, skill: 3 } },
|
|
{ kind: 'hash', peer: 1, tick: 987654, hash: 0x89abcdef },
|
|
{ kind: 'bye', peer: 0 },
|
|
]
|
|
for (const message of messages) {
|
|
const round = decodeMessage(encodeMessage(message))
|
|
expect(round.kind === message.kind, `${message.kind} survives a round trip`)
|
|
if (round.kind === 'input' && message.kind === 'input') {
|
|
expect(round.frame.tick === message.frame.tick, 'the tick round-trips')
|
|
expect(Math.abs(round.frame.movement.x - message.frame.movement.x) < 1e-3, 'the x movement round-trips as fixed point')
|
|
expect(Math.abs(round.frame.movement.y - message.frame.movement.y) < 1e-3, 'the y movement round-trips as fixed point')
|
|
expect(round.frame.attack && !round.frame.pickup && round.frame.talk, 'the control flags round-trip')
|
|
expect(round.frame.skill === 3, 'the skill slot round-trips')
|
|
}
|
|
if (round.kind === 'hello' && message.kind === 'hello') expect(round.seed === 0xdeadbeef, 'the seed round-trips')
|
|
if (round.kind === 'hash' && message.kind === 'hash') {
|
|
expect(round.tick === 987654 && round.hash === 0x89abcdef, 'the hash and tick round-trip')
|
|
}
|
|
}
|
|
|
|
// The layout is pinned, not just self-consistent: a peer running an older build
|
|
// must fail loudly rather than silently misread a field.
|
|
const pinned = encodeMessage({ kind: 'input', peer: 1, frame: { tick: 1, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 } })
|
|
expect(pinned.byteLength === 12, 'an input message is twelve bytes')
|
|
expect(pinned[0] === 2 && pinned[1] === 1, 'an input message starts with its type and peer')
|
|
expect(pinned[6] === 0x18 && pinned[7] === 0xfc, 'a movement of -1 is fixed point -1000, little-endian')
|
|
expect(pinned[10] === 1, 'only the attack flag is set')
|
|
expect(encodeMessage({ kind: 'hash', peer: 0, tick: 0, hash: 0 }).byteLength === 10, 'a hash message is ten bytes')
|
|
expect(encodeMessage({ kind: 'hello', peer: 0, peers: 1, seed: 0, ackTo: NO_ACK }).byteLength === 8, 'a hello message is eight bytes')
|
|
const acked = decodeMessage(encodeMessage({ kind: 'hello', peer: 0, peers: 4, seed: 7, ackTo: 3 }))
|
|
expect(acked.kind === 'hello' && acked.ackTo === 3, 'an acknowledgement names the peer it acknowledges')
|
|
const unacked = decodeMessage(encodeMessage({ kind: 'hello', peer: 0, peers: 4, seed: 7, ackTo: NO_ACK }))
|
|
expect(unacked.kind === 'hello' && unacked.ackTo === NO_ACK, 'a plain introduction is not mistaken for an acknowledgement')
|
|
|
|
const bad: [string, Uint8Array][] = [
|
|
['an empty message', new Uint8Array(0)],
|
|
['an unknown type', new Uint8Array([99, 0])],
|
|
['a truncated input', new Uint8Array([2, 0, 0, 0])],
|
|
['an over-long input', new Uint8Array(16)],
|
|
['a hello claiming zero peers', new Uint8Array([1, 0, 0, 0, 0, 0, 0, 0])],
|
|
['a hello claiming nine peers', new Uint8Array([1, 0, 9, 0, 0, 0, 0, 0])],
|
|
['a truncated hash', new Uint8Array([3, 0, 0])],
|
|
]
|
|
for (const [label, bytes] of bad) {
|
|
let rejected = false
|
|
try {
|
|
decodeMessage(bytes)
|
|
} catch (error) {
|
|
rejected = error instanceof ProtocolError
|
|
}
|
|
expect(rejected, `${label} is rejected before it reaches the simulation`)
|
|
}
|
|
|
|
const movementOverflow = decodeMessage(encodeMessage({
|
|
kind: 'input', peer: 0, frame: { tick: 0, movement: { x: 1e9, y: -1e9 }, attack: false, pickup: false, talk: false, skill: 255 },
|
|
}))
|
|
expect(movementOverflow.kind === 'input' && movementOverflow.frame.movement.x <= 32767, 'an absurd movement is clamped, not wrapped')
|
|
|
|
// --- transports --------------------------------------------------------------
|
|
|
|
const [endA, endB] = memoryTransportPair()
|
|
let receivedAtB = 0
|
|
endB.onMessage(() => { receivedAtB += 1 })
|
|
expect(endA.open && endB.open, 'a fresh memory pair is open at both ends')
|
|
endA.send(new Uint8Array([1, 2, 3]))
|
|
expect(receivedAtB === 0, 'a memory message is queued, not delivered on send')
|
|
endA.flush()
|
|
expect(receivedAtB === 1, 'flushing delivers the queued message')
|
|
const oversizeRejected = ((): boolean => {
|
|
try {
|
|
endA.send(new Uint8Array(5000))
|
|
return false
|
|
} catch { return true }
|
|
})()
|
|
expect(oversizeRejected, 'an oversized message is refused at the transport')
|
|
let closed = 0
|
|
endA.onClose(() => { closed += 1 })
|
|
endB.close()
|
|
expect(closed === 1 && !endA.open && !endB.open, 'closing one end closes the pair')
|
|
endA.send(new Uint8Array([9]))
|
|
endA.flush()
|
|
expect(endA.dropped === 1, 'sending on a closed pair drops instead of throwing')
|
|
|
|
// --- two peers, one world, over a byte pipe ----------------------------------
|
|
|
|
const stats: MonsterStats[] = monsterStatsFromTable(parseTable([
|
|
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
|
// Fast enough to catch the player and weak enough that neither peer's player
|
|
// dies before the kills can be compared: the point of this table is contact.
|
|
'fallen\tFallen\t12\t1\t24\t36\t400\t220\t8',
|
|
'zombie\tZombie\t30\t1\t32\t40\t300\t210\t15',
|
|
].join('\n')))
|
|
|
|
const options: CombatOptions = {
|
|
playerSpeed: 180, playerReach: 48, playerCooldownTicks: 12, playerDamage: 6,
|
|
playerManaPerAttack: 2, respawnTicks: 40,
|
|
}
|
|
const xpTable: readonly number[] = [0, 0, 1000]
|
|
|
|
/** A world plus its random stream: everything a peer simulates. */
|
|
interface Game { world: ReturnType<typeof createWorld>; rng: Rng }
|
|
|
|
/**
|
|
* Build a fresh game, identical on both peers.
|
|
*
|
|
* @param seed - the world seed.
|
|
* @returns the game.
|
|
*/
|
|
function newGame(seed: number): Game {
|
|
const world = createWorld(0, 0)
|
|
// The monsters start close enough to reach the player inside the first few
|
|
// seconds: a network test where nothing ever fights would agree on nothing.
|
|
spawnMonsters(world, stats, 5, { x: 30, y: 0 }, 40, { overlap: () => 0 })
|
|
return { world, rng: new Rng(seed) }
|
|
}
|
|
|
|
/**
|
|
* The input script: a pure function of the tick and the peer, so there is nothing
|
|
* here that two peers could disagree about.
|
|
*
|
|
* @param peer - the peer index.
|
|
* @param tick - the tick.
|
|
* @returns movement and intent.
|
|
*/
|
|
function scriptedInput(peer: number, tick: number): { movement: { x: number; y: number }; attack: boolean; pickup: boolean; talk: boolean; skill: number } {
|
|
const phase = Math.floor((tick + peer * 7) / 25) % 4
|
|
const movement = phase === 0 ? { x: 1, y: 0 } : phase === 1 ? { x: 0, y: 1 } : phase === 2 ? { x: -1, y: 0 } : { x: 0, y: -1 }
|
|
return { movement, attack: (tick + peer) % 5 === 0, pickup: tick % 41 === 0, talk: false, skill: (tick + peer) % 4 }
|
|
}
|
|
|
|
/**
|
|
* Wrap a game as a lockstep simulation. Every peer applies *every* peer's input,
|
|
* and the digest covers the whole world, so a field missed by one peer's
|
|
* simulation shows up as a desync.
|
|
*
|
|
* @param game - the game to drive.
|
|
* @returns the simulation.
|
|
*/
|
|
function asSimulation(game: Game): LockstepSimulation {
|
|
return {
|
|
advance: (inputs) => {
|
|
for (const input of inputs) {
|
|
// Only movement and attack reach the combat tick; pickup and talk belong
|
|
// to the scene, so they are deliberately not part of the digest here.
|
|
tickCombat(game.world, { movement: input.movement, attack: input.attack }, options, { overlap: () => 0 }, xpTable)
|
|
if (input.attack && game.world.player.cooldown === 0) {
|
|
const swing = game.rng.next() // the drop stream is part of the world
|
|
game.world.monsters.forEach((monster, index) => {
|
|
if (monster.state === 'dead') return
|
|
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
|
|
damageMonster(game.world, index, options.playerDamage + (swing > 0.5 ? 1 : 0))
|
|
})
|
|
}
|
|
}
|
|
},
|
|
hash: () => LockstepSession.digest(digest(game)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Digest everything, so a divergence cannot hide in a field the hash forgets.
|
|
*
|
|
* @param game - the game.
|
|
* @returns a digest string.
|
|
*/
|
|
function digest(game: Game): string {
|
|
const round = (value: number): number => Math.round(value * 1000)
|
|
return JSON.stringify({
|
|
tick: game.world.tick,
|
|
kills: game.world.kills,
|
|
rng: game.rng.seed,
|
|
events: game.world.events.map(event => [event.kind, event.subjectId ?? '', round(event.x), round(event.y)]),
|
|
player: {
|
|
x: round(game.world.player.x), y: round(game.world.player.y),
|
|
hp: game.world.player.hp, mana: game.world.player.mana,
|
|
cooldown: game.world.player.cooldown, facing: game.world.player.facing,
|
|
alive: game.world.player.alive,
|
|
},
|
|
monsters: game.world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state, m.cooldown, m.hitFlash]),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* A peer: its session and the game it drives.
|
|
*/
|
|
interface Peer { session: NetplaySession; game: Game }
|
|
|
|
/**
|
|
* Build one networked peer.
|
|
*
|
|
* @param index - the peer index.
|
|
* @param transport - its byte pipe.
|
|
* @param seed - the world seed.
|
|
* @returns the peer.
|
|
*/
|
|
function makePeer(index: number, transport: Transport, seed: number): Peer {
|
|
const game = newGame(seed)
|
|
const session = new NetplaySession(
|
|
{ peer: index, peers: 2, seed, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 40 },
|
|
asSimulation(game),
|
|
transport,
|
|
)
|
|
const peer: Peer = { session, game }
|
|
session.start()
|
|
return peer
|
|
}
|
|
|
|
// --- the quiet-peer case: a stall must not corrupt the world ------------------
|
|
|
|
const [pipeA, pipeB] = memoryTransportPair()
|
|
const peerA = makePeer(0, pipeA, 0x51ed)
|
|
const peerB = makePeer(1, pipeB, 0x51ed)
|
|
// The handshake is owed by `start` and delivered by the first pump: a session is
|
|
// normally created while its socket is still connecting, so sending on `start`
|
|
// would write into a socket that is not open yet.
|
|
expect(peerA.session.stats.helloSent === 0 && !peerA.session.stats.handshaked, 'nothing is sent before the session is pumped')
|
|
peerA.session.pump()
|
|
peerB.session.pump()
|
|
pipeA.flush()
|
|
expect(peerA.session.remoteSeed === 0x51ed && peerB.session.remoteSeed === 0x51ed, 'the handshake exchanges the world seed')
|
|
expect(peerA.session.stats.handshaked && peerB.session.stats.handshaked, 'both peers complete the handshake')
|
|
// The acknowledgements are still in flight; delivering them must end the
|
|
// handshake for good rather than leaving either peer repeating itself.
|
|
pipeA.flush()
|
|
expect(peerA.session.stats.acknowledged && peerB.session.stats.acknowledged, 'both peers learn that their own hello was heard')
|
|
for (let tick = 0; tick < 40; tick += 1) {
|
|
peerA.session.pump()
|
|
peerB.session.pump()
|
|
pipeA.flush()
|
|
}
|
|
expect(
|
|
peerA.session.stats.helloSent === 2 && peerB.session.stats.helloSent === 2,
|
|
'a completed handshake stops: one hello and one acknowledgement each',
|
|
)
|
|
|
|
const TICKS = 200
|
|
let stepsA = 0
|
|
let stepsB = 0
|
|
for (let tick = 0; tick < TICKS; tick += 1) {
|
|
const inputA = scriptedInput(0, tick)
|
|
const inputB = scriptedInput(1, tick)
|
|
peerA.session.setIntent(inputA)
|
|
peerB.session.setIntent(inputB)
|
|
if (peerA.session.pump().kind === 'stepped') stepsA += 1
|
|
if (peerB.session.pump().kind === 'stepped') stepsB += 1
|
|
// B's link is held for a stretch: A must stall, B must run on, and neither may
|
|
// end up with a different world.
|
|
pipeB.hold = tick >= 60 && tick < 90
|
|
pipeA.flush()
|
|
}
|
|
// Drain the backlog the held link accumulated.
|
|
for (let round = 0; round < 60; round += 1) {
|
|
peerA.session.setIntent({ movement: { x: 0, y: 0 }, attack: false })
|
|
peerB.session.setIntent({ movement: { x: 0, y: 0 }, attack: false })
|
|
peerA.session.pump()
|
|
peerB.session.pump()
|
|
pipeA.flush()
|
|
}
|
|
|
|
expect(peerA.session.desyncReport === null && peerB.session.desyncReport === null, 'a peer whose link stalls does not cause a desync')
|
|
expect(peerA.session.stats.hashesCompared === peerA.session.stats.hashesAgreed, 'every hash peer 0 could check agreed')
|
|
expect(peerA.session.stats.waiting > 0, 'the peer that waited counted the stalled ticks')
|
|
expect(peerA.session.stats.hashesCompared > 0 && peerB.session.stats.hashesCompared > 0, 'both peers compared state hashes')
|
|
expect(
|
|
peerB.session.stats.hashesCompared === peerB.session.stats.hashesAgreed,
|
|
'every hash that arrived at peer 1 agreed',
|
|
)
|
|
expect(peerA.session.stats.malformed === 0 && peerB.session.stats.malformed === 0, 'no message was malformed')
|
|
expect(peerA.session.stats.timedOut === false, 'a link that stalls for a moment is not declared lost')
|
|
expect(peerA.session.stats.sent > 100 && peerA.session.stats.received > 100, 'traffic actually crossed the pipe')
|
|
expect(stepsA > 100 && stepsB > 100, 'both peers ran the game, one eventually catching up')
|
|
expect(stepsB > stepsA, 'the peer whose link was open ran ahead while the other was starved')
|
|
// A peer that is starved stops producing input, so its partner runs only as far
|
|
// as the inputs it already holds and then waits too — the world as a whole is held
|
|
// back, which is the point of lockstep. The two ends therefore resume with a small
|
|
// constant lead rather than snapping level.
|
|
const lead = peerB.session.lockstep.tick - peerA.session.lockstep.tick
|
|
expect(lead >= 0 && lead <= 6, 'the two ends resume within a few ticks of each other')
|
|
// The starved peer still holds the other's backlog, so it can be brought level
|
|
// without any further traffic from the far end: pumping peer 0 alone (and holding
|
|
// its own sends) replays exactly the ticks it was missing.
|
|
for (let extra = 0; extra < 12 && peerA.session.lockstep.tick < peerB.session.lockstep.tick; extra += 1) {
|
|
peerA.session.setIntent(scriptedInput(0, peerA.session.lockstep.tick))
|
|
peerA.session.pump()
|
|
}
|
|
expect(
|
|
peerA.session.lockstep.tick === peerB.session.lockstep.tick,
|
|
'the starved peer can be brought level from the backlog it already holds',
|
|
)
|
|
expect(
|
|
digest(peerA.game) === digest(peerB.game),
|
|
'at the same tick, the peer that was starved holds exactly the other peer\'s world',
|
|
)
|
|
expect(peerA.game.world.kills === peerB.game.world.kills, 'both peers agree on the kill count')
|
|
expect(peerA.game.rng.seed === peerB.game.rng.seed, 'both peers agree on the random stream position')
|
|
expect(peerA.game.world.monsters.every((m, i) => m.hp === peerB.game.world.monsters[i]?.hp), 'both peers agree on monster health')
|
|
expect(peerA.game.world.kills > 0, 'the test actually produced kills to disagree about')
|
|
// The starved peer buffered the hashes that arrived for ticks it had not run yet,
|
|
// so once it catches up it checks them all: being behind costs nothing.
|
|
expect(peerA.session.stats.hashesIgnored === 0, 'a peer that fell behind still checks every hash it received')
|
|
expect(peerA.session.stats.hashesCompared > 100, 'catching up results in a real number of compared hashes')
|
|
expect(peerB.session.stats.hashesAgreed === peerB.session.stats.hashesCompared, 'every hash peer 1 could check agreed')
|
|
|
|
|
|
// --- co-op: two peers, one shared world, one fighter each ---------------------
|
|
|
|
/**
|
|
* Build a co-op world: two players, monsters between them.
|
|
*
|
|
* Both peers call this with the same seed and get the same world, which is the
|
|
* whole premise of lockstep — nothing about the world is sent, only what the
|
|
* players pressed.
|
|
*
|
|
* @returns the world.
|
|
*/
|
|
function newCoopWorld(): CombatWorld {
|
|
const world = createWorld(-40, 0)
|
|
addPlayer(world, 40, 0)
|
|
spawnMonsters(world, coopStats, 6, { x: 0, y: 0 }, 90, { overlap: () => 0 })
|
|
return world
|
|
}
|
|
|
|
/**
|
|
* Digest a co-op world.
|
|
*
|
|
* The player list is digested in peer order; `world.player` is a view alias and is
|
|
* deliberately absent, because which player is "local" must not affect the state
|
|
* two peers agree on.
|
|
*
|
|
* @param world - the world.
|
|
* @returns a digest string.
|
|
*/
|
|
function coopDigest(world: CombatWorld): string {
|
|
const round = (value: number): number => Math.round(value * 1000)
|
|
return JSON.stringify({
|
|
tick: world.tick,
|
|
kills: world.kills,
|
|
players: world.players.map(player => [round(player.x), round(player.y), player.hp, player.xp, player.level, player.alive]),
|
|
monsters: world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state]),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* A co-op simulation: each peer's input drives its own fighter.
|
|
*
|
|
* @param world - the shared world.
|
|
* @returns the simulation.
|
|
*/
|
|
function coopSimulation(world: CombatWorld): LockstepSimulation {
|
|
return {
|
|
advance: (inputs) => {
|
|
tickCombatMulti(world, inputs.map(input => ({ movement: input.movement, attack: input.attack })), options, { overlap: () => 0 }, xpTable)
|
|
},
|
|
hash: () => LockstepSession.digest(coopDigest(world)),
|
|
}
|
|
}
|
|
|
|
const coopStats: MonsterStats[] = monsterStatsFromTable(parseTable([
|
|
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
|
'fallen\tFallen\t14\t1\t24\t36\t400\t220\t8',
|
|
].join('\n')))
|
|
|
|
const [coopPipeA, coopPipeB] = memoryTransportPair()
|
|
const coopWorldA = newCoopWorld()
|
|
const coopWorldB = newCoopWorld()
|
|
const coopA = new NetplaySession(
|
|
{ peer: 0, peers: 2, seed: 0x9a9a, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
|
coopSimulation(coopWorldA), coopPipeA,
|
|
)
|
|
const coopB = new NetplaySession(
|
|
{ peer: 1, peers: 2, seed: 0x9a9a, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
|
coopSimulation(coopWorldB), coopPipeB,
|
|
)
|
|
coopA.start()
|
|
coopB.start()
|
|
coopPipeA.flush()
|
|
for (let tick = 0; tick < 300; tick += 1) {
|
|
coopA.setIntent(scriptedInput(0, tick))
|
|
coopB.setIntent(scriptedInput(1, tick))
|
|
coopA.pump()
|
|
coopB.pump()
|
|
coopPipeA.flush()
|
|
}
|
|
expect(coopA.desyncReport === null && coopB.desyncReport === null, 'two players in one world do not desync')
|
|
expect(coopA.stats.hashesCompared > 200 && coopA.stats.hashesCompared === coopA.stats.hashesAgreed, 'every co-op hash peer 0 could check agreed')
|
|
expect(coopB.stats.hashesCompared > 200 && coopB.stats.hashesCompared === coopB.stats.hashesAgreed, 'every co-op hash peer 1 could check agreed')
|
|
expect(coopA.lockstep.tick >= 295 && coopB.lockstep.tick === coopA.lockstep.tick, 'both peers ran the whole co-op run, tick for tick together')
|
|
expect(coopDigest(coopWorldA) === coopDigest(coopWorldB), 'the two peers hold the same two-player world')
|
|
expect(coopWorldA.players.length === 2, 'the world really has two players in it')
|
|
expect(coopWorldA.kills > 0, 'the two players killed something together')
|
|
expect(coopWorldA.players[0]!.xp !== coopWorldA.players[1]!.xp, 'experience is credited to the peer that landed the killing blow')
|
|
expect(coopWorldA.players[0]!.x !== coopWorldB.players[0]!.x || coopWorldA.players[0]!.y === coopWorldB.players[0]!.y, 'player 0 moved under its own input')
|
|
expect(coopDigest(coopWorldA) !== coopDigest(newCoopWorld()), 'the co-op world actually changed while it ran')
|
|
|
|
// Monsters go for whoever is nearest, not for player 0 by seniority.
|
|
const bait = createWorld(-300, 0)
|
|
addPlayer(bait, 0, 0)
|
|
bait.monsters.push({
|
|
index: 0, stats: { ...coopStats[0]!, aggroRadius: 400, reach: 40, speed: 0 }, x: 6, y: 0,
|
|
hp: 50, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
})
|
|
const idle = { movement: { x: 0, y: 0 }, attack: false }
|
|
for (let tick = 0; tick < 40; tick += 1) tickCombatMulti(bait, [idle, idle], options, { overlap: () => 0 }, xpTable)
|
|
expect(bait.players[1]!.hp < bait.players[1]!.maxHp, 'a monster attacks the nearest player')
|
|
expect(bait.players[0]!.hp === bait.players[0]!.maxHp, 'a monster 300 pixels away from the other player leaves it alone')
|
|
|
|
// A dead peer must not stall the monsters or hide the living one.
|
|
const bereaved = createWorld(-30, 0)
|
|
addPlayer(bereaved, 30, 0)
|
|
bereaved.players[1]!.hp = 0
|
|
bereaved.players[1]!.alive = false
|
|
bereaved.players[1]!.respawnIn = 30
|
|
// The monster stands on top of the dead peer and within reach of the living one,
|
|
// so a monster that still targeted corpses would attack the wrong player.
|
|
bereaved.monsters.push({
|
|
index: 0, stats: { ...coopStats[0]!, aggroRadius: 400, reach: 80, speed: 0 }, x: 30, y: 0,
|
|
hp: 50, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0,
|
|
})
|
|
for (let tick = 0; tick < 10; tick += 1) tickCombatMulti(bereaved, [idle, idle], options, { overlap: () => 0 }, xpTable)
|
|
expect(bereaved.players[0]!.hp < bereaved.players[0]!.maxHp, 'with one peer dead the monsters hunt the other')
|
|
expect(!bereaved.players[1]!.alive && bereaved.players[1]!.hp === 0, 'a dead peer stays dead while its respawn timer runs')
|
|
|
|
// A hash whose tick has already fallen out of history is a different case: it
|
|
// cannot be checked at all, and saying so is the honest answer.
|
|
const [latePipeA, latePipeB] = memoryTransportPair()
|
|
const lateA = makePeer(0, latePipeA, 0x6666)
|
|
const lateB = makePeer(1, latePipeB, 0x6666)
|
|
latePipeA.flush()
|
|
for (let tick = 0; tick < 120; tick += 1) {
|
|
lateA.session.setIntent(scriptedInput(0, tick))
|
|
lateB.session.setIntent(scriptedInput(1, tick))
|
|
lateA.session.pump()
|
|
lateB.session.pump()
|
|
latePipeA.flush()
|
|
}
|
|
latePipeB.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 0x1234 }))
|
|
latePipeA.flush()
|
|
for (let extra = 0; extra < 5; extra += 1) { lateA.session.pump(); latePipeA.flush() }
|
|
expect(lateA.session.stats.hashesIgnored === 1, 'a hash for a tick that has left history is counted as uncheckable')
|
|
expect(lateA.session.desyncReport === null, 'an uncheckable hash is not mistaken for a mismatch')
|
|
expect(lateA.session.stats.hashesAgreed === lateA.session.stats.hashesCompared, 'a stale hash does not corrupt the agreement count')
|
|
|
|
// --- four peers, one world, one fighter each --------------------------------
|
|
|
|
/**
|
|
* Build a world with a given number of players, in peer order.
|
|
*
|
|
* @param count - how many players.
|
|
* @returns the world.
|
|
*/
|
|
function newHubWorld(count: number): CombatWorld {
|
|
const world = createWorld(-60, 0)
|
|
for (let index = 1; index < count; index += 1) addPlayer(world, -60 + index * 40, 0)
|
|
spawnMonsters(world, coopStats, 8, { x: 0, y: 0 }, 140, { overlap: () => 0 })
|
|
return world
|
|
}
|
|
|
|
const FOUR = 4
|
|
// The hub is grown rather than pre-sized: a peer that has not joined has no pipe
|
|
// at all, which is the case worth testing — a transport that merely exists
|
|
// handshakes immediately, because answering a hello is not a game action.
|
|
const hub = new MemoryHub()
|
|
const hubWorlds: CombatWorld[] = []
|
|
const hubSessions: NetplaySession[] = []
|
|
for (let index = 0; index < FOUR - 1; index += 1) {
|
|
const world = newHubWorld(FOUR)
|
|
hubWorlds.push(world)
|
|
const session = new NetplaySession(
|
|
{ peer: index, peers: FOUR, seed: 0x4bee, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
|
coopSimulation(world),
|
|
hub.attach(),
|
|
)
|
|
session.start()
|
|
hubSessions.push(session)
|
|
}
|
|
|
|
/**
|
|
* Pump one round: every running peer once, then deliver.
|
|
*
|
|
* @param tick - the scripted tick.
|
|
*/
|
|
function pumpRound(tick: number): void {
|
|
for (let index = 0; index < hubSessions.length; index += 1) {
|
|
hubSessions[index]!.setIntent(scriptedInput(index, tick))
|
|
hubSessions[index]!.pump()
|
|
}
|
|
hub.flush()
|
|
}
|
|
|
|
// Three peers are up; the fourth has not joined.
|
|
for (let tick = 0; tick < 60; tick += 1) pumpRound(tick)
|
|
expect(hubSessions[0]!.stats.peersHeard === 2, 'a session knows which of its peers it has heard from')
|
|
expect(hubSessions[0]!.stats.peersExpected === 3, 'a four-peer session expects three other peers')
|
|
expect(!hubSessions[0]!.stats.ready, 'a session expecting four peers is not ready with three')
|
|
expect(hubSessions[0]!.lockstep.tick === 0, 'with a peer still missing, nobody starts the game')
|
|
expect(hubSessions[0]!.stats.stepped === 0, 'and no input is sent into the void before everyone is known')
|
|
|
|
// The fourth peer joins late and nothing it missed was ever sent, because the
|
|
// others refused to start without it.
|
|
const lateWorld = newHubWorld(FOUR)
|
|
const lateSession = new NetplaySession(
|
|
{ peer: 3, peers: FOUR, seed: 0x4bee, inputDelayTicks: 3, hashInterval: 1, timeoutTicks: 400 },
|
|
coopSimulation(lateWorld),
|
|
hub.attach(),
|
|
)
|
|
lateSession.start()
|
|
hubWorlds.push(lateWorld)
|
|
hubSessions.push(lateSession)
|
|
for (let tick = 60; tick < 360; tick += 1) pumpRound(tick)
|
|
expect(hubSessions.every(session => session.stats.handshaked), 'all four peers complete the handshake')
|
|
expect(hubSessions.every(session => session.stats.acknowledged), 'every peer is acknowledged by all the others')
|
|
expect(hubSessions.every(session => session.stats.ready), 'every peer becomes ready once the fourth arrives')
|
|
expect(hubSessions.every(session => session.lockstep.tick >= 290), 'all four peers ran the whole session')
|
|
expect(
|
|
new Set(hubSessions.map(session => session.lockstep.tick)).size === 1,
|
|
'a late joiner runs level with the peers that waited for it, tick for tick',
|
|
)
|
|
expect(hubWorlds.every(world => world.players.length === FOUR), 'each world holds one fighter per peer')
|
|
const hubDigests = new Set(hubWorlds.map(world => coopDigest(world)))
|
|
expect(hubDigests.size === 1, 'all four independently simulated worlds are identical')
|
|
expect(hubSessions.every(session => session.desyncReport === null), 'four peers do not desync')
|
|
expect(
|
|
hubSessions.every(session => session.stats.hashesCompared > 200 && session.stats.hashesAgreed === session.stats.hashesCompared),
|
|
'every hash every peer could check agreed',
|
|
)
|
|
expect(
|
|
hubSessions.every(session => session.stats.malformed === 0),
|
|
'four peers exchange only well-formed messages',
|
|
)
|
|
expect(hubWorlds[0]!.kills > 0, 'four fighters killed something together')
|
|
expect(
|
|
hubWorlds[0]!.players.some((player, index) => index > 0 && player.x !== hubWorlds[0]!.players[0]!.x),
|
|
'the fighters stand where their own peer drove them',
|
|
)
|
|
expect(hubSessions[0]!.peerSeeds.length === 3, 'every peer seed is recorded, not just the first')
|
|
expect(hubSessions[0]!.peerSeeds.every(entry => entry.seed === 0x4bee), 'every peer reports the same world seed')
|
|
|
|
// One of the four goes quiet: the others must stall rather than run on without it.
|
|
const beforeQuiet = hubSessions.map(session => session.lockstep.tick)
|
|
hub.held.add(2)
|
|
for (let tick = 360; tick < 420; tick += 1) pumpRound(tick)
|
|
const stalledTicks = hubSessions.map((session, index) => session.lockstep.tick - beforeQuiet[index]!)
|
|
expect(hubSessions.every(session => session.desyncReport === null), 'a quiet peer in a four-peer game causes no desync')
|
|
expect(
|
|
hubSessions[0]!.lockstep.tick <= hubSessions[2]!.lockstep.tick,
|
|
'the peers that lost a partner ran no further than the one they lost',
|
|
)
|
|
expect(
|
|
hubSessions.slice(0, 3).every(session => session.stats.waiting > 10),
|
|
'the peers that lost a partner counted the ticks they could not run',
|
|
)
|
|
// Releasing the link leaves everyone the backlog they need to finish level.
|
|
hub.held.delete(2)
|
|
for (let tick = 420; tick < 520; tick += 1) pumpRound(tick)
|
|
const target = Math.max(...hubSessions.map(session => session.lockstep.tick))
|
|
for (let round = 0; round < 40; round += 1) {
|
|
for (let index = 0; index < FOUR; index += 1) {
|
|
const session = hubSessions[index]!
|
|
if (session.lockstep.tick >= target) continue
|
|
session.setIntent(scriptedInput(index, session.lockstep.tick))
|
|
session.pump()
|
|
}
|
|
hub.flush()
|
|
}
|
|
expect(
|
|
hubSessions.every(session => session.lockstep.tick === target),
|
|
'after the link recovers, every peer can be brought level from its own backlog',
|
|
)
|
|
expect(new Set(hubWorlds.map(world => coopDigest(world))).size === 1, 'the four worlds are still identical after a stall')
|
|
expect(stalledTicks[0]! < 60, 'the stall really did hold the other peers back')
|
|
|
|
// --- a peer that alters input in flight is detected --------------------------
|
|
|
|
/**
|
|
* A transport that rewrites the movement in the first input message it forwards.
|
|
*
|
|
* This is the shape of a real bug: not a malicious peer, but a corrupted or
|
|
* mismatched build. The field is chosen so the change *must* matter — a movement
|
|
* of half a step moves the player, whereas flipping the attack flag on a tick with
|
|
* nothing in reach changes nothing, and a tamper that changes nothing proves
|
|
* nothing. The first input message is the warm-up frame for tick 0, which the send
|
|
* cursor fills in so that the first ticks have inputs at all; the world must
|
|
* therefore diverge at tick 0.
|
|
*
|
|
* @param inner - the transport to wrap.
|
|
* @returns the tampering transport.
|
|
*/
|
|
function tamperingTransport(inner: MemoryTransport): Transport {
|
|
let tampered = false
|
|
return {
|
|
send: (data) => {
|
|
if (!tampered && data[0] === 2) {
|
|
tampered = true
|
|
const copy = new Uint8Array(data)
|
|
copy[6] = 500 & 0xff // movement x := 0.5, a value the script never sends
|
|
copy[7] = (500 >> 8) & 0xff
|
|
inner.send(copy)
|
|
return
|
|
}
|
|
inner.send(data)
|
|
},
|
|
onMessage: handler => { inner.onMessage(handler) },
|
|
onClose: handler => { inner.onClose(handler) },
|
|
close: () => { inner.close() },
|
|
get open(): boolean { return inner.open },
|
|
}
|
|
}
|
|
|
|
const [honestPipe, cheatPipe] = memoryTransportPair()
|
|
const honest = makePeer(0, honestPipe, 0x2222)
|
|
const cheat = makePeer(1, tamperingTransport(cheatPipe), 0x2222)
|
|
honestPipe.flush()
|
|
for (let tick = 0; tick < 40; tick += 1) {
|
|
honest.session.setIntent(scriptedInput(0, tick))
|
|
cheat.session.setIntent(scriptedInput(1, tick))
|
|
honest.session.pump()
|
|
cheat.session.pump()
|
|
honestPipe.flush()
|
|
}
|
|
const report = honest.session.desyncReport ?? cheat.session.desyncReport
|
|
expect(report !== null, 'a tampered input is detected as a desync')
|
|
expect(report !== null && report.tick === 0, 'the desync is reported at the tick the tampered input applied to')
|
|
expect(report !== null && report.local !== report.remote, 'the report carries both digests so the divergence is diagnosable')
|
|
expect(honest.session.stats.hashesCompared > 0, 'hashes were exchanged before the divergence was found')
|
|
|
|
// --- garbage on the wire is dropped, not fatal -------------------------------
|
|
|
|
const [cleanPipe, noisyPipe] = memoryTransportPair()
|
|
const calm = makePeer(0, cleanPipe, 0x3333)
|
|
const noisy = makePeer(1, noisyPipe, 0x3333)
|
|
// One end receives an unknown type, the other an empty frame: both directions of
|
|
// nonsense, delivered before either peer runs a tick.
|
|
cleanPipe.send(new Uint8Array([200, 1, 2, 3]))
|
|
noisyPipe.send(new Uint8Array(0))
|
|
cleanPipe.flush()
|
|
for (let tick = 0; tick < 20; tick += 1) {
|
|
calm.session.setIntent(scriptedInput(0, tick))
|
|
noisy.session.setIntent(scriptedInput(1, tick))
|
|
calm.session.pump()
|
|
noisy.session.pump()
|
|
cleanPipe.flush()
|
|
}
|
|
expect(noisy.session.stats.malformed === 1, 'an unknown message type is counted and dropped')
|
|
expect(calm.session.stats.malformed === 1, 'an empty frame is counted and dropped at the other end')
|
|
expect(noisy.session.stats.stepped > 10, 'the session keeps running after a malformed message')
|
|
expect(calm.session.desyncReport === null && noisy.session.desyncReport === null, 'garbage does not desync the game')
|
|
expect(digest(calm.game) === digest(noisy.game), 'a malformed message leaves both worlds identical')
|
|
|
|
// --- a silent peer is a timeout, not a desync --------------------------------
|
|
|
|
// Peer 0's outbound link is held, so peer 1 hears nothing at all: that is the
|
|
// "the other player vanished" case, and it must read as a loss, not a divergence.
|
|
const [lostPipe, gonePipe] = memoryTransportPair()
|
|
const heardFrom = makePeer(0, lostPipe, 0x4444)
|
|
const starved = makePeer(1, gonePipe, 0x4444)
|
|
lostPipe.flush()
|
|
lostPipe.hold = true
|
|
for (let tick = 0; tick < 60; tick += 1) {
|
|
heardFrom.session.setIntent(scriptedInput(0, tick))
|
|
starved.session.setIntent(scriptedInput(1, tick))
|
|
heardFrom.session.pump()
|
|
starved.session.pump()
|
|
lostPipe.flush()
|
|
}
|
|
expect(starved.session.stats.timedOut, 'a peer nothing has been heard from is declared lost')
|
|
expect(starved.session.stats.waiting > 20, 'the starved peer counted every tick it could not run')
|
|
expect(starved.session.lockstep.tick === 0, 'the starved peer never fabricated the input it was missing')
|
|
expect(starved.session.desyncReport === null && heardFrom.session.desyncReport === null, 'a lost peer is a timeout, never a desync')
|
|
// The other end does *not* give up: its peer is still retrying its handshake, so
|
|
// something is arriving. "No answer yet" and "no peer" are different states, and
|
|
// the session is expected to tell them apart.
|
|
expect(!heardFrom.session.stats.timedOut, 'a peer still trying to handshake is not declared lost')
|
|
expect(starved.session.stats.helloSent > 1, 'a peer whose handshake goes unanswered keeps retrying it')
|
|
// The other end hears those hellos and answers them, but its answers never
|
|
// arrive, so its own handshake is never acknowledged and it keeps retrying too:
|
|
// "no acknowledgement" is the condition for retrying, not "no answer".
|
|
expect(heardFrom.session.stats.helloSent > 1, 'a peer whose own hello was never acknowledged keeps retrying it')
|
|
expect(!heardFrom.session.stats.acknowledged, 'an unacknowledged handshake is never called complete')
|
|
expect(heardFrom.session.lockstep.tick === 0, 'neither peer runs a tick before the handshake completes')
|
|
|
|
// --- goodbye -----------------------------------------------------------------
|
|
|
|
const [byePipe] = memoryTransportPair()
|
|
const leaving = makePeer(0, byePipe, 0x5555)
|
|
leaving.session.pump()
|
|
byePipe.flush()
|
|
leaving.session.leave()
|
|
expect(!leaving.session.stats.connected, 'leaving closes the pipe')
|
|
expect(leaving.session.stats.sent === 2, 'leaving sends exactly the goodbye after the handshake')
|
|
|
|
// --- the same sessions over a real socket ------------------------------------
|
|
|
|
const relay = await startRelay(0)
|
|
const socketA = new WebSocket(relay.url)
|
|
const socketB = new WebSocket(relay.url)
|
|
await Promise.all([
|
|
new Promise<void>(resolve => { socketA.addEventListener('open', () => { resolve() }) }),
|
|
new Promise<void>(resolve => { socketB.addEventListener('open', () => { resolve() }) }),
|
|
])
|
|
expect(relay.accepted === 2, 'the relay accepted both peers')
|
|
|
|
const netA = makePeer(0, socketTransport(socketA as unknown as Parameters<typeof socketTransport>[0]), 0x7777)
|
|
const netB = makePeer(1, socketTransport(socketB as unknown as Parameters<typeof socketTransport>[0]), 0x7777)
|
|
|
|
const deadline = Date.now() + 4000
|
|
for (let tick = 0; tick < 150 && Date.now() < deadline; tick += 1) {
|
|
netA.session.setIntent(scriptedInput(0, tick))
|
|
netB.session.setIntent(scriptedInput(1, tick))
|
|
netA.session.pump()
|
|
netB.session.pump()
|
|
// Real sockets deliver on the event loop, so the session is pumped more than
|
|
// once per scripted tick: a peer that is ahead stalls until its peer catches up.
|
|
await new Promise(resolve => { setTimeout(resolve, 8) })
|
|
netA.session.pump()
|
|
netB.session.pump()
|
|
await new Promise(resolve => { setTimeout(resolve, 1) })
|
|
}
|
|
|
|
expect(netA.session.stats.handshaked && netB.session.stats.handshaked, 'the handshake crosses a real socket')
|
|
expect(relay.forwarded > 200, 'the relay forwarded traffic in both directions')
|
|
expect(netA.session.stats.stepped > 120 && netB.session.stats.stepped > 120, 'both peers ran the game over the socket')
|
|
expect(Math.abs(netA.session.stats.stepped - netB.session.stats.stepped) <= 1, 'the peers stay within one tick of each other')
|
|
expect(netA.session.desyncReport === null && netB.session.desyncReport === null, 'no desync over a real socket')
|
|
expect(
|
|
netA.session.stats.hashesCompared > 50 && netA.session.stats.hashesCompared === netA.session.stats.hashesAgreed,
|
|
'every hash that crossed the socket agreed',
|
|
)
|
|
expect(
|
|
netB.session.stats.hashesCompared > 50 && netB.session.stats.hashesCompared === netB.session.stats.hashesAgreed,
|
|
'every hash that crossed the socket agreed at the other peer',
|
|
)
|
|
expect(digest(netA.game) === digest(netB.game), 'two worlds fed by a real socket end up identical')
|
|
expect(netA.session.stats.malformed === 0 && netB.session.stats.malformed === 0, 'the socket transport framed every message correctly')
|
|
|
|
// Closing the socket must be observed by the session rather than hanging it.
|
|
netA.session.leave()
|
|
await new Promise(resolve => { setTimeout(resolve, 40) })
|
|
expect(!netB.session.stats.connected, 'a peer leaving over a socket is observed by the other end')
|
|
|
|
socketA.close()
|
|
socketB.close()
|
|
await relay.close()
|
|
expect(relay.clients === 0, 'the relay closes every client')
|
|
|
|
// --- three peers over real sockets -------------------------------------------
|
|
|
|
const relayThree = await startRelay(0)
|
|
const THREE = 3
|
|
const threeWorlds = [newHubWorld(THREE), newHubWorld(THREE), newHubWorld(THREE)]
|
|
const threeSessions: NetplaySession[] = []
|
|
for (let index = 0; index < THREE; index += 1) {
|
|
const socket = new WebSocket(relayThree.url)
|
|
await new Promise<void>(resolve => { socket.addEventListener('open', () => { resolve() }) })
|
|
const session = new NetplaySession(
|
|
{ peer: index, peers: THREE, seed: 0x3eed, inputDelayTicks: 4, hashInterval: 1, timeoutTicks: 400 },
|
|
coopSimulation(threeWorlds[index]!),
|
|
socketTransport(socket as unknown as Parameters<typeof socketTransport>[0]),
|
|
)
|
|
session.start()
|
|
threeSessions.push(session)
|
|
}
|
|
const threeSockets: WebSocket[] = []
|
|
expect(relayThree.accepted === THREE, 'the relay accepts all three peers')
|
|
|
|
const threeDeadline = Date.now() + 6000
|
|
for (let tick = 0; tick < 160 && Date.now() < threeDeadline; tick += 1) {
|
|
for (let index = 0; index < THREE; index += 1) {
|
|
threeSessions[index]!.setIntent(scriptedInput(index, tick))
|
|
threeSessions[index]!.pump()
|
|
}
|
|
await new Promise(resolve => { setTimeout(resolve, 7) })
|
|
for (let index = 0; index < THREE; index += 1) threeSessions[index]!.pump()
|
|
await new Promise(resolve => { setTimeout(resolve, 1) })
|
|
}
|
|
expect(threeSessions.every(session => session.stats.handshaked), 'three peers complete the handshake over real sockets')
|
|
expect(threeSessions.every(session => session.stats.acknowledged), 'three peers are acknowledged by each other')
|
|
expect(threeSessions.every(session => session.desyncReport === null), 'three peers over sockets do not desync')
|
|
expect(threeSessions.every(session => session.stats.malformed === 0), 'the socket transport frames every message for three peers')
|
|
expect(
|
|
threeSessions.every(session => session.stats.hashesCompared > 50 && session.stats.hashesAgreed === session.stats.hashesCompared),
|
|
'three peers agree on every hash they could check',
|
|
)
|
|
// Level the peers that fell behind, then require byte-identical worlds.
|
|
const threeTarget = Math.max(...threeSessions.map(session => session.lockstep.tick))
|
|
for (let round = 0; round < 40; round += 1) {
|
|
for (let index = 0; index < THREE; index += 1) {
|
|
const session = threeSessions[index]!
|
|
if (session.lockstep.tick >= threeTarget) continue
|
|
session.setIntent(scriptedInput(index, session.lockstep.tick))
|
|
session.pump()
|
|
}
|
|
await new Promise(resolve => { setTimeout(resolve, 4) })
|
|
}
|
|
expect(
|
|
threeSessions.every(session => session.lockstep.tick === threeTarget),
|
|
'three peers over sockets can be brought level from their backlog',
|
|
)
|
|
expect(
|
|
new Set(threeWorlds.map(world => coopDigest(world))).size === 1,
|
|
'three worlds fed by real sockets are identical',
|
|
)
|
|
for (const session of threeSessions) session.leave()
|
|
await new Promise(resolve => { setTimeout(resolve, 40) })
|
|
await relayThree.close()
|
|
expect(relayThree.clients === 0, 'the three-peer relay closes every client')
|
|
void threeSockets
|
|
|
|
console.log(`checks ${String(checks)}`)
|
|
console.log(`problems ${String(problems.length)}`)
|
|
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
|
|
console.log(problems.length === 0 ? 'RESULT protocol, transports and two-peer lockstep hold' : 'RESULT FAILED')
|
|
process.exit(problems.length === 0 ? 0 : 1)
|