UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

190 lines (171 loc) 6.49 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; /** * Per-frame ring of action records. * * Each frame's `BinaryBuffer` holds a sequence of action records. One record per * `SimActionExecutor.execute` call: * * ``` * varint: prior_state_count * loop: * varint: entity_id * uint8: component_type_id (assigned by the ReplicatedComponentRegistry) * uint32: prior_payload_len * bytes: prior_payload (adapter.serialize of the component's prior state) * uint8: action_type_id * uint8: sender_id (peer that originated the action; local-only, * STRIPPED by Replicator before send) * uint32: action_payload_len * bytes: action_payload (the action's own serialize output) * ``` * * Self-describing: records can be skipped without consulting the action registry, * which means rewind code can walk records forward to find boundaries, then * iterate backward to apply prior states — no need to instantiate any actions. * * Note that `sender_id` is recorded in-buffer for local rollback orchestrators * (stable-sort tie-breaking by sender on replay) but never crosses the wire — * {@link Replicator#pack_for_peer} strips it and the receiver derives sender * from the inbound packet's peer_id, so a hostile peer cannot impersonate. * * Ring depth is set at construction. When the ring fills, the oldest frame's * buffer is recycled for the new frame. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class ActionLog { #buffers; #frame_numbers; #write_ends; #current_frame; /** * @param {{ frame_capacity: number, initial_buffer_size?: number }} options */ constructor({ frame_capacity, initial_buffer_size = 4096 }) { if (!Number.isInteger(frame_capacity) || frame_capacity <= 0) { throw new Error(`ActionLog: frame_capacity must be a positive integer, got ${frame_capacity}`); } /** * @readonly * @type {number} */ this.frame_capacity = frame_capacity; /** * Per-frame buffers. Index by `frame % frame_capacity`. * @type {BinaryBuffer[]} * @private */ this.#buffers = new Array(frame_capacity); /** * Per-frame absolute frame numbers (so we can detect "this slot is stale"). * `-1` means the slot has not yet been used. * @type {Int32Array} * @private */ this.#frame_numbers = new Int32Array(frame_capacity).fill(-1); /** * Per-frame "write end" position. The buffer's own `position` is reused * when actively writing; this is captured at frame_end so readers know * where the meaningful data ends. * @type {Int32Array} * @private */ this.#write_ends = new Int32Array(frame_capacity); for (let i = 0; i < frame_capacity; i++) { this.#buffers[i] = new BinaryBuffer(); this.#buffers[i].setCapacity(initial_buffer_size); } /** * Frame number currently being written to, or -1 if no frame is open. * @type {number} * @private */ this.#current_frame = -1; } /** * Open a new frame for writing. Recycles the oldest slot if the ring is full. * * @param {number} frame absolute frame number */ begin_frame(frame) { assert.isNonNegativeInteger(frame, 'frame'); if (this.#current_frame !== -1) { throw new Error(`ActionLog.begin_frame: previous frame ${this.#current_frame} still open; call end_frame first`); } const slot = frame % this.frame_capacity; const buffer = this.#buffers[slot]; buffer.position = 0; this.#frame_numbers[slot] = frame; this.#current_frame = frame; } /** * Close the currently-open frame. */ end_frame() { if (this.#current_frame === -1) { throw new Error(`ActionLog.end_frame: no frame is open`); } const slot = this.#current_frame % this.frame_capacity; this.#write_ends[slot] = this.#buffers[slot].position; this.#current_frame = -1; } /** * Get the buffer for the currently-open frame for direct writing. * * @returns {BinaryBuffer} */ current_buffer() { if (this.#current_frame === -1) { throw new Error(`ActionLog.current_buffer: no frame is open`); } return this.#buffers[this.#current_frame % this.frame_capacity]; } /** * Has the given frame number ever been written, and is it still in the ring? * * @param {number} frame * @returns {boolean} */ has_frame(frame) { assert.isNonNegativeInteger(frame, 'frame'); const slot = frame % this.frame_capacity; return this.#frame_numbers[slot] === frame; } /** * Read-only access to a closed frame's buffer. Throws if the frame is not in the ring. * The buffer's `position` will be set to 0 and its valid byte length is `write_end_for(frame)`. * * **Only valid for frames that have been closed via {@link end_frame}.** While a * frame is open, `__write_ends[slot]` still holds the previous occupant's * length — `buffer_for`/`write_end_for` would return mismatched * buffer-contents-vs-length and any reader would walk garbage. * * @param {number} frame * @returns {BinaryBuffer} */ buffer_for(frame) { assert.isNonNegativeInteger(frame, 'frame'); if (!this.has_frame(frame)) { throw new Error(`ActionLog.buffer_for: frame ${frame} is not present in the ring`); } const slot = frame % this.frame_capacity; const buffer = this.#buffers[slot]; buffer.position = 0; return buffer; } /** * Number of valid bytes written to the given frame's buffer. * @param {number} frame * @returns {number} */ write_end_for(frame) { assert.isNonNegativeInteger(frame, 'frame'); if (!this.has_frame(frame)) { throw new Error(`ActionLog.write_end_for: frame ${frame} is not present in the ring`); } const slot = frame % this.frame_capacity; return this.#write_ends[slot]; } }