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