UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

152 lines (133 loc) 5.49 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; /** * Compute a 32-bit fingerprint of every replicated component on every entity * in the world. Used by `SyncTest` and as a general "did the world state change?" * primitive. * * The hash is FNV-1a — fast, stable, no dependencies. Not cryptographic. * * Iteration order: entities in the order yielded by `world` (the * EntityComponentDataset's iterator), then components in `type_id` order. * As long as both sides iterate identically, the fingerprint is comparable. * * @param {EntityComponentDataset} world * @param {ReplicatedComponentRegistry} component_registry * @param {BinaryBuffer} [scratch] optional reusable scratch; resets to position 0 each call * @returns {number} 32-bit unsigned hash */ export function fingerprint_world(world, component_registry, scratch) { assert.ok(world, 'world required'); assert.ok(component_registry, 'component_registry required'); const buffer = scratch === undefined ? new BinaryBuffer() : scratch; let h = 0x811c9dc5; // FNV-1a 32-bit offset basis const type_count = component_registry.type_count(); for (const entity of world) { for (let type_id = 0; type_id < type_count; type_id++) { const klass = component_registry.class_of(type_id); if (!world.hasComponent(entity, klass)) continue; const adapter = component_registry.adapter_for_id(type_id); buffer.position = 0; adapter.serialize(buffer, world.getComponent(entity, klass)); // Mix entity_id and type_id into the hash so structurally-identical // components on different entities/types produce different prints. h = __fnv_byte(h, entity & 0xFF); h = __fnv_byte(h, (entity >> 8) & 0xFF); h = __fnv_byte(h, (entity >> 16) & 0xFF); h = __fnv_byte(h, (entity >> 24) & 0xFF); h = __fnv_byte(h, type_id); const bytes = buffer.raw_bytes; const len = buffer.position; for (let i = 0; i < len; i++) { h = __fnv_byte(h, bytes[i]); } } } return h >>> 0; } /** * @private */ function __fnv_byte(h, byte) { h ^= byte; return Math.imul(h, 0x01000193) >>> 0; } /** * Diagnostic harness for catching rewind / replay bugs. * * Usage: * - `harness.checkpoint()` — records the current world fingerprint. * - … run tick logic … * - `harness.assert_recoverable_to_checkpoint(rewind_engine, current_frame, target_frame)` * rewinds and asserts the world's fingerprint matches the checkpoint. * * For full nondeterminism detection (GGPO-style "save → advance → load → * advance → diff"), the application's tick logic must be re-runnable; that * coordination is left to the caller. This harness provides the fingerprint + * compare primitives and the assertion shape. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class SyncTest { #scratch; #checkpoint_hash; #has_checkpoint; /** * @param {{ * world: EntityComponentDataset, * component_registry: ReplicatedComponentRegistry, * }} options */ constructor({ world, component_registry }) { assert.ok(world, 'world required'); assert.ok(component_registry, 'component_registry required'); /** @type {EntityComponentDataset} */ this.world = world; /** @type {ReplicatedComponentRegistry} */ this.component_registry = component_registry; /** Reused scratch for fingerprint serialization. */ this.#scratch = new BinaryBuffer(); /** @type {number} @private */ this.#checkpoint_hash = 0; /** @type {boolean} @private */ this.#has_checkpoint = false; } /** * Capture the current world state as the reference point for the next * `assert_recoverable_to_checkpoint` call. */ checkpoint() { this.#checkpoint_hash = fingerprint_world(this.world, this.component_registry, this.#scratch); this.#has_checkpoint = true; } /** * @returns {number} */ current_fingerprint() { return fingerprint_world(this.world, this.component_registry, this.#scratch); } /** * Rewind from `current_frame` back to `target_frame` (typically the frame * at which `checkpoint()` was called) and assert the resulting world state * matches the checkpoint. * * Throws on mismatch with a diagnostic message including both fingerprints. * * @param {RewindEngine} rewind_engine * @param {number} current_frame * @param {number} target_frame */ assert_recoverable_to_checkpoint(rewind_engine, current_frame, target_frame) { assert.ok(this.#has_checkpoint, 'SyncTest: no checkpoint() taken before assertion'); rewind_engine.rewind_to(current_frame, target_frame); const after = fingerprint_world(this.world, this.component_registry, this.#scratch); if (after !== this.#checkpoint_hash) { throw new Error( `SyncTest: world fingerprint after rewind (${after.toString(16)}) ` + `does not match checkpoint (${this.#checkpoint_hash.toString(16)}) ` + `— rewind is non-deterministic or did not fully restore state` ); } } }