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, plussubscribe). - Bounded change history (default 100 entries, configurable).
- Synchronous listener notification with isolated error handling.
- Injectable
ClockandIdGeneratorfor deterministic test scenarios. - Three OTel instruments:
mutation_total,listener_error_total,mutation_duration_ms. - Snapshot validation via predicate; typed errors on rejection.
- Discriminated
StateChangeEvent.kindfor 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.