UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

253 lines (226 loc) 8.48 kB
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, }; } }