UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

292 lines (266 loc) 11.2 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; import Signal from "../../../core/events/signal/Signal.js"; /** * Reliable, at-least-once delivery layer over a {@link Channel}. * * Used for messages that must arrive: chat, lobby/room state changes, * level transitions, kick / disconnect notifications. Distinct from the * action-stream traffic the rest of the netcode uses, which is UDP-style * best-effort with per-frame back-fill compensating for losses. * * Wire layout (after the {@link Channel}'s 8-byte header and the * `packet_type` byte that {@link NetworkPeer}'s dispatcher consumes): * * uintVar logical_seq sender-assigned, monotonically increasing * bytes command_payload * * Reliability mechanism: each outgoing command goes out as one * Channel.send call. The returned channel-level seq is tracked in * `__unacked`. When the Channel later fires: * - `onPacketAcked(seq)`: drop the entry — the command was delivered * (the receiver de-duplicated by logical_seq, so a "duplicate" from * a previous retransmit is harmless). * - `onPacketLost(seq)`: the Channel's 33-packet ack window aged out * this seq without seeing it acked. Re-send the same logical * command on a fresh channel seq. * * Receiver side: a sliding-window de-dupe of recent `logical_seq` values. * Anything seen within the last `max_received_history` is silently * dropped; first-time arrivals fire `onCommand` with the payload bytes. * * Ordering: NOT guaranteed. Commands may arrive in any order. If you * need in-order delivery (level transitions before subsequent chat), * key payloads with a counter and reorder in the application handler. * * Lifecycle constraints: * - `max_unacked` caps outstanding commands. Send throws when full * (signals real backpressure — receiver gone or path is broken). * - `max_received_history` caps the de-dupe set. A retransmit older * than this window will be re-delivered as a duplicate to the * application; not a problem in practice because the sender's * `max_unacked` keeps the inflight set bounded too. * * Caveats: * - Loss detection relies on the Channel's seq window, which advances * when subsequent packets are sent. Under sustained no-traffic * conditions a single lost reliable command may stay unacked * indefinitely — by convention the action-stream's per-tick traffic * keeps the window moving even when the application is otherwise * idle. Real production code may want a timer-based retransmit * fallback layered on top of this. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class ReliableCommandPipeline { #next_send_seq; #unacked; #received_seqs; #received_order; #send_scratch; #bound_acked; #bound_lost; /** * @param {{ * channel: import("./Channel.js").Channel, * packet_type: number, * max_unacked?: number, * max_received_history?: number, * max_retries?: number, * }} options */ constructor({ channel, packet_type, max_unacked = 64, max_received_history = 64, max_retries = 16, }) { assert.ok(channel && typeof channel.send === 'function', 'channel must be a Channel'); assert.isNonNegativeInteger(packet_type, 'packet_type'); assert.ok(packet_type <= 0xFF, 'packet_type must fit in a uint8'); assert.isPositiveInteger(max_unacked, 'max_unacked'); assert.isPositiveInteger(max_received_history, 'max_received_history'); assert.isPositiveInteger(max_retries, 'max_retries'); /** @type {import("./Channel.js").Channel} */ this.channel = channel; /** @type {number} @readonly */ this.packet_type = packet_type; /** @type {number} @readonly */ this.max_unacked = max_unacked; /** @type {number} @readonly */ this.max_received_history = max_received_history; /** * Maximum retransmissions per logical command before it is dropped * and {@link onCommandAbandoned} fires. Bounded so a permanently- * black-holed peer can't pin retransmit traffic on the channel * forever. The default (16) tolerates roughly half a second of * sustained loss at typical channel cadences before giving up. * @type {number} @readonly */ this.max_retries = max_retries; /** * Monotonic counter for outgoing logical seqs. Receiver dedups on * this value; sender retransmits use the SAME logical_seq with a * new channel_seq. * @type {number} * @private */ this.#next_send_seq = 0; /** * Outstanding commands keyed by their CURRENT channel_seq. On * retransmit, the entry is moved to the new channel_seq. * `retries` counts how many times this logical command has been * resent; the entry is dropped when it exceeds `max_retries`. * @type {Map<number, {logical_seq: number, payload: Uint8Array, retries: number}>} * @private */ this.#unacked = new Map(); /** * Logical seqs received recently. Sliding window for dedup. * @type {Set<number>} * @private */ this.#received_seqs = new Set(); /** * FIFO order of received logical_seqs, for pruning when * `__received_seqs` exceeds `max_received_history`. * @type {number[]} * @private */ this.#received_order = []; /** * Scratch buffer for assembling outgoing packets. * @type {BinaryBuffer} * @private */ this.#send_scratch = new BinaryBuffer(); this.#send_scratch.setCapacity(1024); /** * Fired when a brand-new logical command arrives (not a duplicate). * Args: `(buf, payload_offset, payload_length)`. The buffer is * shared scratch; consumers must copy bytes if they need them to * outlive the handler. * @type {Signal} */ this.onCommand = new Signal(); /** * Fired when a sent command has been retransmitted `max_retries` * times without an ack and is being given up on. Args: * `(logical_seq)`. Subscribers typically surface this as a "command * failed to deliver" event to the application, or treat it as a * hard signal to tear down the session. * @type {Signal} */ this.onCommandAbandoned = new Signal(); // Channel reliability notifications. this.#bound_acked = (seq) => this.#on_packet_acked(seq); this.#bound_lost = (seq) => this.#on_packet_lost(seq); channel.onPacketAcked.add(this.#bound_acked); channel.onPacketLost.add(this.#bound_lost); } /** * Send a logical command. Returns the assigned `logical_seq` (useful * for application-level diagnostics; the receiver dedups internally * regardless). * * @param {Uint8Array} payload * @param {number} length * @returns {number} */ send(payload, length) { assert.isNonNegativeInteger(length, 'length'); if (this.#unacked.size >= this.max_unacked) { throw new Error( `ReliableCommandPipeline.send: ${this.#unacked.size} commands unacked ` + `(max ${this.max_unacked}); receiver likely disconnected or path saturated` ); } const logical_seq = this.#next_send_seq; this.#next_send_seq++; // Defensive copy: the caller's buffer may be reused after this call. const payload_copy = new Uint8Array(length); payload_copy.set(payload.subarray(0, length)); const channel_seq = this.#transmit(logical_seq, payload_copy); this.#unacked.set(channel_seq, { logical_seq, payload: payload_copy, retries: 0 }); return logical_seq; } /** * Called by the orchestrator (typically {@link NetworkPeer}'s * dispatcher) when a packet of this pipeline's type arrives. `buf` * is positioned just past the packet-type byte. * * @param {BinaryBuffer} buf * @param {number} total_length full transport-level payload length, * including the consumed packet-type byte */ handle_inbound(buf, total_length) { const logical_seq = buf.readUintVar(); if (this.#received_seqs.has(logical_seq)) { // Duplicate from a retransmit; ignore. return; } this.#received_seqs.add(logical_seq); this.#received_order.push(logical_seq); // Prune. Oldest-first. while (this.#received_order.length > this.max_received_history) { const oldest = this.#received_order.shift(); this.#received_seqs.delete(oldest); } const payload_offset = buf.position; const payload_length = total_length - payload_offset; this.onCommand.send3(buf, payload_offset, payload_length); } /** * Number of commands sent that haven't yet been acked. Useful for * backpressure / progress UI. * @returns {number} */ unacked_count() { return this.#unacked.size; } /** * Drop subscriptions and clear state. Required before discarding a * pipeline whose channel will outlive it. */ dispose() { this.channel.onPacketAcked.remove(this.#bound_acked); this.channel.onPacketLost.remove(this.#bound_lost); this.#unacked.clear(); this.#received_seqs.clear(); this.#received_order.length = 0; } /** @private */ #transmit(logical_seq, payload) { const buf = this.#send_scratch; // Worst-case capacity: type byte + 10 bytes for varint + payload. const needed = 1 + 10 + payload.length; if (buf.raw_bytes.length < needed) buf.setCapacity(needed); buf.position = 0; buf.writeUint8(this.packet_type); buf.writeUintVar(logical_seq); buf.writeBytes(payload, 0, payload.length); return this.channel.send(buf.raw_bytes, buf.position); } /** @private */ #on_packet_acked(channel_seq) { this.#unacked.delete(channel_seq); } /** @private */ #on_packet_lost(channel_seq) { const entry = this.#unacked.get(channel_seq); if (entry === undefined) return; this.#unacked.delete(channel_seq); if (entry.retries >= this.max_retries) { // Permanently black-holed peer or fundamentally broken path — // surface the failure to the application and stop retrying. this.onCommandAbandoned.send1(entry.logical_seq); return; } // Retransmit on a fresh channel_seq. Same logical_seq, same payload. entry.retries++; const new_channel_seq = this.#transmit(entry.logical_seq, entry.payload); this.#unacked.set(new_channel_seq, entry); } }