UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

181 lines (165 loc) 6.39 kB
import { assert } from "../../../core/assert.js"; /** * Per-action-type priority accumulator for bandwidth-aware packet packing. * * Each action class is assigned a static priority at registration; per * tick, each type's accumulator grows by `priority * dt`. When the * outgoing packet's byte budget is tight, the orchestrator queries * {@link sorted_descending} to get types in priority-ranked order and * packs from highest down until the packet is full. Calling * {@link consume} after a type has been packed reduces its accumulator, * letting other types win the next tick — this is the starvation- * resistant scheduling pattern Glenn Fiedler's *Networked Physics* * series describes. * * Memory layout: two Float32Arrays indexed by `type_id` (assumed dense * starting at 0, matching the engine's `SimActionRegistry` numbering). * `tick` is a single pass over both arrays — cache-friendly and * allocation-free in steady state. * * Scope: this module is the data-structure half. Wiring it into * `Replicator.pack_for_peer` is the orchestrator's concern — the * prototype's tiny action volumes make integration unnecessary, but * a real production game with hundreds of replicated entities will * need the Replicator to consult {@link sorted_descending} when its * outgoing buffer approaches the MTU. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class PriorityAccumulator { #priority; #accum; #sort_indices; /** * @param {{ type_count: number }} options * `type_count` is the dense range `[0, type_count)` of action type_ids * the accumulator will track. Should be at least one more than the * highest registered type_id. */ constructor({ type_count }) { assert.isPositiveInteger(type_count, 'type_count'); /** @readonly */ this.type_count = type_count; /** * Static per-type priority. Set once at registration via * {@link set_priority}. * @type {Float32Array} * @private */ this.#priority = new Float32Array(type_count); /** * Per-type accumulator, grows linearly with `priority * dt` each * tick. Reduced by {@link consume} when an action of that type is * packed into an outgoing packet. * @type {Float32Array} * @private */ this.#accum = new Float32Array(type_count); /** * Reusable indirection array for {@link sorted_descending}. Typed * arrays don't accept comparator-based sort; this is a regular * `Array<number>` for that purpose. Pre-allocated to avoid GC * pressure on the per-tick selection path. * @type {number[]} * @private */ this.#sort_indices = new Array(type_count); } /** * Set the static priority for a type. Typically called at action * registration (per type, once). Larger = more important. * * @param {number} type_id * @param {number} priority units are arbitrary; only relative magnitudes matter */ set_priority(type_id, priority) { assert.isNonNegativeInteger(type_id, 'type_id'); assert.ok(type_id < this.type_count, `type_id ${type_id} out of range [0, ${this.type_count})`); assert.isNumber(priority, 'priority'); this.#priority[type_id] = priority; } /** * @param {number} type_id * @returns {number} the configured static priority for this type */ priority(type_id) { return this.#priority[type_id]; } /** * Advance every accumulator by `priority * dt`. Call once per tick * before consulting {@link sorted_descending} / {@link consume}. * * @param {number} dt elapsed seconds (or any time unit consistent with * the priority units you configured) */ tick(dt) { const N = this.type_count; const p = this.#priority; const a = this.#accum; for (let i = 0; i < N; i++) { a[i] += p[i] * dt; } } /** * Current accumulator value for a type. * @param {number} type_id * @returns {number} */ value(type_id) { return this.#accum[type_id]; } /** * Reduce a type's accumulator by `amount`. Clamped at 0 — a type's * accumulator never goes negative, so missing a single tick doesn't * permanently penalize it. Call after packing an action of this type * into an outgoing packet. * * @param {number} type_id * @param {number} amount */ consume(type_id, amount) { const next = this.#accum[type_id] - amount; this.#accum[type_id] = next < 0 ? 0 : next; } /** * Clear a type's accumulator to 0. Useful when an entire backlog has * been packed (e.g. all pending actions of a type fit in one packet). * * @param {number} type_id */ reset(type_id) { this.#accum[type_id] = 0; } /** * Write type_ids to `out_array` in descending accumulator order. The * orchestrator then iterates `out_array` to know which types to pack * first. * * `out_array` must have capacity for `type_count` entries. Stable * within ties (insertion order = type_id ascending), so the result * is deterministic given the same accumulator state. * * @param {Int32Array|number[]} out_array */ sorted_descending(out_array) { assert.ok(out_array.length >= this.type_count, `out_array must hold at least ${this.type_count} entries`); const N = this.type_count; const indices = this.#sort_indices; for (let i = 0; i < N; i++) indices[i] = i; const accum = this.#accum; // Stable sort (Array.prototype.sort is stable in ES2019+); tie-break // by insertion order = type_id ascending, which matches the natural // registration order. indices.sort((a, b) => accum[b] - accum[a]); for (let i = 0; i < N; i++) out_array[i] = indices[i]; } /** * Reset every accumulator to 0. Priorities are preserved. Use when * re-establishing fresh state (e.g. after a disconnect/reconnect). */ clear_accumulators() { for (let i = 0; i < this.type_count; i++) this.#accum[i] = 0; } }