first commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# StateManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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.
|
||||
Reference in New Issue
Block a user