UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

183 lines (161 loc) 6.14 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; /** * Append-only per-tick log of "which entities mutated this tick", recorded as * compact varint sequences in a single backing buffer. * * Used by the server to answer the recovery query: *"which entities changed * between tick X and tick Y?"* — the client asks this when it detects loss * beyond the ActionLog retry window, so it can request the current state of * just those entities instead of a full re-init. * * Keeps one global log (NOT per-client). Entry header per tick is small * (`{tick, offset, length}` = ~16 bytes); the per-id payload is one varint * each. At 60 Hz with 1k mutations/sec this is ~60 KB/min uncompacted. * * Trim: caller invokes {@link drop_through} periodically to release old * ticks. The backing buffer is re-packed in place via `Uint8Array.copyWithin` * so memory stays bounded. Beyond the retained window, the recovery answer * is "you're too far behind — full re-init." * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class MutationLedger { #buffer; #entries; /** * @param {{ initial_buffer_size?: number }} [options] */ constructor({ initial_buffer_size = 4096 } = {}) { /** * @private * @type {BinaryBuffer} */ this.#buffer = new BinaryBuffer(); this.#buffer.setCapacity(initial_buffer_size); this.#buffer.position = 0; /** * Per-tick metadata. Sorted ascending by `tick` (we only append in * monotonic-tick order, so this invariant holds naturally). * @private * @type {{tick: number, offset: number, length: number}[]} */ this.#entries = []; } /** * Record the contents of `changed_set` as the mutations for `tick_number`. * Tick numbers must be monotonically non-decreasing across calls; recording * the same tick twice appends a second entry (which is fine — range * queries will union them). * * @param {number} tick_number * @param {ChangedEntitySet} changed_set */ record_tick(tick_number, changed_set) { assert.isNonNegativeInteger(tick_number, 'tick_number'); // Idle ticks (no mutations) are a no-op: storing a zero-length entry // would just waste an array slot and force range queries to iterate it. if (changed_set.size() === 0) return; const offset = this.#buffer.position; changed_set.compact_into(this.#buffer); const length = this.#buffer.position - offset; this.#entries.push({ tick: tick_number, offset, length }); } /** * Read every network ID recorded in any tick within `[start_tick, end_tick]` * (inclusive on both ends) and add it to `output_set`. Duplicate IDs across * ticks are deduped naturally by the set. * * @param {ChangedEntitySet} output_set caller-owned destination; not cleared * @param {number} start_tick * @param {number} end_tick */ entities_changed_in_range(output_set, start_tick, end_tick) { assert.isNonNegativeInteger(start_tick, 'start_tick'); assert.isNonNegativeInteger(end_tick, 'end_tick'); const buf = this.#buffer; const saved_pos = buf.position; try { const entries = this.#entries; for (let i = 0; i < entries.length; i++) { const e = entries[i]; if (e.tick < start_tick) continue; if (e.tick > end_tick) break; // entries are sorted const end = e.offset + e.length; buf.position = e.offset; while (buf.position < end) { output_set.add(buf.readUintVar()); } } } finally { buf.position = saved_pos; } } /** * Drop all entries whose tick is `<=` `tick_number` and reclaim their * buffer space. After this call, queries below `tick_number` return nothing. * * @param {number} tick_number */ drop_through(tick_number) { assert.isInteger(tick_number, 'tick_number'); const entries = this.#entries; // Find the index of the first entry to keep. let keep_from = 0; while (keep_from < entries.length && entries[keep_from].tick <= tick_number) keep_from++; if (keep_from === 0) return; // nothing to drop if (keep_from === entries.length) { // Drop everything. entries.length = 0; this.#buffer.position = 0; return; } // Shift the buffer's retained tail down to offset 0. const shift = entries[keep_from].offset; const tail_end = this.#buffer.position; // copyWithin(target, start, end) — safe for overlapping sources. this.#buffer.raw_bytes.copyWithin(0, shift, tail_end); this.#buffer.position = tail_end - shift; // Drop the dead head entries; rebase offsets of the survivors. entries.splice(0, keep_from); for (let i = 0; i < entries.length; i++) { entries[i].offset -= shift; } } /** * Earliest tick still recorded, or -1 if empty. * @returns {number} */ earliest_recorded_tick() { return this.#entries.length === 0 ? -1 : this.#entries[0].tick; } /** * Most recent tick recorded, or -1 if empty. * @returns {number} */ latest_recorded_tick() { return this.#entries.length === 0 ? -1 : this.#entries[this.#entries.length - 1].tick; } /** * Number of recorded ticks (not network IDs). * @returns {number} */ size() { return this.#entries.length; } /** * Bytes currently held in the backing buffer (after any drops). * @returns {number} */ byte_size() { return this.#buffer.position; } /** * Drop everything. */ clear() { this.#entries.length = 0; this.#buffer.position = 0; } }