@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
253 lines (226 loc) • 8.48 kB
JavaScript
import { assert } from "../../../../core/assert.js";
import { seededRandom } from "../../../../core/math/random/seededRandom.js";
import { Transport } from "../Transport.js";
/**
* In-process Transport pair that models real network conditions.
*
* Each `send` samples loss probability against the live `config`; survivors
* are queued for delivery at `now + latency + jitter` and {@link tick} drains
* anything past its deadline. Reorder happens naturally when jitter is high
* relative to send rate.
*
* Sister to {@link LoopbackTransport}. Loopback is for unit tests where you
* want deterministic, manual `deliver_all` control. SimulatedTransport is for
* prototypes and stress tests where you want realistic-feeling behaviour
* driven by wall-clock time.
*
* Pair two instances with {@link bind_pair}; each becomes the other's delivery
* target. The `config` object is mutable at runtime — sliders in dev tools
* can poke at it freely.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class SimulatedTransport extends Transport {
#stats;
#forced_drops_remaining;
#random;
/**
* @param {{
* latency_ms?: number,
* jitter_ms?: number,
* loss_pct?: number,
* clock?: function(): number,
* }} [options]
*
* `clock` returns the current "now" in ms — used both to schedule
* `deliver_at_ms` inside {@link send} and as the default for {@link tick}.
* Defaults to `Date.now`, which is right for tests and any caller that
* uses real wall time. Pair-bound transports should share the same clock
* source (typically a single sim-time variable) so packets sent on one
* side become deliverable at the matching moment on the other.
*/
constructor({
latency_ms = 60,
jitter_ms = 15,
loss_pct = 2,
clock = Date.now,
random_seed = 1337
} = {}) {
super();
assert.isNumber(latency_ms, 'latency_ms');
assert.isNumber(jitter_ms, 'jitter_ms');
assert.isNumber(loss_pct, 'loss_pct');
assert.isFunction(clock, 'clock');
/**
* Peer transport; null until {@link bind_pair} is called.
* @type {SimulatedTransport|null}
*/
this.peer = null;
/**
* Inbound queue, sorted ascending by `deliver_at_ms`.
* Each entry: `{ deliver_at_ms: number, bytes: Uint8Array }`.
* @type {{deliver_at_ms: number, bytes: Uint8Array}[]}
*/
this.in_queue = [];
/**
* Live-tunable simulated link conditions. Mutate these freely; values
* take effect on the next `send`.
*/
this.config = { latency_ms, jitter_ms, loss_pct };
/**
* Stats. Includes `packets_dropped` (a SimulatedTransport-specific count
* not in the base `getStats()` shape).
* @private
*/
this.#stats = {
bytes_in: 0, bytes_out: 0,
packets_in: 0, packets_out: 0,
packets_dropped: 0,
};
/**
* Counter for deterministic forced losses scheduled by {@link force_drop_next}.
* Each `send` decrements this and drops the packet if non-zero, regardless of
* the configured `loss_pct`. Used by demos / repro tests where you want to
* trigger a precise burst of loss.
* @private @type {number}
*/
this.#forced_drops_remaining = 0;
/**
*
* @type {function(): number}
* @private
*/
this.#random = seededRandom(random_seed);
/**
* Clock source. Returns the current time in ms — read once per
* {@link send} call to compute `deliver_at_ms`, and used as the default
* for {@link tick} when no explicit `now_ms` is supplied.
* @type {function(): number}
*/
this.clock = clock;
}
/**
* Bind two SimulatedTransports together so they deliver to each other.
*
* @param {SimulatedTransport} a
* @param {SimulatedTransport} b
*/
static bind_pair(a, b) {
assert.ok(a instanceof SimulatedTransport, 'a must be a SimulatedTransport');
assert.ok(b instanceof SimulatedTransport, 'b must be a SimulatedTransport');
a.peer = b;
b.peer = a;
}
/**
* @param {Uint8Array} bytes
* @param {number} length
*/
send(bytes, length) {
assert.isNonNegativeInteger(length, 'length');
this.#stats.bytes_out += length;
this.#stats.packets_out += 1;
if (this.peer === null) return;
// Forced drops trump configured probability. They model a deterministic
// loss burst — useful for demos and reproducible repros.
if (this.#forced_drops_remaining > 0) {
this.#forced_drops_remaining--;
this.#stats.packets_dropped += 1;
return;
}
if (this.#random() * 100 < this.config.loss_pct) {
this.#stats.packets_dropped += 1;
return;
}
const now_ms = this.clock();
const jitter = (this.#random() * 2 - 1) * this.config.jitter_ms;
const deliver_at_ms = now_ms + this.config.latency_ms + jitter;
const copy = new Uint8Array(length);
copy.set(bytes.subarray(0, length));
// Insertion-sort into peer's queue by delivery time. Reorder happens
// naturally because the new packet may slot in ahead of an earlier
// packet that drew a larger jitter sample.
const q = this.peer.in_queue;
let i = q.length;
while (i > 0 && q[i - 1].deliver_at_ms > deliver_at_ms) i--;
q.splice(i, 0, { deliver_at_ms, bytes: copy });
}
/**
* Deliver every packet whose `deliver_at_ms <= now_ms`. Call once per tick
* (or whenever you want to advance the simulated link).
*
* @param {number} now_ms
* @returns {number} packets delivered
*/
tick(now_ms) {
assert.isNumber(now_ms, 'now_ms');
let delivered = 0;
const q = this.in_queue;
while (q.length > 0 && q[0].deliver_at_ms <= now_ms) {
const e = q.shift();
this.#stats.bytes_in += e.bytes.length;
this.#stats.packets_in += 1;
this.onReceive.send2(e.bytes, e.bytes.length);
delivered++;
}
return delivered;
}
/**
* Number of packets queued for delivery but not yet released.
* @returns {number}
*/
queued_count() {
return this.in_queue.length;
}
/**
* Number of packets dropped to simulated loss since construction.
* @returns {number}
*/
dropped_count() {
return this.#stats.packets_dropped;
}
/**
* Schedule the next `n` outbound packets to be dropped unconditionally
* (in addition to any loss from `config.loss_pct`). Useful for demos and
* reproducible loss-burst tests where you want the recovery path to engage
* on demand rather than after enough random drops.
*
* @param {number} n
*/
force_drop_next(n) {
assert.isNonNegativeInteger(n, 'n');
this.#forced_drops_remaining += n;
}
/**
* Clear the queue and unbind. Subsequent sends become no-ops.
*
* Mirrors {@link LoopbackTransport#disconnect}: the local side fires
* `onDisconnect` so test harnesses can drive reconnect flows
* deterministically, and the formerly-paired peer is notified so its
* own listeners observe the link drop. Symmetric whether the
* disconnect originates here or on the peer.
*/
disconnect() {
this.in_queue.length = 0;
if (this.peer !== null) {
const p = this.peer;
this.peer = null;
if (p.peer === this) {
p.peer = null;
p.onDisconnect.send1('peer_disconnected');
}
}
this.onDisconnect.send1('local_disconnect');
}
/**
* @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,
};
}
}