UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

212 lines (190 loc) 7.09 kB
import { assert } from "../../../core/assert.js"; /** * Bidirectional map between local entity IDs and stable, peer-shared network IDs. * * The same entity is represented by different local IDs on different peers (each * peer's `EntityComponentDataset` assigns IDs independently). The network ID is * the peer-shared identifier for "this object" in serialized form. * * Network IDs are recyclable: when a slot is freed, its `generation` counter * increments. A stale reference like `(network_id=5, generation=2)` will not * match the live `(network_id=5, generation=3)` slot, so use-after-free can * be detected at parse time on the receiving peer. * * Storage is two parallel typed arrays indexed by `network_id`, plus a small * free-list of reusable IDs. No JS object per slot. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class ReplicationSlotTable { #entity_id_by_network; #generation_by_network; #network_id_by_entity; #free_list; #highwater; #capacity; /** * @param {{ initial_capacity?: number }} [options] */ constructor({ initial_capacity = 256 } = {}) { /** * `entity_id` for each network_id slot, or -1 if free. * @type {Int32Array} * @private */ this.#entity_id_by_network = new Int32Array(initial_capacity).fill(-1); /** * Generation counter per slot. Bumped on free. * @type {Uint32Array} * @private */ this.#generation_by_network = new Uint32Array(initial_capacity); /** * Reverse map for entity_id -> network_id lookup. * Sparse; only populated for live mappings. * @type {Map<number, number>} * @private */ this.#network_id_by_entity = new Map(); /** * Recyclable network_ids. * @type {number[]} * @private */ this.#free_list = []; /** * Highest network_id ever assigned. * @type {number} * @private */ this.#highwater = 0; /** * @type {number} * @private */ this.#capacity = initial_capacity; } /** * Allocate a network_id for the given entity_id. Returns the new id. * Throws if the entity already has a network_id. * * @param {number} entity_id * @returns {number} network_id */ allocate(entity_id) { assert.isNonNegativeInteger(entity_id, 'entity_id'); if (this.#network_id_by_entity.has(entity_id)) { throw new Error(`ReplicationSlotTable.allocate: entity ${entity_id} already has network_id ${this.#network_id_by_entity.get(entity_id)}`); } let network_id; if (this.#free_list.length > 0) { network_id = this.#free_list.pop(); } else { network_id = this.#highwater++; if (network_id >= this.#capacity) { this.#grow(); } } this.#entity_id_by_network[network_id] = entity_id; this.#network_id_by_entity.set(entity_id, network_id); return network_id; } /** * Allocate using an explicit network_id (for the receiving peer that takes its * IDs from the wire). Bumps the slot's generation if it was reused. Throws * if the slot is currently in use. * * @param {number} network_id * @param {number} entity_id */ allocate_at(network_id, entity_id) { assert.isNonNegativeInteger(network_id, 'network_id'); assert.isNonNegativeInteger(entity_id, 'entity_id'); if (network_id >= this.#capacity) { while (network_id >= this.#capacity) this.#grow(); } if (this.#entity_id_by_network[network_id] !== -1) { throw new Error(`ReplicationSlotTable.allocate_at: network_id ${network_id} already in use`); } if (this.#network_id_by_entity.has(entity_id)) { throw new Error(`ReplicationSlotTable.allocate_at: entity ${entity_id} already has network_id ${this.#network_id_by_entity.get(entity_id)}`); } // Catch the "id is on the free list" case before silent clobbering: a // subsequent allocate() would pop the same id and overwrite this mapping. assert.arrayHasNo(this.#free_list, network_id, `ReplicationSlotTable.allocate_at: network_id ${network_id} is in the free list`); this.#entity_id_by_network[network_id] = entity_id; this.#network_id_by_entity.set(entity_id, network_id); if (network_id >= this.#highwater) this.#highwater = network_id + 1; } /** * Free the slot for `network_id`. Bumps the slot's generation. Slot becomes * available for re-allocation. * * @param {number} network_id */ free(network_id) { assert.isNonNegativeInteger(network_id, 'network_id'); const entity_id = this.#entity_id_by_network[network_id]; if (entity_id === -1) { return; // already free } this.#entity_id_by_network[network_id] = -1; this.#network_id_by_entity.delete(entity_id); this.#generation_by_network[network_id] = (this.#generation_by_network[network_id] + 1) >>> 0; this.#free_list.push(network_id); } /** * Local entity for a network_id, or -1 if the slot is free. * * @param {number} network_id * @returns {number} */ entity_for(network_id) { assert.isInteger(network_id, 'network_id'); if (network_id < 0 || network_id >= this.#capacity) return -1; return this.#entity_id_by_network[network_id]; } /** * Network_id for a local entity, or -1 if not registered. * * @param {number} entity_id * @returns {number} */ network_for(entity_id) { assert.isNonNegativeInteger(entity_id, 'entity_id'); const v = this.#network_id_by_entity.get(entity_id); return v === undefined ? -1 : v; } /** * Generation of the slot. Useful for stale-reference detection. * * @param {number} network_id * @returns {number} */ generation_of(network_id) { assert.isInteger(network_id, 'network_id'); if (network_id < 0 || network_id >= this.#capacity) return 0; return this.#generation_by_network[network_id]; } /** * Number of currently-allocated slots. * @returns {number} */ live_count() { return this.#network_id_by_entity.size; } /** * @private */ #grow() { const new_capacity = Math.max(this.#capacity * 2, this.#capacity + 64); const new_entities = new Int32Array(new_capacity).fill(-1); new_entities.set(this.#entity_id_by_network); const new_generations = new Uint32Array(new_capacity); new_generations.set(this.#generation_by_network); this.#entity_id_by_network = new_entities; this.#generation_by_network = new_generations; this.#capacity = new_capacity; } }