UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

273 lines (239 loc) 9.74 kB
import { assert } from "../../../core/assert.js"; import Signal from "../../../core/events/signal/Signal.js"; import { ack_bitfield_build, ack_bitfield_for_each, } from "../core/sequence/ack_bitfield.js"; import { SEQ16_HALF_RANGE, seq16_advance, seq16_distance, seq16_greater_than } from "../core/sequence/seq16.js"; /** * Reliability-notification layer over a {@link Transport}. * * Adds an 8-byte header to every packet: * ``` * uint16: outgoing_seq * uint16: ack_latest (most recent seq received from peer, or 0 if none) * uint32: ack_bitfield (the previous 32 seqs: bit i set => latest-1-i was received) * ``` * * The header gives positive acknowledgement of up to 33 packets per inbound * packet — robust enough that a single dropped ack-bearing packet typically * doesn't matter (a later one carries the same info). * * Channel does NOT retransmit. It surfaces: * - `onPacketAcked(seq)` when a peer's ack indicates an outgoing packet arrived * - `onPacketLost(seq)` when an outgoing packet falls outside the 33-seq ack * window without ever being acked (peer has acked 32 newer packets, so this * one is presumed lost) * - `onPayload(bytes, length)` for the payload portion of received packets, * with the header stripped * * Reliability-by-retransmission lives in a higher-level pipeline (separate file, * yet to build). Channel just provides the notification primitive. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ const HEADER_SIZE = 8; export class Channel { #next_outgoing_seq; #unacked_outgoing; #latest_received_seq; #received_seqs; #send_buffer; #send_view; #lost_scratch; /** * @param {{ * transport: { send(bytes: Uint8Array, length: number): void, onReceive: Signal }, * }} options */ constructor({ transport }) { assert.ok(transport && typeof transport.send === 'function', 'transport must implement send()'); assert.ok(transport.onReceive, 'transport must expose onReceive signal'); /** * @type {{ send(bytes: Uint8Array, length: number): void, onReceive: Signal }} */ this.transport = transport; this.transport.onReceive.add(this.#handle_inbound, this); /** @type {number} @private */ this.#next_outgoing_seq = 0; /** * Outgoing seqs waiting on ack from the peer. * @type {Set<number>} * @private */ this.#unacked_outgoing = new Set(); /** * Most recent seq we've received from the peer; -1 if none yet. * @type {number} * @private */ this.#latest_received_seq = -1; /** * Seqs we have received. Used to build the ack bitfield piggyback. * Pruned when entries fall outside the 64-seq window. * @type {Set<number>} * @private */ this.#received_seqs = new Set(); /** * Pre-allocated send buffer. Resized only if a payload exceeds capacity. * @type {Uint8Array} * @private */ this.#send_buffer = new Uint8Array(1500); /** @type {DataView} @private */ this.#send_view = new DataView(this.#send_buffer.buffer); /** * Scratch list for collecting "to be marked lost" outgoing seqs during * an inbound handler (avoids modifying the Set while iterating). * @type {number[]} * @private */ this.#lost_scratch = []; /** * Fired when an outgoing seq has been positively acknowledged by the peer. * @type {Signal} */ this.onPacketAcked = new Signal(); /** * Fired when an outgoing seq has aged out of the ack window without being acked. * @type {Signal} */ this.onPacketLost = new Signal(); /** * Fired when an inbound packet's payload is ready (header already stripped). * Handler args: (payload: Uint8Array starting at offset 0, length: number). * The Uint8Array is owned by the Channel and may be reused after the handler returns. * @type {Signal} */ this.onPayload = new Signal(); } /** * Wrap and send a payload. Returns the seq number assigned to it. * * @param {Uint8Array} payload * @param {number} length payload length in bytes * @returns {number} seq */ send(payload, length) { assert.isNonNegativeInteger(length, 'length'); const total_size = HEADER_SIZE + length; if (total_size > this.#send_buffer.length) { this.#send_buffer = new Uint8Array(Math.max(total_size, this.#send_buffer.length * 2)); this.#send_view = new DataView(this.#send_buffer.buffer); } const seq = this.#next_outgoing_seq; this.#next_outgoing_seq = seq16_advance(seq); const ack_latest = this.#latest_received_seq < 0 ? 0 : this.#latest_received_seq; const ack_bitfield = this.#latest_received_seq < 0 ? 0 : ack_bitfield_build(this.#latest_received_seq, s => this.#received_seqs.has(s)); this.#send_view.setUint16(0, seq, true); this.#send_view.setUint16(2, ack_latest, true); this.#send_view.setUint32(4, ack_bitfield, true); if (length > 0) { this.#send_buffer.set(payload.subarray(0, length), HEADER_SIZE); } this.transport.send(this.#send_buffer, total_size); this.#unacked_outgoing.add(seq); return seq; } /** * Number of outgoing packets sent but not yet acked or marked lost. * @returns {number} */ unacked_count() { return this.#unacked_outgoing.size; } /** * Most recent seq received from the peer, or -1 if none yet. * @returns {number} */ latest_received_seq() { return this.#latest_received_seq; } /** * Test-only: seed the next outgoing seq number. Used to fast-forward * the seq counter so the seq16 wraparound can be exercised in * unit tests without sending 65k packets. Do not call from production. * @param {number} seq */ seed_outgoing_seq_for_testing(seq) { this.#next_outgoing_seq = seq; } /** * @param {Uint8Array} bytes * @param {number} length * @private */ #handle_inbound(bytes, length) { if (length < HEADER_SIZE) { // Malformed: too short to contain a header. Drop silently. return; } const view = new DataView(bytes.buffer, bytes.byteOffset, length); const seq = view.getUint16(0, true); const ack_latest = view.getUint16(2, true); const ack_bitfield = view.getUint32(4, true); // Record receipt of this seq. if (this.#latest_received_seq < 0 || seq16_greater_than(seq, this.#latest_received_seq)) { this.#latest_received_seq = seq; } this.#received_seqs.add(seq); this.#prune_received_seqs(); // Mark each acknowledged outgoing seq. ack_bitfield_for_each(ack_latest, ack_bitfield, acked_seq => { if (this.#unacked_outgoing.delete(acked_seq)) { this.onPacketAcked.send1(acked_seq); } }); // Anything older than (ack_latest - 32) that is still unacked is presumed lost. // Sanity: if `d` ever approaches half-range we're flying so many // unacked packets that distance is about to wrap to negative and the // loss-detection would silently break. Non-goal to support; assert. this.#lost_scratch.length = 0; for (const out_seq of this.#unacked_outgoing) { const d = seq16_distance(out_seq, ack_latest); assert.lessThan(d, SEQ16_HALF_RANGE / 2, `Channel: outgoing seq ${out_seq} is ${d} packets behind ack_latest ${ack_latest} — approaching seq16 half-range wraparound`); if (d > 32) { this.#lost_scratch.push(out_seq); } } for (let i = 0; i < this.#lost_scratch.length; i++) { const lost = this.#lost_scratch[i]; this.#unacked_outgoing.delete(lost); this.onPacketLost.send1(lost); } // Strip header and surface the payload. const payload_length = length - HEADER_SIZE; const payload = bytes.subarray(HEADER_SIZE, HEADER_SIZE + payload_length); this.onPayload.send2(payload, payload_length); } /** * Detach this Channel from its transport. The transport.onReceive subscription * is removed so the Channel stops processing inbound bytes; the Channel's own * Signals are left in place (callers usually hold no references to them after * dispose, so they GC with the Channel). Required before discarding a Channel * whose transport will be reused — otherwise the old Channel keeps consuming * inbound packets in parallel with whatever replaces it. */ dispose() { this.transport.onReceive.remove(this.#handle_inbound, this); } /** * Drop received-seq entries that are too old to matter for the next ack bitfield. * Keeps a window slightly larger than the 33-seq bitfield to absorb out-of-order arrival. * @private */ #prune_received_seqs() { const latest = this.#latest_received_seq; if (latest < 0) return; // Window: 64 seqs back. Anything farther can be dropped. for (const s of this.#received_seqs) { if (seq16_distance(s, latest) > 64) { this.#received_seqs.delete(s); } } } }