UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

335 lines (300 loc) 13.8 kB
import { assert } from "../../../core/assert.js"; import Signal from "../../../core/events/signal/Signal.js"; /** * Forwards action records between peers. * * **Send path** ({@link pack_for_peer}): walk the local action log over a * frame range, filter records by scope, and write a packet that contains * only the action portion of each record (no prior state — receivers don't * need it; their executors capture their own prior state). * * **Receive path** ({@link unpack_from_peer}): parse the packet, deserialize * each action via the registry, and run it through the local executor. The * executor logs it into the receiver's action log just like locally-originated * actions, capturing the receiver's prior state for its own rewind purposes. * * The Replicator is transport-agnostic: it produces and consumes * `BinaryBuffer`s. The orchestrator hands the produced buffer to a transport * and feeds the inbound buffer in the other direction. * * Wire format: * ``` * loop while bytes remain: * varint: frame_number * varint: action_count * loop action_count times: * uint8: action_type_id * uint32: action_payload_len * bytes: action_payload * ``` * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class Replicator { #record_bounds; /** * @param {{ * action_log: ActionLog, * action_registry: SimActionRegistry, * executor: SimActionExecutor, * slot_table: ReplicationSlotTable, * scope_filter: { is_entity_in_scope(peer_id: number, network_id: number): boolean }, * }} options */ constructor({ action_log, action_registry, executor, slot_table, scope_filter }) { /** * @type {ActionLog} */ this.action_log = action_log; /** * @type {SimActionRegistry} */ this.action_registry = action_registry; /** * @type {SimActionExecutor} */ this.executor = executor; /** * @type {ReplicationSlotTable} */ this.slot_table = slot_table; /** * Receives `network_id` (peer-shared) — NOT local `entity_id`. Replicator * translates via `slot_table.network_for(entity_id)` before calling. * @type {{ is_entity_in_scope(peer_id: number, network_id: number): boolean }} */ this.scope_filter = scope_filter; /** * Pre-allocated scratch for record positions in the current pack pass. * Each slot holds (record_start, record_end) interleaved. * Grown on demand. * @type {Int32Array} * @private */ this.#record_bounds = new Int32Array(64 * 2); /** * Fired after each per-frame action group is fully applied via * {@link unpack_from_peer}. Args: `(peer_id, frame_number)`. * * Subscribe to drive interpolation buffers, replay logs, diagnostics, * or anything else that needs to know "frame N from peer P has just * been applied to the local world." * * Fires in frame-ascending order within a single packet (the order * the wire format encodes them in). * * @type {Signal} */ this.onFrameApplied = new Signal(); /** * Optional deferral hook. When non-null, {@link unpack_from_peer} * does NOT touch the action log or executor — it parses the packet * and calls this function once per action with: * * `(peer_id, frame_number, action_type_id, in_buffer, payload_offset, payload_len)` * * The buffer is shared scratch; consumers must copy the payload bytes * if they need them to outlive the call. The orchestrator is then * responsible for executing the buffered actions in whatever order * the netcode model requires (e.g. rewind to oldest frame, stable- * sort by sender, replay forward). * * Leave null for "execute on arrival" semantics (the default and * what the loopback/single-orchestrator tests rely on). * * @type {((peer_id: number, frame_number: number, action_type_id: number, in_buffer: BinaryBuffer, payload_offset: number, payload_len: number) => void) | null} */ this.on_pending_action = null; } /** * Pack actions from frames in `[start_frame, end_frame]` (inclusive) into * `out_buffer` for a given peer, applying the scope filter. * * Writes nothing if no in-scope actions are found. * * @param {number} peer_id * @param {number} start_frame inclusive; if no frame in range is in the log, that frame is skipped * @param {number} end_frame inclusive * @param {BinaryBuffer} out_buffer */ pack_for_peer(peer_id, start_frame, end_frame, out_buffer) { assert.isNonNegativeInteger(peer_id, 'peer_id'); assert.isNonNegativeInteger(start_frame, 'start_frame'); assert.isNonNegativeInteger(end_frame, 'end_frame'); for (let frame = start_frame; frame <= end_frame; frame++) { if (!this.action_log.has_frame(frame)) continue; const buffer = this.action_log.buffer_for(frame); const end = this.action_log.write_end_for(frame); // First pass: walk records, capture (start, end) for those in scope. let in_scope_count = 0; while (buffer.position < end) { const record_start = buffer.position; const in_scope = this.#scan_record_in_scope(buffer, peer_id); const record_end = buffer.position; if (in_scope) { if ((in_scope_count + 1) * 2 > this.#record_bounds.length) { this.#grow_record_bounds(); } this.#record_bounds[in_scope_count * 2] = record_start; this.#record_bounds[in_scope_count * 2 + 1] = record_end; in_scope_count++; } } if (in_scope_count === 0) continue; // Second pass: write the frame header + each in-scope action's payload only. out_buffer.writeUintVar(frame); out_buffer.writeUintVar(in_scope_count); for (let i = 0; i < in_scope_count; i++) { const rs = this.#record_bounds[i * 2]; buffer.position = rs; this.#copy_action_to(buffer, out_buffer); } } } /** * Parse and apply actions from `in_buffer` into the local world via the executor. * Each frame in the packet opens a new entry in the local action log under the * sender's frame number, ensuring the rewind/replay machinery sees consistent * frame indexing across peers. * * @param {number} peer_id * @param {BinaryBuffer} in_buffer * @param {number} in_buffer_end byte position to stop reading at */ unpack_from_peer(peer_id, in_buffer, in_buffer_end) { assert.isNonNegativeInteger(peer_id, 'peer_id'); assert.isNonNegativeInteger(in_buffer_end, 'in_buffer_end'); // Deferral path: parse only, hand each action off to the orchestrator's // pending-input log. The action log and executor are not touched here — // the orchestrator decides when (and in what order) to apply actions // (e.g. rewind to oldest pending frame and replay forward with stable- // sort tie-breaking by sender, the way rollback netcode demands). if (this.on_pending_action !== null) { while (in_buffer.position < in_buffer_end) { const frame_number = in_buffer.readUintVar(); const action_count = in_buffer.readUintVar(); for (let i = 0; i < action_count; i++) { const action_type_id = in_buffer.readUint8(); const action_payload_len = in_buffer.readUint32(); this.on_pending_action( peer_id, frame_number, action_type_id, in_buffer, in_buffer.position, action_payload_len, ); in_buffer.position += action_payload_len; } } return; } const registry = this.action_registry; while (in_buffer.position < in_buffer_end) { const frame_number = in_buffer.readUintVar(); const action_count = in_buffer.readUintVar(); this.action_log.begin_frame(frame_number); try { for (let i = 0; i < action_count; i++) { const action_type_id = in_buffer.readUint8(); const action_payload_len = in_buffer.readUint32(); const klass = registry.klass_for(action_type_id); if (klass === undefined) { // Unknown action — skip the payload to stay aligned and continue. in_buffer.position += action_payload_len; continue; } const action = registry.acquire(klass); try { action.deserialize(in_buffer); // Sender of the action is the peer we received it from // (the wire format strips sender_id for security). this.executor.execute(action, peer_id); } finally { registry.release(action); } } } finally { this.action_log.end_frame(); } // Fire AFTER end_frame so handlers see consistent state and can // safely query the action log for the just-applied frame. this.onFrameApplied.send2(peer_id, frame_number); } } /** * Walk a single record from the buffer's current position, advancing past it. * Returns true if any of the record's affected entities is in scope for `peer_id`, * or if the record has no affected entities (event-style action — always sent). * * @param {BinaryBuffer} buffer * @param {number} peer_id * @returns {boolean} * @private */ #scan_record_in_scope(buffer, peer_id) { const prior_count = buffer.readUintVar(); let any_in_scope = false; for (let i = 0; i < prior_count; i++) { const entity_id = buffer.readUintVar(); // Translate local entity_id to network_id for the scope filter, which // operates on peer-shared identifiers. If `network_for` returns -1, // the entity is not currently networked on this sender — either it // was never replicated (local-only) or its slot was freed. In both // cases the receiver cannot reconstruct the reference (no slot maps // to it), so we skip rather than send a packet the receiver can't // safely apply. const network_id = this.slot_table.network_for(entity_id); if (network_id >= 0 && this.scope_filter.is_entity_in_scope(peer_id, network_id)) { any_in_scope = true; } buffer.readUint8(); // component_type_id const payload_len = buffer.readUint32(); buffer.position += payload_len; // skip prior payload } // Skip the action payload too. buffer.readUint8(); // action_type_id buffer.readUint8(); // sender_id (local-only metadata, not sent) const action_payload_len = buffer.readUint32(); buffer.position += action_payload_len; // Event-style actions (no affected components) are always in scope. return prior_count === 0 ? true : any_in_scope; } /** * Buffer's current position is the start of an action record. Skip the * prior_state section, then copy the action portion (type_id + len + payload) * to `out_buffer`. Buffer position advances to the end of the record. * * @param {BinaryBuffer} buffer source action-log frame * @param {BinaryBuffer} out_buffer destination packet * @private */ #copy_action_to(buffer, out_buffer) { // Skip prior_state. const prior_count = buffer.readUintVar(); for (let i = 0; i < prior_count; i++) { buffer.readUintVar(); // entity_id buffer.readUint8(); // component_type_id const len = buffer.readUint32(); buffer.position += len; // skip prior payload } // Copy action portion: type_id + len + payload. The local-only // sender_id byte is STRIPPED here — over-the-wire format stays // (type_id, len, payload) and the receiver derives sender from // the packet's peer_id. Including sender_id on the wire would // let a hostile peer impersonate other peers' actions. const action_type_id = buffer.readUint8(); buffer.readUint8(); // sender_id — discarded, not sent on the wire const action_payload_len = buffer.readUint32(); out_buffer.writeUint8(action_type_id); out_buffer.writeUint32(action_payload_len); out_buffer.writeBytes(buffer.raw_bytes, buffer.position, action_payload_len); buffer.position += action_payload_len; } /** * @private */ #grow_record_bounds() { const old = this.#record_bounds; const next = new Int32Array(old.length * 2); next.set(old); this.#record_bounds = next; } }