Files
skills/docs/gaming/games/game-server/src/state/state-manager/state-manager.md
2026-05-29 08:20:56 +01:00

5.1 KiB

StateManager

(no submodules)

Purpose

StateManager is the canonical, observable container for game-session state. It holds a single typed value, exposes read/write APIs (get, update, updateMany, setState, reset, restore), and emits a StateChangeEvent on every mutation so downstream consumers — UI binders, sync engines, persistence layers, replay recorders — can react without polling. Snapshots are returned deep-frozen so reckless callers can't reach into the manager's internal state and break its invariants.

The Wave B-3 port replaces the upstream's Logger and GameEventEmitter dependencies with injectable Clock and IdGenerator from @suaveplan/types plus OTel-native instrumentation. Listener faults are counted and absorbed — a misbehaving subscriber can never destabilise the state machine. restore accepts an optional validator predicate and throws GAME_SERVER.INVALID_SNAPSHOT when the snapshot fails it; clone failures surface as GAME_SERVER.STATE_RESTORE_FAILED.

Features

  • Single-value typed store with deep-frozen reads.
  • Six mutation paths (update, updateMany, setState, reset, restore, plus subscribe).
  • Bounded change history (default 100 entries, configurable).
  • Synchronous listener notification with isolated error handling.
  • Injectable Clock and IdGenerator for deterministic test scenarios.
  • Three OTel instruments: mutation_total, listener_error_total, mutation_duration_ms.
  • Snapshot validation via predicate; typed errors on rejection.
  • Discriminated StateChangeEvent.kind for downstream switch logic.

Usage: Basic

import { StateManager } from "@suaveplan/game-server/state/state-manager";

interface World { score: number; alive: boolean }
const world = new StateManager<World>({ score: 0, alive: true });

const unsub = world.subscribe((event) => {
    console.log(event.kind, event.currentState.score);
});

world.update("score", 1);
world.update("score", 2);
world.setState({ score: 5, alive: false });

unsub();
world.reset(); // back to initial { score: 0, alive: true }

Usage: Advanced

import { StateManager } from "@suaveplan/game-server/state/state-manager";
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";

interface Session { round: number; players: string[] }

const isSession = (v: unknown): v is Session =>
    typeof v === "object" &&
    v !== null &&
    typeof (v as Session).round === "number" &&
    Array.isArray((v as Session).players);

const manager = new StateManager<Session>(
    { round: 0, players: [] },
    { maxHistorySize: 500, clock: () => 1700000000000, idGenerator: () => "test" }
);

try {
    manager.restore({ wrong: true }, isSession);
} catch (err) {
    if (
        err instanceof GameServerError &&
        err.code === GAME_SERVER_CODES.INVALID_SNAPSHOT
    ) {
        // Surface a typed diagnostic; state is unchanged.
    }
}

manager.restore({ round: 3, players: ["a", "b"] }, isSession);
console.log(manager.getStats()); // { updateCount, listenerCount, ... }

API Reference

class StateManager<TState extends object>

constructor(initialState, options?)

options — { maxHistorySize?, clock?, idGenerator?, meter? }.

getState(): DeepReadonly<TState>

get<K>(key): DeepReadonly<TState[K]>

has(key): boolean / keys(): Array<keyof TState>

update(key, value): void / updateMany(partial): void

setState(next): void / reset(): void

restore(snapshot, validator?): void

Throws GAME_SERVER.INVALID_SNAPSHOT (validator) or GAME_SERVER.STATE_RESTORE_FAILED (clone).

subscribe(listener): () => void

getHistory() / getRecentHistory(count) / clearHistory()

getStats(): StateManagerStats / dispose(): void

Types

  • StateChangeEvent<TState> — { id, timestamp, kind, previousState, currentState }.
  • StateChangeListener<TState>, StateManagerStats, SnapshotValidator<TState>.

Implementation Notes

getState deep-clones then deep-freezes the snapshot so callers cannot mutate internal state, and so two consecutive getState calls return distinct frozen objects — important for downstream code that uses referential equality to detect change. The clone is recursive and pure (plain objects + arrays + primitives) because the manager is constrained to JSON-serialisable shapes; class instances are treated as opaque values and would survive clone by reference. Game code that wants identity-preserving state should split such objects into a sibling registry rather than embedding them in the state tree.

update short-circuits via Object.is when the value is unchanged, avoiding spurious history entries and listener invocations. The listener loop catches every thrown exception, counts it on listener_error_total, and continues — a misbehaving subscriber cannot block other subscribers or corrupt the state machine.

maxHistorySize is enforced by slice(-max) after every push so the array is never more than max + 1 long; this keeps memory bounded on long-running sessions where a million mutations would otherwise pin the entire history in RAM.