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