@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
257 lines (216 loc) • 8.49 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { ceilPowerOfTwo } from "../../../core/binary/operations/ceilPowerOfTwo.js";
import {
generate_next_linear_congruential_index
} from "../../../core/collection/map/generate_next_linear_congruential_index.js";
import { isPowerOfTwo } from "../../../core/math/isPowerOfTwo.js";
/**
* Reserved sentinel for "empty slot." Network IDs are unsigned 32-bit; we
* forbid `0xFFFFFFFF` as a usable ID (the slot table never allocates that
* many slots, so this is safe in practice).
* @type {number}
*/
const EMPTY = 0xFFFFFFFF;
/**
* Maximum load factor before {@link ChangedEntitySet#add} grows the backing
* array. 0.5 keeps probe chains short (mean probe distance ≈ 1.5 with random
* keys) at the cost of using twice the slots; the right tradeoff for a hot-path
* set where O(1)-expected `add` matters more than memory.
* @type {number}
*/
const LOAD_FACTOR = 0.5;
/**
* Open-addressed hash set keyed by `uint32` network IDs. Backed by a single
* `Uint32Array`; empty slots hold `0xFFFFFFFF`. Probing uses
* {@link generate_next_linear_congruential_index} (the same scheme as
* `core/collection/map/HashMap.js`).
*
* Designed for the per-tick "which entities changed this tick?" use case:
* - `clear()` is one `fill(0xFFFFFFFF)` call (vectorisable, JIT-friendly).
* - `add(network_id)` is O(1) expected with a load factor < 0.5.
* - `compact_into(buffer)` writes each non-empty slot as a `varint` and
* returns the count, suitable for appending to a `MutationLedger` slice.
*
* Capacity grows automatically when load factor exceeds 0.5 (doubles + rehashes,
* O(N) amortised). Construction-time `capacity` is the *initial* size —
* pick at least 2× your typical peak per-tick mutation count to avoid the
* grow path on the hot loop.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class ChangedEntitySet {
#mask;
#slots;
#size;
/**
* @param {{ capacity?: number }} [options] capacity is rounded up to a power of two; default 256
*/
constructor({ capacity = 256 } = {}) {
assert.isNonNegativeInteger(capacity, 'capacity');
// Round up to power of two so we can use a bitmask instead of modulo.
const cap = ceilPowerOfTwo(capacity);
/** @type {number} */
this.capacity = cap;
/** @private @type {number} */
this.#mask = cap - 1;
/**
* @private
* @type {Uint32Array}
*/
this.#slots = new Uint32Array(cap).fill(EMPTY);
/** @private @type {number} */
this.#size = 0;
}
/**
* Reallocate the backing array at `new_capacity` and re-insert every
* present ID. Items are placed by their hash against the new mask, so
* resize is the only correct way to change capacity (a flat copy would
* leave entries at indices that no longer match `id & mask`).
*
* @param {number} new_capacity power of two; must be at least 2× current size
*/
resize(new_capacity) {
assert.isNonNegativeInteger(new_capacity, 'new_capacity');
assert.ok(isPowerOfTwo(new_capacity), 'new_capacity must be a power of two');
assert.ok(new_capacity >= this.#size * 2, 'new_capacity too small to host current size at load factor 0.5');
// Snapshot the old slots, swap in the new ones, then re-insert. Doing
// it via add() handles the new hash positions for free; the trick is
// resetting __size first so add()'s "already present" path doesn't
// miscount and so the load-factor guard inside add() doesn't recurse.
const old_slots = this.#slots;
this.#slots = new Uint32Array(new_capacity).fill(EMPTY);
this.#mask = new_capacity - 1;
this.capacity = new_capacity;
this.#size = 0;
for (let i = 0; i < old_slots.length; i++) {
const v = old_slots[i];
if (v !== EMPTY) this.add(v);
}
}
/**
* Double the capacity. Called automatically by {@link add} when the load
* factor would exceed `LOAD_FACTOR` after the next insert.
*/
grow() {
this.resize(this.capacity * 2);
}
/**
* Add a network ID. No-op if already present. Grows the backing array
* (and re-hashes) if adding this ID would exceed the load factor.
*
* @param {number} network_id non-negative integer < 0xFFFFFFFF
*/
add(network_id) {
// The EMPTY sentinel collides with itself: the dedup check would match
// an empty slot and silently no-op while `has(EMPTY)` would later report
// true — `size`/`for_each`/`has` would disagree.
assert.notEqual(network_id, EMPTY, 'ChangedEntitySet.add: 0xFFFFFFFF is reserved as the EMPTY sentinel');
// Pre-grow check: if this insert would push us over the load factor,
// double capacity *first* so the insert lands in the bigger table.
// Equivalent to (size+1)/capacity > LOAD_FACTOR; written without the
// division to keep the hot path branch-light.
if ((this.#size + 1) > this.capacity * LOAD_FACTOR) {
this.grow();
}
// No assert in the hot path — this is called per-mutation. Bad input
// produces wrong results (collisions with the EMPTY sentinel) which
// higher layers will notice; the cost of asserting per call isn't worth it.
const slots = this.#slots;
const mask = this.#mask;
let slot_index = network_id & mask;
const capacity = this.capacity;
for (let probe = 0; probe < capacity; probe++) {
const v = slots[slot_index];
if (v === network_id) {
// already present
return;
}
if (v === EMPTY) {
slots[slot_index] = network_id;
this.#size++;
return;
}
slot_index = generate_next_linear_congruential_index(slot_index, mask);
}
// Unreachable: the load-factor guard above keeps occupancy under 0.5,
// so the probe loop is guaranteed to find an EMPTY slot.
throw new Error(`ChangedEntitySet.add: probe loop exhausted at capacity ${this.capacity}, size ${this.#size} — this is a bug`);
}
/**
* @param {number} network_id
* @returns {boolean}
*/
has(network_id) {
const slots = this.#slots;
const mask = this.#mask;
let slot_index = network_id & mask;
for (let probe = 0; probe < this.capacity; probe++) {
const v = slots[slot_index];
if (v === network_id) {
return true;
}
if (v === EMPTY) {
return false;
}
slot_index = generate_next_linear_congruential_index(slot_index, mask);
}
return false;
}
/**
* Reset every slot to empty.
*/
clear() {
this.#slots.fill(EMPTY);
this.#size = 0;
}
/**
* Number of unique network IDs currently in the set.
* @returns {number}
*/
size() {
return this.#size;
}
/**
* Iterate every present network ID. Order is implementation-defined
* (slot order, not insertion or sorted) — callers must not rely on it.
*
* @param {function(number): void} fn
*/
for_each(fn) {
const slots = this.#slots;
const cap = this.capacity;
for (let i = 0; i < cap; i++) {
const v = slots[i];
if (v !== EMPTY) fn(v);
}
}
/**
* Append every present network ID into `buffer` as varints. Returns the
* number of IDs written. Caller is responsible for any framing (count
* prefix, length prefix, etc.).
*
* @param {BinaryBuffer} buffer
* @returns {number} count written
*/
compact_into(buffer) {
let written = 0;
const slots = this.#slots;
const cap = this.capacity;
for (let i = 0; i < cap; i++) {
const v = slots[i];
if (v !== EMPTY) {
buffer.writeUintVar(v);
written++;
}
}
return written;
}
}
/**
* Sentinel value reserved for empty slots — exposed so external callers can
* recognise it if they peek at raw storage.
* @readonly
* @type {number}
*/
ChangedEntitySet.EMPTY = EMPTY;