@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
195 lines (177 loc) • 6.23 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { Transport } from "./Transport.js";
/**
* In-process Transport pair for tests and tooling.
*
* Two `LoopbackTransport` instances are paired via {@link bind_pair}. Calls to
* `send` on one place a copy of the bytes into the peer's inbound queue, where
* they sit until {@link deliver_all} is called. This gives tests precise control
* over packet timing — no hidden setTimeout, no real network.
*
* Wire-condition simulation is offered as deterministic helpers
* ({@link drop_next}, {@link reorder}) rather than randomized parameters; tests
* are easier to write and reproduce when they pick which packet to drop, not
* "5% of packets". Random conditions can be layered on top by the test if needed.
*
* Each `send` copies the bytes (because the caller may reuse the source buffer
* immediately). Pool-based optimization is reasonable later but not necessary
* for correctness.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class LoopbackTransport extends Transport {
#peer;
#in_queue;
#stats;
constructor() {
super();
// `deliver_all()` empties the queue front-to-back, so packets
// arrive in send order, and the queue never drops them.
this.reliable = true;
this.ordered = true;
/**
* Peer transport; null until {@link bind_pair} is called.
* @type {LoopbackTransport|null}
* @private
*/
this.#peer = null;
/**
* Inbound queue of pending packets (copies of the bytes the peer sent).
* Entries are Uint8Array — already trimmed to packet length.
* @type {Uint8Array[]}
* @private
*/
this.#in_queue = [];
/**
* Stats.
* @private
*/
this.#stats = {
bytes_in: 0,
bytes_out: 0,
packets_in: 0,
packets_out: 0,
};
}
/**
* Bind two LoopbackTransports together so they deliver to each other.
*
* @param {LoopbackTransport} a
* @param {LoopbackTransport} b
*/
static bind_pair(a, b) {
assert.ok(a instanceof LoopbackTransport, 'a must be a LoopbackTransport');
assert.ok(b instanceof LoopbackTransport, 'b must be a LoopbackTransport');
a.#peer = b;
b.#peer = a;
}
/**
* Send a packet. Bytes are copied into the peer's inbound queue immediately;
* delivery to the peer's `onReceive` happens when {@link deliver_all} runs
* on the peer.
*
* @param {Uint8Array} bytes
* @param {number} length
*/
send(bytes, length) {
assert.isNonNegativeInteger(length, 'length');
if (this.#peer === null) {
// Fire-and-forget when unbound; useful for echoing tests that ignore output.
this.#stats.bytes_out += length;
this.#stats.packets_out += 1;
return;
}
const copy = new Uint8Array(length);
copy.set(bytes.subarray(0, length));
this.#peer.#in_queue.push(copy);
this.#stats.bytes_out += length;
this.#stats.packets_out += 1;
}
/**
* Deliver every packet in the inbound queue to {@link onReceive}, in order.
* Updates inbound stats. Returns the number of packets delivered.
*
* @returns {number}
*/
deliver_all() {
let delivered = 0;
while (this.#in_queue.length > 0) {
const packet = this.#in_queue.shift();
this.#stats.bytes_in += packet.length;
this.#stats.packets_in += 1;
this.onReceive.send2(packet, packet.length);
delivered++;
}
return delivered;
}
/**
* Drop the next `n` queued inbound packets (simulates packet loss).
* If fewer than `n` are queued, drops what's available.
*
* @param {number} n
* @returns {number} actual number dropped
*/
drop_next(n) {
assert.isNonNegativeInteger(n, 'n');
const actual = Math.min(n, this.#in_queue.length);
this.#in_queue.splice(0, actual);
return actual;
}
/**
* Swap two queued inbound packets by index (simulates reorder).
*
* @param {number} i
* @param {number} j
*/
reorder(i, j) {
assert.isNonNegativeInteger(i, 'i');
assert.isNonNegativeInteger(j, 'j');
if (i >= this.#in_queue.length || j >= this.#in_queue.length) {
throw new Error(`LoopbackTransport.reorder: index out of range (queue has ${this.#in_queue.length} packets)`);
}
const tmp = this.#in_queue[i];
this.#in_queue[i] = this.#in_queue[j];
this.#in_queue[j] = tmp;
}
/**
* Number of packets currently waiting to be delivered.
* @returns {number}
*/
queued_count() {
return this.#in_queue.length;
}
/**
* Clear the queue and unbind. Subsequent sends become no-ops. The peer is
* also unbound so its `send()` calls don't keep accumulating into our
* (now-undrained) inbound queue. The peer's own inbound queue is left
* alone — the caller can still `deliver_all()` on the peer if needed.
*/
disconnect() {
this.#in_queue.length = 0;
if (this.#peer !== null) {
const p = this.#peer;
this.#peer = null;
if (p.#peer === this) {
p.#peer = null;
// Notify the peer's session-level disconnect listeners
// so test harnesses can drive reconnect flows
// deterministically through `disconnect()`.
p.onDisconnect.send1('peer_disconnected');
}
}
this.onDisconnect.send1('local_disconnect');
}
/**
* Read-only stats snapshot.
* @returns {{bytes_in: number, bytes_out: number, packets_in: number, packets_out: number}}
*/
getStats() {
return {
bytes_in: this.#stats.bytes_in,
bytes_out: this.#stats.bytes_out,
packets_in: this.#stats.packets_in,
packets_out: this.#stats.packets_out,
};
}
}