UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

239 lines (209 loc) 9.8 kB
import { assert } from "../../../core/assert.js"; /** * Sentinel sender id for actions that originated on the local peer (not * received over the wire). Used as the default `sender_id` for * {@link SimActionExecutor#execute} so existing single-peer code keeps * working. Valid peer ids must therefore be in `[0, 254]`. * * @type {number} */ export const SENDER_LOCAL = 0xFF; /** * The single legitimate gateway for replicated state mutations. * * On `execute(action, sender_id)`: * 1. Iterate `action.affected_components((entity_id, component_class) => ...)`. * For each, look up the live component on `world`, look up its replication * adapter via the binary registry, and write * `(entity_id, component_type_id, payload_len, payload)` into the current * frame's `ActionLog` buffer. * 2. Call `action.apply(world)`. * 3. Append `(action_type_id, sender_id, action_payload_len, action_payload)` * to the same frame's buffer. `sender_id` is the peer that originated * the action — used by rollback orchestrators for stable-sort tie- * breaking when reconstructing per-tick action order. Defaults to * {@link SENDER_LOCAL} for actions generated by this peer. * * Code outside the executor that wants to mutate replicated state is doing it * wrong: such mutations will not be replicated, will not be reversible, and * will desync clients on rollback. Use a `SimAction`. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class SimActionExecutor { #scratch_entities; #scratch_classes; #collect_cb; /** * @param {{ * world: EntityComponentDataset, * action_log: ActionLog, * action_registry: SimActionRegistry, * component_registry: ReplicatedComponentRegistry, * slot_table: ReplicationSlotTable, * changed_entities?: ChangedEntitySet, * }} options */ constructor({ world, action_log, action_registry, component_registry, slot_table, changed_entities = null }) { /** * @type {EntityComponentDataset} */ this.world = world; /** * @type {ActionLog} */ this.action_log = action_log; /** * @type {SimActionRegistry} */ this.action_registry = action_registry; /** * @type {ReplicatedComponentRegistry} */ this.component_registry = component_registry; /** * Available to actions (via the executor reference passed to apply / affected_components) * for translating network_id ↔ local entity_id. * @type {ReplicationSlotTable} */ this.slot_table = slot_table; /** * Optional per-tick "which entities mutated this tick?" sink. When set, * each `execute(action)` translates the action's affected entities to * `network_id`s and adds them to this set. The orchestrator typically * compacts the set into a {@link MutationLedger} at end-of-tick and * clears it for the next. * * Null when the executor is configured for receive-only flows that * don't need to record mutation history. * * @type {ChangedEntitySet|null} */ this.changed_entities = changed_entities; // Pre-allocated scratch state for the affected_components callback. We // collect (entity_id, component_class) pairs into these arrays first, // then write them out after counting — this lets us emit the // prior_state_count varint at the head of the record. /** * @type {number[]} * @private */ this.#scratch_entities = []; /** * @type {Function[]} * @private */ this.#scratch_classes = []; // Bound callback so the per-execute `affected_components` call doesn't // close over a fresh arrow function each time. /** * @type {function(number, Function): void} * @private */ this.#collect_cb = (entity_id, component_class) => { this.#scratch_entities.push(entity_id); this.#scratch_classes.push(component_class); }; /** * Optional hook fired at the top of {@link execute}, before * prior-bytes capture. Higher-level orchestrators install this * to normalize the world before the executor reads live state * (e.g. {@link NetworkSession} undoes render-time interpolation * on remote-owned components). Should be idempotent and cheap * when no work is needed. * * @type {(() => void) | null} */ this.before_execute = null; } /** * Execute an action: capture prior state of affected components, apply, log. * * @param {SimAction} action * @param {number} [sender_id] peer that originated this action. * Defaults to {@link SENDER_LOCAL} for locally-originated actions. * Must be in `[0, 254]` for remote-originated actions. */ execute(action, sender_id = SENDER_LOCAL) { assert.ok(action && action.isSimAction, 'execute: action must be a SimAction'); assert.isNonNegativeInteger(sender_id, 'sender_id'); assert.ok(sender_id <= 0xFF, 'sender_id must fit in a uint8'); if (this.before_execute !== null) { this.before_execute(); } const buffer = this.action_log.current_buffer(); // 1. Collect affected (entity, component_class) pairs. // Pass the executor as context so actions can translate network_id → entity_id // via this.slot_table inside affected_components. this.#scratch_entities.length = 0; this.#scratch_classes.length = 0; action.affected_components(this.#collect_cb, this); const affected_count = this.#scratch_entities.length; // 2. Validate ALL component_type_ids up front, BEFORE any buffer // writes. A mid-loop throw after we'd already written the // prior_state_count varint and N entries would leave a partial // record in the action log; subsequent rewind code walking the // frame would read garbage. By failing before any write, the // buffer position is unchanged and the frame is reusable. for (let i = 0; i < affected_count; i++) { const component_class = this.#scratch_classes[i]; const component_type_id = this.component_registry.type_id_of(component_class); if (component_type_id < 0) { throw new Error(`SimActionExecutor.execute: component '${component_class.typeName ?? component_class.name}' is not registered for replication`); } } // 3. Write the prior_state_count varint. buffer.writeUintVar(affected_count); // 4. For each affected pair, capture prior bytes via the adapter. const changed = this.changed_entities; for (let i = 0; i < affected_count; i++) { const entity_id = this.#scratch_entities[i]; const component_class = this.#scratch_classes[i]; // Safe lookup — validated above; type_id_of() result is reusable // because the registry assigns ids stably. const component_type_id = this.component_registry.type_id_of(component_class); const adapter = this.component_registry.adapter_for_id(component_type_id); const component = this.world.getComponent(entity_id, component_class); // Track this entity in the per-tick mutation set if one is wired up. // Translation entity_id → network_id is via slot_table; if the entity // isn't replicated (no slot), skip — local-only mutations don't go // into the mutation ledger. if (changed !== null) { const network_id = this.slot_table.network_for(entity_id); if (network_id >= 0) changed.add(network_id); } buffer.writeUintVar(entity_id); buffer.writeUint8(component_type_id); // Reserve 4 bytes for payload_len, write payload, then back-patch length. const len_position = buffer.position; buffer.writeUint32(0); // placeholder const payload_start = buffer.position; adapter.serialize(buffer, component); const payload_end = buffer.position; const payload_len = payload_end - payload_start; buffer.position = len_position; buffer.writeUint32(payload_len); buffer.position = payload_end; } // 4. Apply the action. Pass the executor as context so the action can // resolve network_id ↔ entity_id via this.slot_table. action.apply(this.world, this); // 5. Append the action itself. buffer.writeUint8(action.constructor.type_id); // sender_id: peer that originated this action. Local-only record; not // sent over the wire (Replicator strips it during pack). Used by // rollback orchestrators for stable-sort tie-breaking. buffer.writeUint8(sender_id); // Reserve 4 bytes for action_payload_len, write payload, then back-patch. const action_len_position = buffer.position; buffer.writeUint32(0); // placeholder const action_payload_start = buffer.position; action.serialize(buffer); const action_payload_end = buffer.position; const action_payload_len = action_payload_end - action_payload_start; buffer.position = action_len_position; buffer.writeUint32(action_payload_len); buffer.position = action_payload_end; } }