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