UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,216 lines (1,090 loc) 77.4 kB
import { assert } from "../../core/assert.js"; import { BinaryBuffer } from "../../core/binary/BinaryBuffer.js"; import Signal from "../../core/events/signal/Signal.js"; import { EntityObserver } from "../ecs/EntityObserver.js"; import { UUID } from "../ecs/guid/UUID.js"; import { BinarySerializationRegistry, } from "../ecs/storage/binary/BinarySerializationRegistry.js"; import { NetworkIdentity } from "./ecs/components/NetworkIdentity.js"; import { NetworkSystem } from "./ecs/NetworkSystem.js"; import { NetworkIdentitySerializationAdapter, } from "./ecs/serialization/NetworkIdentitySerializationAdapter.js"; import { NetworkPeer, ResumeRejectReason, } from "./orchestrator/NetworkPeer.js"; import { ServerAuthoritativeClient } from "./orchestrator/ServerAuthoritativeClient.js"; import { ServerAuthoritativeServer } from "./orchestrator/ServerAuthoritativeServer.js"; import { OwnerAwareScope } from "./replication/ScopeFilter.js"; import { BinaryInterpolationAdapter, InterpolationKind, } from "./sim/BinaryInterpolationAdapter.js"; import { InterpolationLog } from "./sim/InterpolationLog.js"; import { SimAction } from "./sim/SimAction.js"; import { snapshotter_emit } from "./sim/Snapshotter.js"; import { AdaptiveRenderDelay } from "./time/AdaptiveRenderDelay.js"; import { TimeDilation } from "./time/TimeDilation.js"; /** * Roles a session can occupy. The orchestrator it wires up underneath is * uniquely determined by the role. * * - `'client'` → {@link ServerAuthoritativeClient}: predicts locally * when an input sampler is registered; reconciles to * server-authoritative state arriving via AUTH_STATE. * With no input sampler, a client is a pure spectator * — it receives the action stream, smooths via * interpolation, and never predicts or rewinds. This * is the default role and covers both "playing" and * "spectating" use cases. * - `'host'` → {@link ServerAuthoritativeServer}: simulation * authority for one or more clients; runs the optional * server-side input buffer (`simulation_delay_ticks`). * * @readonly @enum {string} */ export const NetworkSessionRole = Object.freeze({ Client: 'client', Host: 'host', }); const DEFAULT_TICK_RATE_HZ = 60; const DEFAULT_SIMULATION_DELAY_TICKS = 4; const DEFAULT_INITIAL_RENDER_DELAY_FRAMES = 6; const PULL_PER_MS = 0.0005; // wall-clock low-pass on render frame state machine const MAX_CLIENT_DILATED_TICKS_PER_STEP = 3; const MAX_FIXED_STEPS_PER_TICK = 8; const DEFAULT_SERVER_RESUME_GRACE_MS = 30_000; const DEFAULT_RECONNECT_POLICY = Object.freeze({ enabled: true, max_attempts: 8, base_delay_ms: 200, max_delay_ms: 5_000, exponential_factor: 2.0, total_timeout_ms: 60_000, accept_state_resync: true, }); /** * Client-side reconnect state machine values. * @readonly @enum {string} */ const ReconnectState = Object.freeze({ Connected: 'connected', Reconnecting: 'reconnecting', Resyncing: 'resyncing', Lost: 'lost', }); /** * Scratch buffer size used for AUTH_STATE assembly and net_mutate_component * event payloads. Sized for the common cases (Transform ≈ 36 B); components * with richer payloads need this raised or a different scratch lifecycle. */ const SCRATCH_BUFFER_BYTES = 1024; /** * High-level networking facade. Wraps the orchestrator + state machinery * into one config + lifecycle surface. The lower-level orchestrators * remain reachable through `session.server` / `session.client` / * `session.peer` for callers that need to override defaults post-hoc. * * Replication is component-driven: attaching a {@link NetworkIdentity} * to an entity registers it with the session via an `EntityObserver`. * Mutation is event-driven: callers fire * `dataset.sendEvent(entity, "net_mutate_component", payload)`. * * Per-tick driving is a single `session.tick(dt_seconds)`. The session * runs fixed-timestep stepping, client time dilation, server-side input * buffer, AUTH_STATE dispatch, time-dilation feedback, and interpolated * rendering for remote-owned entities. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class NetworkSession { // ---- Pre-start registration (set via replicate / defineAction / defineInputSampler) ---- /** * User-registered replicated component classes, in `replicate()` call * order. Finalized at `start()` into {@link #replicated} with * `NetworkIdentity` prepended. * @private @type {Array<{klass: Function, interp: BinaryInterpolationAdapter|null}>} */ #user_replicated = []; /** * Finalized at `start()`. Insertion order = wire-format order; * `NetworkIdentity` is the first entry (its slot allocation must * fire before subsequent components attach during initial-sync apply). * Each value: `{ interp, interp_type_id }`. * @private @type {Map<Function, {interp: BinaryInterpolationAdapter|null, interp_type_id: number}>} */ #replicated = new Map(); /** * Type_id → ComponentClass for the synthetic ReplaceComponentAction. * @private @type {Function[]} */ #interp_type_id_to_class = []; /** @private @type {Array<Function>} */ #user_action_classes = []; /** @private @type {((frame: number) => Array<SimAction>|null) | null} */ #input_sampler = null; // ---- Set in start() ---- /** @private @type {ServerAuthoritativeServer|null} */ #server = null; /** @private @type {ServerAuthoritativeClient|null} */ #client = null; /** @private @type {NetworkPeer|null} */ #peer = null; /** @private @type {EntityObserver|null} */ #identity_observer = null; /** @private @type {InterpolationLog|null} */ #interp_log = null; /** @private @type {Function|null} */ #ReplaceComponentAction = null; // ---- Per-entity tracking ---- /** * `entity_id → "net_mutate_component" listener` so detach can clean up. * Also doubles as the iterable of all networked entities. * @private @type {Map<number, Function>} */ #event_listeners = new Map(); /** * Entities the local peer does NOT own — driven by the render * interpolation pass. * @private @type {Set<number>} */ #remote_entities = new Set(); // ---- Render-frame interpolation state ---- /** @private @type {number} */ #latest_received_frame = -1; /** @private @type {number} */ #smooth_render_frame = -1; /** @private @type {number} */ #smooth_last_wall_ms = -1; /** * Set after `#render_interpolated_entities` writes interp values into * live components; cleared by `normalize_if_dirty`. Drives the * canonical-form invariant: live is canonical when this is false, * polluted (smooth) when true. * @private @type {boolean} */ render_dirty = false; // ---- Per-tick driver state ---- /** @private @type {number} */ #local_frame = 0; /** @private @type {number} */ #local_frame_accum = 0; /** @private @type {number} */ #sim_accum_ms = 0; /** @private @type {Set<number>} */ #connected_peers = new Set(); /** * Host-only: peer_id → remaining-resends. Drained in * `#on_host_tick_complete` once `current_sim_frame >= 0`. Sent twice * for loss tolerance; production code should re-send on receiver * RECOVERY request. * @private @type {Map<number, number>} */ #pending_initial_sync = new Map(); /** * Sampled-action ledger for predict-reconcile replay. The sampler * reads live input, so re-calling it during reconcile would give * wrong results; replay reads from here instead. * @private @type {Map<number, Array<{type_id: number, bytes: Uint8Array}>>} */ #sampled_actions_per_frame = new Map(); /** @private @type {boolean} */ #started = false; // ---- Host-side: per-peer session token + grace state ---- /** * Tokens issued at first INITIAL_SYNC, kept while the peer is * connected or in the grace window. Used to authenticate * RESUME_HELLO. * @private @type {Map<number, UUID>} */ #peer_session_tokens = new Map(); /** * Peers currently in the post-disconnect grace window. Value carries * the deadline (wall-clock ms) and the cached `last_acked_frame` so * a successful resume restores the action-stream baseline. * @private @type {Map<number, { deadline_ms: number, last_acked: number }>} */ #peer_grace_state = new Map(); /** * Per-peer transport onDisconnect listener bookkeeping so we can * unsubscribe when the peer is fully dropped. * @private @type {Map<number, { transport: object, listener: Function }>} */ #peer_transport_listeners = new Map(); // ---- Client-side: session token + reconnect state machine ---- /** * Server-issued session token (raw UUID v1 bytes). Set on first * INITIAL_SYNC arrival; refreshed on every Tier-3 resync. Sent in * `RESUME_HELLO` to claim continuity with a prior session record. * Observable to game code for diagnostics; not auth-grade. * @type {UUID|null} */ session_token = null; /** @private @type {string} */ #reconnect_state = ReconnectState.Connected; /** @private @type {number} */ #reconnect_attempt = 0; /** @private @type {number} */ #reconnect_elapsed_ms = 0; /** @private @type {number} */ #reconnect_next_delay_ms = 0; /** @private @type {number} */ #reconnect_timer_remaining_ms = 0; /** @private @type {object|null} */ #current_transport = null; /** @private @type {Function|null} */ #current_transport_listener = null; /** @private @type {number} */ #remote_peer_id = -1; // ---- Signals (initialized inline because they take no input) ---- /** * Host: a connected peer's transport reported a disconnect (the peer * has entered the grace window). Args: `(peer_id, reason)`. * @type {Signal} */ onPeerLost = new Signal(); /** * Host: a peer has been permanently freed — either via * `drop_peer()`, a grace-window timeout, or a peer-initiated * DISCONNECT. Args: `(peer_id, reason)`. * @type {Signal} */ onPeerPermanentlyDropped = new Signal(); /** * Client: the transport has reported a disconnect, and the * reconnect ladder has started. Args: `(reason)`. * @type {Signal} */ onConnectionLost = new Signal(); /** * Client: about to rebuild the transport and dial the host. Args: * `(attempt_number)`. * @type {Signal} */ onReconnectAttempt = new Signal(); /** * Client: reconnect succeeded. Args: `({ state_resynced: boolean })` — * `state_resynced` is `true` if we fell through to Tier-3 (server * had purged our state and re-sent INITIAL_SYNC), `false` for the * happy Tier-2 path where the action stream just resumed. * @type {Signal} */ onReconnected = new Signal(); /** * Client: the reconnect ladder is exhausted, or the host explicitly * kicked us with a DISCONNECT packet, or `reconnect.enabled` is * false and the link dropped. Args: `(reason)`. * @type {Signal} */ onConnectionPermanentlyLost = new Signal(); // ---- Set in constructor (depend on input or imperative setup) ---- /** @private */ #transport = null; /** @private */ #scope_filter = null; /** @private @type {TimeDilation} */ #time_dilation; /** @private @type {AdaptiveRenderDelay} */ #adaptive_render_delay; /** @private @type {BinaryBuffer} */ #scratch_send_buf; /** @private @type {BinaryBuffer} */ #scratch_interp_buf; /** @private @type {() => void} */ #bound_normalize_if_dirty; /** @private @type {number} */ #server_resume_grace_ms; /** @private @type {object} */ #reconnect_policy; /** @private @type {(() => object) | null} */ #transport_factory; /** * @param {{ * entity_manager: EntityManager, * transport?: object, * transport_factory?: (() => object), * role?: 'client'|'host', * local_peer_id?: number, * simulation_delay_ticks?: number, * tick_rate_hz?: number, * binary_registry?: BinarySerializationRegistry, * scope_filter?: object, * time_dilation?: TimeDilation, * adaptive_render_delay?: AdaptiveRenderDelay, * server_resume_grace_ms?: number, * reconnect?: { * enabled?: boolean, * max_attempts?: number, * base_delay_ms?: number, * max_delay_ms?: number, * exponential_factor?: number, * total_timeout_ms?: number, * accept_state_resync?: boolean, * }, * }} options * * - `transport` is not connected at construction — call {@link connect} * with an explicit remote peer id after {@link start}. * - `transport_factory` (client role): zero-arg function that returns * a fresh `Transport` on each reconnect attempt. Required for * automatic reconnect; without it, transport-level disconnects fall * straight through to `onConnectionPermanentlyLost`. * - `role` defaults to `'client'`. See {@link NetworkSessionRole}. * - `local_peer_id` defaults to 0 for host, 1 for client. Must be * unique across peers. * - `simulation_delay_ticks` is honored only under `role: 'host'`. * Default 4. See {@link ServerAuthoritativeServer}. * - `tick_rate_hz` defaults to 60. * - `scope_filter` defaults to {@link OwnerAwareScope} on the host. * - `server_resume_grace_ms` (host role): how long peer state is * retained after a transport drop before being freed. Default 30s. * - `reconnect` (client role): policy for the reconnect ladder. See * {@link DEFAULT_RECONNECT_POLICY}. Set `enabled: false` to opt * out entirely. */ constructor({ entity_manager, transport = null, transport_factory = null, role = NetworkSessionRole.Client, local_peer_id = -1, simulation_delay_ticks = DEFAULT_SIMULATION_DELAY_TICKS, tick_rate_hz = DEFAULT_TICK_RATE_HZ, binary_registry = null, scope_filter = null, time_dilation = null, adaptive_render_delay = null, server_resume_grace_ms = DEFAULT_SERVER_RESUME_GRACE_MS, reconnect = null, } = {}) { assert.ok(entity_manager !== undefined && entity_manager !== null, 'NetworkSession: entity_manager is required'); if (role !== NetworkSessionRole.Client && role !== NetworkSessionRole.Host) { throw new Error(`NetworkSession: invalid role '${role}'`); } assert.isNumber(tick_rate_hz, 'tick_rate_hz'); assert.ok(tick_rate_hz > 0, 'tick_rate_hz must be positive'); assert.isNonNegativeInteger(simulation_delay_ticks, 'simulation_delay_ticks'); /** * @readonly * @type {EntityManager} */ this.entity_manager = entity_manager; /** * @readonly * @type {EntityComponentDataset} */ this.world = entity_manager.dataset; /** * @readonly * @type {NetworkSessionRole|string} */ this.role = role; /** * @readonly * @type {number} */ this.tick_period_ms = 1000 / tick_rate_hz; /** * @readonly * @type {number} */ this.simulation_delay_ticks = role === NetworkSessionRole.Host ? simulation_delay_ticks : 0; /** * @readonly * @type {number} */ this.local_peer_id = local_peer_id >= 0 ? local_peer_id : (role === NetworkSessionRole.Host ? 0 : 1); /** * @readonly * @type {BinarySerializationRegistry} */ this.binary_registry = binary_registry || new BinarySerializationRegistry(); this.#transport = transport; this.#scope_filter = scope_filter; this.#time_dilation = time_dilation || new TimeDilation({ target_buffer_depth: this.simulation_delay_ticks > 0 ? this.simulation_delay_ticks : 2, max_dilation: 0.05, gain: 0.05, }); this.#adaptive_render_delay = adaptive_render_delay || new AdaptiveRenderDelay({ tick_period_ms: this.tick_period_ms, min_delay_frames: 2, max_delay_frames: 30, initial_delay_frames: DEFAULT_INITIAL_RENDER_DELAY_FRAMES, history_size: 60, safety_multiplier: 2.0, decay_per_sample_ms: 1.0, }); this.#scratch_send_buf = new BinaryBuffer(); this.#scratch_send_buf.setCapacity(SCRATCH_BUFFER_BYTES); this.#scratch_interp_buf = new BinaryBuffer(); this.#scratch_interp_buf.setCapacity(SCRATCH_BUFFER_BYTES); /** * @type {() => void} */ this.#bound_normalize_if_dirty = () => this.normalize_if_dirty(); this.#server_resume_grace_ms = server_resume_grace_ms; this.#reconnect_policy = Object.freeze({ ...DEFAULT_RECONNECT_POLICY, ...(reconnect || {}) }); this.#transport_factory = transport_factory; } // ---------------------------------------------------------------- // Configuration (must run before start()) // ---------------------------------------------------------------- /** * Register a component class for network replication. * * The component must have a {@link BinaryClassSerializationAdapter} * registered for its `typeName` in the session's `binary_registry` * before {@link start} is called — that adapter handles the wire * format and rewind capture. * * `interpolator` is optional. When omitted, the component's state * snaps on each received update (no sub-tick smoothing). When * provided, it must extend {@link BinaryInterpolationAdapter} with * `kind = InterpolationKind.Linear` — Discrete and Cubic are * reserved for future support and currently throw. * * The order of `replicate()` calls must match across all peers in * the session: AUTH_STATE payload layout iterates components in * insertion order. * * @param {Function} ComponentClass * @param {BinaryInterpolationAdapter} [interpolator] */ replicate(ComponentClass, interpolator = null) { this.#assert_not_started('replicate'); assert.isFunction(ComponentClass, 'ComponentClass'); if (ComponentClass === NetworkIdentity) { throw new Error("NetworkSession.replicate: NetworkIdentity is auto-replicated; do not call replicate() for it"); } for (const entry of this.#user_replicated) { if (entry.klass === ComponentClass) { throw new Error(`NetworkSession.replicate: ${ComponentClass.typeName ?? ComponentClass.name} already registered`); } } if (interpolator !== null) { if (!(interpolator instanceof BinaryInterpolationAdapter)) { throw new Error("NetworkSession.replicate: interpolator must extend BinaryInterpolationAdapter"); } if (interpolator.kind !== InterpolationKind.Linear) { throw new Error( `NetworkSession.replicate: only Linear interpolation is currently supported; ` + `got kind=${interpolator.kind} for ${ComponentClass.typeName ?? ComponentClass.name}. ` + `Omit the interpolator argument for snap (Discrete) behavior; Cubic is reserved.`, ); } } this.#user_replicated.push({ klass: ComponentClass, interp: interpolator }); } /** * Register a user-defined {@link SimAction} subclass with the * session. The action's `type_id` is assigned by the underlying * registry when {@link start} runs. Calling code constructs * instances directly (`new MyAction(...)`) and dispatches via * {@link send} or returns them from the input sampler. * * @param {Function} SimActionClass class extending SimAction */ defineAction(SimActionClass) { this.#assert_not_started('defineAction'); assert.isFunction(SimActionClass, 'SimActionClass'); this.#user_action_classes.push(SimActionClass); } /** * Install a per-tick input sampler (client role only). The sampler * is called once per local sim tick at predict time and must return * an array of {@link SimAction} instances representing the local * peer's inputs for that frame. The session executes them locally * (predict), records their serialized bytes for replay, and forwards * them over the action stream. * * The sampler is NOT called during reconciliation replay — the * recorded bytes are deserialized and re-executed instead, so the * action set faithfully reproduces what the user originally * sampled at that frame. * * Return `[]` (or `null`) for ticks with no inputs. * * @param {(frame: number) => Array<SimAction>|null} sampler_fn */ defineInputSampler(sampler_fn) { this.#assert_not_started('defineInputSampler'); assert.isFunction(sampler_fn, 'sampler_fn'); if (this.role !== NetworkSessionRole.Client) { throw new Error(`NetworkSession.defineInputSampler: only valid for role='${NetworkSessionRole.Client}', got '${this.role}'`); } this.#input_sampler = sampler_fn; } // ---------------------------------------------------------------- // Lifecycle // ---------------------------------------------------------------- /** * Wire the session to the engine. After this, the EntityObserver * is active, the right orchestrator is constructed, and a * {@link NetworkSystem} is attached. Subsequent {@link replicate} * / {@link defineAction} / {@link defineInputSampler} calls throw. * * Pre-condition: every component class passed to {@link replicate} * must already have a `BinaryClassSerializationAdapter` registered * in the session's binary_registry. */ async start() { if (this.#started) throw new Error("NetworkSession.start: already started"); // Auto-register the NetworkIdentity adapter so initial-sync can // ship it across the wire. Allow caller-supplied registrations // to win (game might have a custom one). if (this.binary_registry.getAdapter(NetworkIdentity.typeName) === undefined) { this.binary_registry.registerAdapter(new NetworkIdentitySerializationAdapter(), NetworkIdentity.typeName); } // Finalize the replicated-components ordering: NetworkIdentity // FIRST (so it gets type_id=0 and arrives as the first per-entity // record in snapshots — receiver attaches NetworkIdentity first, // which triggers slot-table allocation before any other component // payload needs to look up `entity_for(network_id)`). let next_id = 0; const network_identity_scratch = new BinaryBuffer(); network_identity_scratch.setCapacity(SCRATCH_BUFFER_BYTES); this.#replicated.set(NetworkIdentity, { interp: null, interp_type_id: next_id++, }); this.#interp_type_id_to_class[0] = NetworkIdentity; for (const { klass, interp } of this.#user_replicated) { const interp_type_id = next_id++; this.#replicated.set(klass, { interp, interp_type_id }); this.#interp_type_id_to_class[interp_type_id] = klass; } // Verify each replicated component has a serialization adapter. for (const ComponentClass of this.#replicated.keys()) { const name = ComponentClass.typeName; if (typeof name !== 'string') { throw new Error(`NetworkSession.start: ${ComponentClass.name} has no static .typeName; cannot look up its adapter`); } if (this.binary_registry.getAdapter(name) === undefined) { throw new Error(`NetworkSession.start: no BinaryClassSerializationAdapter registered for ${name}; register one in the binary_registry before calling start()`); } } // Build the synthetic ReplaceComponentAction now so it's in the // action_classes list when the orchestrator is constructed. this.#ReplaceComponentAction = this.#make_replace_component_action_class(); const replicated_components = Array.from(this.#replicated.keys()); const action_classes = [...this.#user_action_classes, this.#ReplaceComponentAction]; const orchestrator_opts = { world: this.world, binary_registry: this.binary_registry, replicated_components, action_classes, }; if (this.role === NetworkSessionRole.Host) { this.#server = new ServerAuthoritativeServer({ ...orchestrator_opts, simulation_delay_ticks: this.simulation_delay_ticks, }); this.#peer = this.#server.peer; this.#peer.replicator.scope_filter = this.#scope_filter || new OwnerAwareScope({ world: this.world, slot_table: this.#server.slot_table, identity_class: NetworkIdentity, }); this.#server.onTickComplete.add((sim_frame) => this.#on_host_tick_complete(sim_frame)); } else { // Client role: covers both "playing" (input sampler registered) // and "spectator" (no sampler — sampler-driven onPredict // returns no actions, so the predict-reconcile machinery // exists but stays idle). this.#client = new ServerAuthoritativeClient({ ...orchestrator_opts, time_dilation: this.#time_dilation, }); this.#peer = this.#client.peer; if (this.#scope_filter) { this.#peer.replicator.scope_filter = this.#scope_filter; } this.#wire_client_predict_reconcile(); } // Install the canonical-form normalize hook on the executor. // Anything that calls executor.execute — predict apply, action- // stream apply, reconcile replay — first normalizes the world. // Idempotent: cheap when `render_dirty` is false (one bool // check), expensive (one adapter.deserialize per remote-owned // component) only on the first execute after a render pass. this.#peer.executor.before_execute = this.#bound_normalize_if_dirty; // NetworkSystem handles slot_table allocation on NetworkIdentity attach. // addSystem returns a promise that resolves once the system's own // startup completes (it auto-starts when added to an already-Running // EM). Await it so the session's identity observer below doesn't // race with the NetworkSystem's observer over slot_table allocation. await this.entity_manager.addSystem(new NetworkSystem(this.#peer)); // Sub-tick interpolation log: records per-frame replicated component // state, sampled at render time for remotely-owned entities. this.#interp_log = new InterpolationLog({ buffer_capacity_bytes: 65536, records_capacity: 4096, }); // Per-frame applied: snapshot all replicated components for each // remote-owned entity into the interpolation log. this.#peer.replicator.onFrameApplied.add((peer_id, frame_number) => { this.#on_frame_applied(peer_id, frame_number); }); // Initial-sync arrival (client only): store the session token, // populate the world from the host's snapshot, resume normal // action-stream processing, and finalize any in-flight Tier-3 // resync state. if (this.role === NetworkSessionRole.Client) { this.#peer.onInitialSync.add((_peer_id, session_token, _frame_number, buf, payload_end) => { const token = new UUID(); token.data = session_token; this.session_token = token; this.#apply_initial_sync(buf, payload_end); if (this.#reconnect_state === ReconnectState.Resyncing) { this.#enter_connected(); this.onReconnected.send1({ state_resynced: true }); } }); // Host's response to our RESUME_HELLO during reconnect. this.#peer.onResumeAccept.add(() => { if (this.#reconnect_state === ReconnectState.Reconnecting || this.#reconnect_state === ReconnectState.Resyncing) { this.#enter_connected(); this.onReconnected.send1({ state_resynced: false }); } }); this.#peer.onResumeReject.add((_peer_id, reason_code) => { if (this.#reconnect_policy.accept_state_resync) { // Tier-3: drop unconfirmed predictions and wait for // the host to deliver a fresh INITIAL_SYNC. this.#sampled_actions_per_frame.clear(); this.session_token = null; this.#reconnect_state = ReconnectState.Resyncing; // Restart the elapsed-ms budget so the Resyncing // watchdog (see #tick_reconnect) has a full // total_timeout_ms before falling to Lost. this.#reconnect_elapsed_ms = 0; } else { this.#permanently_lose_connection(`resume_rejected:${reason_code}`); } }); // Host kicked us — final, no reconnect. this.#peer.onDisconnectPacket.add((_peer_id, reason_label) => { this.#permanently_lose_connection(reason_label || 'disconnected_by_host'); }); } else { // Host: RESUME_HELLO validation + peer-initiated disconnects. this.#peer.onResumeHello.add((peer_id, local_peer_id, last_acked_frame, session_token) => { this.#on_resume_hello(peer_id, local_peer_id, last_acked_frame, session_token); }); this.#peer.onDisconnectPacket.add((peer_id, reason_label) => { // Client-initiated disconnect — skip the grace window // and free state immediately. this.drop_peer(peer_id, reason_label || 'client_disconnect'); }); } // EntityObserver for NetworkIdentity: patches owner/network_id on // attach, wires the per-entity mutation listener. this.#identity_observer = new EntityObserver( [NetworkIdentity], (identity, entity_id) => this.#on_identity_attached(identity, entity_id), (identity, entity_id) => this.#on_identity_detached(identity, entity_id), this, ); this.#identity_observer.connect(this.world); this.#started = true; } /** * Connect to a remote peer. Host can connect many peers (one per * client); client typically connects one (the server). Called both * for first-time connects and reconnect attempts — the session * detects which based on whether a `session_token` is cached * (client) or whether the peer is in the grace window (host). * * @param {number} remote_peer_id * @param {object} transport */ connect(remote_peer_id, transport) { if (!this.#started) throw new Error("NetworkSession.connect: call start() first"); if (this.role === NetworkSessionRole.Host) { const in_grace = this.#peer_grace_state.has(remote_peer_id); this.#server.connect_peer(remote_peer_id, transport); if (in_grace) { // Resume candidate. Wait for RESUME_HELLO; restore // baseline + add to connected_peers on validation. // Don't queue INITIAL_SYNC — it'll only fire if we // RESUME_REJECT. } else { // Fresh peer. Queue initial-sync for the next host tick. this.#pending_initial_sync.set(remote_peer_id, 2); this.#connected_peers.add(remote_peer_id); } this.#wire_peer_transport_disconnect(remote_peer_id, transport); } else { // Client this.#client.connect_to_server(remote_peer_id, transport); this.#remote_peer_id = remote_peer_id; this.#current_transport = transport; this.#wire_client_transport_disconnect(transport); this.#connected_peers.add(remote_peer_id); // If we have a session token cached, treat this as a // reconnect attempt and send RESUME_HELLO immediately. A // fresh session has no token yet (it arrives in the first // INITIAL_SYNC), so first-time connects don't trigger this. if (this.session_token !== null) { const last_acked = Math.max(0, this.#latest_received_frame); this.#peer.send_resume_hello( remote_peer_id, this.local_peer_id, last_acked, this.session_token.data, ); } } } /** * Host-only: forcibly free a peer's state, regardless of whether * they are actively connected, in the grace window, or already * unknown. Fires `onPeerPermanentlyDropped(peer_id, reason)`. * Sends a DISCONNECT packet if the peer is still actively * connected so the remote can short-circuit its reconnect attempts. * * Policy for *when* to drop (high server load, anti-cheat, admin * kick, etc.) is the caller's; the session provides the mechanism. * * @param {number} peer_id * @param {string} [reason] */ drop_peer(peer_id, reason = '') { if (this.role !== NetworkSessionRole.Host) { throw new Error("NetworkSession.drop_peer: host-only"); } const was_connected = this.#connected_peers.has(peer_id); const was_in_grace = this.#peer_grace_state.has(peer_id); if (!was_connected && !was_in_grace) return; if (was_connected) { // Best-effort notification — peer may have already dropped. try { this.#peer.send_disconnect(peer_id, reason); } catch (_) { /* swallow */ } this.#server.disconnect_peer(peer_id); } this.#unwire_peer_transport_disconnect(peer_id); this.#connected_peers.delete(peer_id); this.#peer_grace_state.delete(peer_id); this.#peer_session_tokens.delete(peer_id); this.#pending_initial_sync.delete(peer_id); this.onPeerPermanentlyDropped.send2(peer_id, reason); } /** * Client-only: voluntarily disconnect from the server. Sends a * DISCONNECT packet so the host can free state immediately and * skip the grace window. Disables reconnect for this session. * * @param {string} [reason] */ disconnect(reason = '') { if (this.role !== NetworkSessionRole.Client) { throw new Error("NetworkSession.disconnect: client-only — host uses drop_peer instead"); } if (this.#remote_peer_id < 0) return; const peer_id = this.#remote_peer_id; try { this.#peer.send_disconnect(peer_id, reason); } catch (_) { /* swallow */ } // Tear down the channel binding so a subsequent connect() with a // fresh transport doesn't trip the underlying NetworkPeer's // "peer already exists" guard. Mirrors the cleanup that // #on_client_transport_disconnected does on an involuntary drop. try { this.#peer.disconnect_peer(peer_id); } catch (_) { /* swallow */ } if (this.#current_transport_listener !== null && this.#current_transport !== null) { try { this.#current_transport.onDisconnect.remove(this.#current_transport_listener); } catch (_) { /* swallow */ } } this.#current_transport = null; this.#current_transport_listener = null; this.#remote_peer_id = -1; this.#permanently_lose_connection(reason || 'client_disconnect'); } /** * Tear down. Idempotent — safe to call from cleanup paths. */ stop() { if (!this.#started) return; if (this.#identity_observer !== null) { this.#identity_observer.disconnect(); this.#identity_observer = null; } for (const [entity_id, listener] of this.#event_listeners) { this.world.removeEntityEventListener(entity_id, "net_mutate_component", listener); } this.#event_listeners.clear(); this.#remote_entities.clear(); this.#sampled_actions_per_frame.clear(); this.#started = false; } // ---------------------------------------------------------------- // Per-tick driver // ---------------------------------------------------------------- /** * Fixed-timestep accumulator-driven step. Catch-up bounded by * `MAX_FIXED_STEPS_PER_TICK`. Under `role: 'client'` the local frame * advances at `1 / dilation_factor` per fixed step. * * @param {number} dt_seconds elapsed wall seconds since the previous call */ tick(dt_seconds) { if (!this.#started) return; // Restore canonical form before sim work. The executor's // `before_execute` hook also covers per-action paths (e.g. // packets delivered between session.tick calls). this.normalize_if_dirty(); const dt_ms = dt_seconds * 1000; // Host: expire grace-window peers whose deadline has passed. if (this.role === NetworkSessionRole.Host) { this.#expire_grace_peers(performance.now()); } else { // Client: drive the reconnect timer. this.#tick_reconnect(dt_ms); } this.#sim_accum_ms += dt_ms; let steps = 0; while (this.#sim_accum_ms >= this.tick_period_ms && steps < MAX_FIXED_STEPS_PER_TICK) { this.#sim_accum_ms -= this.tick_period_ms; this.#simulate_one_step(); steps++; } if (steps === MAX_FIXED_STEPS_PER_TICK) this.#sim_accum_ms = 0; // Per-render-frame: smooth interpolated rendering of remote // entities. Marks the live ECD as polluted so the next sim-tick // or reconcile starts with a normalize. this.#render_interpolated_entities(); } /** * Undo render-time interpolation on remote-owned components by * restoring each from its latest InterpolationLog entry. Idempotent * via the `render_dirty` flag — cheap when false. * * Called from `tick()` (top), `executor.before_execute` (per-action * applies, including transport-delivered action stream), and * `client.onBeforeReconcile` (before the rewind walk). * * @private */ normalize_if_dirty() { if (!this.render_dirty) return; this.render_dirty = false; if (this.#latest_received_frame < 0) return; if (this.#remote_entities.size === 0) return; const scratch = this.#scratch_interp_buf; const tick = this.#latest_received_frame; for (const entity_id of this.#remote_entities) { const identity = this.world.getComponent(entity_id, NetworkIdentity); if (identity === undefined || identity.network_id < 0) continue; for (const [ComponentClass, desc] of this.#replicated) { if (desc.interp === null) continue; const live = this.world.getComponent(entity_id, ComponentClass); if (live === undefined) continue; scratch.position = 0; // tick_a === tick_b, t = 0 — degenerate "snap to single // snapshot" lerp. Output equals the stored snapshot. const ok = this.#interp_log.interpolate( scratch, identity.network_id, desc.interp_type_id, tick, tick, 0, desc.interp, ); if (!ok) continue; scratch.position = 0; const adapter = this.binary_registry.getAdapter(ComponentClass.typeName); adapter.deserialize(scratch, live); } } } /** * Dispatch one user-defined action. Equivalent to * `peer.executor.execute(action, this.local_peer_id)` but exposed * at the session level so callers don't need a reference to the * executor or to know the local peer id. * * @param {SimAction} action_instance */ send(action_instance) { if (!this.#started) throw new Error("NetworkSession.send: call start() first"); if (!action_instance || action_instance.isSimAction !== true) { throw new Error("NetworkSession.send: argument must be a SimAction instance"); } this.#peer.executor.execute(action_instance, this.local_peer_id); } // ---------------------------------------------------------------- // Accessors // ---------------------------------------------------------------- /** * @returns {ServerAuthoritativeServer|null} */ get server() { return this.#server; } /** * @returns {ServerAuthoritativeClient|null} */ get client() { return this.#client; } /** * @returns {NetworkPeer|null} */ get peer() { return this.#peer; } /** * @returns {InterpolationLog|null} */ get interpolation_log() { return this.#interp_log; } /** * @returns {AdaptiveRenderDelay} */ get adaptive_render_delay() { return this.#adaptive_render_delay; } /** * @returns {TimeDilation} */ get time_dilation() { return this.#time_dilation; } /** * Most recent locally-completed sim frame. */ get current_frame() { if (this.role === NetworkSessionRole.Host) return this.#server.current_sim_frame; return this.#local_frame - 1; } // ---------------------------------------------------------------- // Internal: identity observer (component-driven spawn/despawn) // ---------------------------------------------------------------- /** * Patch `NetworkIdentity` on attach: `owner_peer_id` defaults to * this session's `local_peer_id` if unset; `network_id` is assigned * by {@link NetworkSystem.link}, which fires on the same attach. * * Callers must not mutate either field afterwards — the slot table * and replicator cache them. * */ #on_identity_attached(identity, entity_id) { if (identity.owner_peer_id < 0) { identity.owner_peer_id = this.local_peer_id; } if (identity.owner_peer_id !== this.local_peer_id) { this.#remote_entities.add(entity_id); } // Per-entity event listener for net_mutate_component. const listener = (payload) => this.#on_net_mutate_component(entity_id, payload); this.world.addEntityEventListener(entity_id, "net_mutate_component", listener); this.#event_listeners.set(entity_id, listener); } #on_identity_detached(_identity, entity_id) { const listener = this.#event_listeners.get(entity_id); if (listener !== undefined) { this.world.removeEntityEventListener(entity_id, "net_mutate_component", listener); this.#event_listeners.delete(entity_id); } this.#remote_entities.delete(entity_id); } /** * Handler for `"net_mutate_component"` events. Translates the payload * into a `ReplaceComponentAction` and dispatches via the executor. * * Payload: * - `{ component_type, new_state }` — recommended; engine captures * current bytes as prior, then applies `new_state` via the * replication adapter. * - `{ component_type }` — caller has already mutated live; prior * bytes equal post bytes (rewind for this mutation is a no-op). * */ #on_net_mutate_component(entity_id, payload) { const { component_type, new_state } = payload || {}; if (!component_type) { throw new Error("net_mutate_component: payload must include `component_type`"); } const desc = this.#replicated.get(component_type); if (desc === undefined) { throw new Error(`net_mutate_component: ${component_type.typeName ?? component_type.name} is not replicated; call session.replicate() for it before mutating`); } const network_id = this.#peer.slot_table.network_for(entity_id); if (network_id < 0) return; // not networked yet const live = this.world.getComponent(entity_id, component_type); if (live === undefined) return; const adapter = this.binary_registry.getAdapter(component_type.typeName); // Build the action with the new-state bytes pre-baked. The // executor will then capture the current `live` as prior bytes // before action.apply deserializes new bytes into `live`. const action = this.#peer.action_registry.acquire(this.#ReplaceComponentAction); action.network_id = network_id; action.component_type_id = desc.interp_type_id; action.payload_buf.position = 0; if (new_state !== undefined && new_state !== null) { adapter.serialize(action.payload_buf, new_state); } else { // No new_state: live IS the new state. adapter.serialize(action.payload_buf, live); } action.payload_length = action.payload_buf.position; try { this.#peer.executor.execute(action, this.local_peer_id); } finally { this.#peer.action_registry.release(action); } } /** * Build the synthetic ReplaceComponentAction class. * The factory captures `interp_type_id_to_class` and `binary_registry` by * reference so the inner class methods can resolve type ids and * adapters without reaching into the outer class's private fields * (which `#`-private syntax would forbid from a different class). */ #make_replace_component_action_class() { const interp_type_id_to_class = this.#interp_type_id_to_class; const binary_registry = this.binary_registry; return class ReplaceComponentAction extends SimAction { constructor(network_id = 0, component_type_id = 0) { super(); this.network_id = network_id; this.component_type_id = component_type_id; this.payload_buf = new BinaryBuffer(); this.payload_buf.setCapacity(SCRATCH_BUFFER_BYTES); this.payload_length = 0; } apply(world, executor) { const local = executor.slot_table.entity_for(this.network_id); if (local < 0) return; const ComponentClass = interp_type_id_to_class[this.component_type_id]; if (ComponentClass === undefined) return; const live = world.getComponent(local, ComponentClass); if (live === undefined) return; const adapter = binary_registry.getAdapter(ComponentClass.typeName); if (adapter === undefined) return; this.payload_buf.position = 0; adapter.deserialize(this.payload_buf, live); } affected_components(callback, executor) { const local = executor.slot_table.entity_for(this.network_id); if (local < 0) return; const ComponentClass = interp_type_id_to_class[this.component_type_id]; if (ComponentClass === undefined) return; callback(local, ComponentClass); } serialize(buffer) { buffer.writeUintVar(this.network_id); buffer.writeUint8(this.component_type_id); buffer.writeUint32(this.payload_length); if (this.payload_length > 0) { buffer.writeBytes(this.payload_buf.raw_bytes, 0, this.payload_length); } } deserialize(buffer) { this.network_id = buffer.readUintVar(); this.component_type_id = buffer.readUint8(); this.payload_length = buffer.readUint32(); if (this.payload_buf.raw_bytes.length < this.payload_length) { this.payload_buf.setCapacity(this.payload_length); } if (this.payload_length > 0) { buffer.readBytes(this.payload_buf.raw_bytes, 0, this.payload_length); } this.payload_buf.position = 0; } reset() { this.network_id = 0; this.component_type_id = 0; this.payload_length = 0; this.payload_buf.position = 0; } }; } // -----------