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