@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
239 lines (209 loc) • 9.8 kB
JavaScript
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;
}
}