168 lines
6.5 KiB
Markdown
168 lines
6.5 KiB
Markdown
# Capability Spec Delta: game-server (game-server-state)
|
|
|
|
**Package**: `@suaveplan/game-server`
|
|
**Status**: PROPOSED
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Observable state container
|
|
|
|
The package SHALL expose a `StateManager<TState extends object>` class
|
|
that holds a single typed state value, returns deep-frozen snapshots
|
|
through `getState`/`get`, and emits a `StateChangeEvent` on every
|
|
mutation. Listener faults SHALL be isolated and counted but MUST NOT
|
|
propagate or skip remaining listeners.
|
|
|
|
#### Scenario: getState returns deep-frozen snapshot
|
|
|
|
- **WHEN** a caller invokes `getState()`
|
|
- **THEN** the returned value is `Object.isFrozen`-true at every level
|
|
reachable through plain object/array traversal.
|
|
|
|
#### Scenario: listener errors do not interrupt other listeners
|
|
|
|
- **WHEN** one subscribed listener throws and another is registered
|
|
- **THEN** the non-throwing listener is still invoked exactly once per
|
|
mutation and the `listener_error_total` OTel counter increments.
|
|
|
|
**Verified by:**
|
|
`src/state/state-manager/state-manager.test.ts::"getState returns deep-frozen current state"`
|
|
AND
|
|
`src/state/state-manager/state-manager.test.ts::"listener errors are isolated and counted"`
|
|
AND
|
|
`src/state/state-manager/state-manager.test.ts::"event carries previous and current snapshots and id+timestamp"`.
|
|
|
|
---
|
|
|
|
### Requirement: Snapshot restore with typed validation
|
|
|
|
`StateManager.restore(snapshot, validator?)` SHALL run the optional
|
|
predicate before cloning. A failing predicate SHALL throw
|
|
`GameServerError(GAME_SERVER.INVALID_SNAPSHOT)` and leave the manager
|
|
state unchanged. A clone failure SHALL be wrapped as
|
|
`GameServerError(GAME_SERVER.STATE_RESTORE_FAILED)`.
|
|
|
|
#### Scenario: validator rejects malformed snapshot
|
|
|
|
- **WHEN** `restore` is invoked with a snapshot the validator rejects
|
|
- **THEN** a `GameServerError` with code `GAME_SERVER.INVALID_SNAPSHOT`
|
|
is thrown and `getState()` returns the previous state unchanged.
|
|
|
|
#### Scenario: clone failure wrapped as STATE_RESTORE_FAILED
|
|
|
|
- **WHEN** the snapshot has a throwing getter that triggers during clone
|
|
- **THEN** a `GameServerError` with code
|
|
`GAME_SERVER.STATE_RESTORE_FAILED` is thrown.
|
|
|
|
**Verified by:**
|
|
`src/state/state-manager/state-manager.test.ts::"restore throws INVALID_SNAPSHOT when validator fails"`
|
|
AND
|
|
`src/state/state-manager/state-manager.test.ts::"restore wraps clone failures as STATE_RESTORE_FAILED"`.
|
|
|
|
---
|
|
|
|
### Requirement: Structural diff and patch utility
|
|
|
|
The package SHALL expose a `StateDiffer` class with `diff(a, b)`,
|
|
`patch(s, d)`, `isEmpty(d)`, and `mergeDeltas(deltas)` methods that
|
|
produce and consume a discriminated `DiffOperation` grammar (`add`,
|
|
`remove`, `replace`). For any plain-JSON pair `(a, b)`, `patch(a, diff(a, b))`
|
|
SHALL be deep-equal to `b`.
|
|
|
|
#### Scenario: round-trip on nested object + array state
|
|
|
|
- **WHEN** `patch(a, diff(a, b))` is computed for any pair of plain
|
|
JSON-serialisable values `a` and `b`
|
|
- **THEN** the result is deep-equal to `b`.
|
|
|
|
#### Scenario: typed errors on diff/patch failure
|
|
|
|
- **WHEN** an internal traversal throws
|
|
- **THEN** the error surfaces as `GameServerError` with code
|
|
`GAME_SERVER.STATE_DIFF_FAILED` or `GAME_SERVER.STATE_PATCH_FAILED`
|
|
respectively.
|
|
|
|
**Verified by:**
|
|
`src/state/state-differ/state-differ.test.ts::"round-trips: patch(a, diff(a, b)) deep-equals b"`
|
|
AND
|
|
`src/state/state-differ/state-differ.test.ts::"wraps internal failures as STATE_DIFF_FAILED"`
|
|
AND
|
|
`src/state/state-differ/state-differ.test.ts::"wraps patch failures as STATE_PATCH_FAILED"`.
|
|
|
|
---
|
|
|
|
### Requirement: Versioned, Zod-validated delta compressor
|
|
|
|
The package SHALL expose a `DeltaCompressor` class that produces a
|
|
`CompressedDelta` packet `{ version, timestamp, data: Uint8Array, stats }`
|
|
from any pair of plain-JSON snapshots, and decompresses such a packet
|
|
back to a snapshot. The wire payload SHALL be Zod-validated on receipt.
|
|
Version mismatches and parse failures SHALL surface as
|
|
`GameServerError(GAME_SERVER.STATE_DECOMPRESS_FAILED)`.
|
|
|
|
#### Scenario: round-trip via compress + decompress
|
|
|
|
- **WHEN** a delta produced by `compress(a, b)` is applied with
|
|
`decompress(a, packet)`
|
|
- **THEN** the result equals `b`.
|
|
|
|
#### Scenario: version mismatch raises typed error
|
|
|
|
- **WHEN** `decompress` receives a packet whose `version` differs from
|
|
`COMPRESSED_DELTA_VERSION`
|
|
- **THEN** a `GameServerError` with code
|
|
`GAME_SERVER.STATE_DECOMPRESS_FAILED` is thrown.
|
|
|
|
#### Scenario: schema-invalid payload raises typed error
|
|
|
|
- **WHEN** `decompress` receives a packet whose data parses to a value
|
|
that fails the operation schema
|
|
- **THEN** a `GameServerError` with code
|
|
`GAME_SERVER.STATE_DECOMPRESS_FAILED` is thrown.
|
|
|
|
**Verified by:**
|
|
`src/state/delta-compressor/delta-compressor.test.ts::"round-trips compress + decompress"`
|
|
AND
|
|
`src/state/delta-compressor/delta-compressor.test.ts::"throws STATE_DECOMPRESS_FAILED on version mismatch"`
|
|
AND
|
|
`src/state/delta-compressor/delta-compressor.test.ts::"throws STATE_DECOMPRESS_FAILED on schema-invalid payload"`.
|
|
|
|
---
|
|
|
|
### Requirement: Transport-agnostic state synchroniser
|
|
|
|
The package SHALL expose a `StateSync<TState>` class that orchestrates
|
|
delta and full-snapshot synchronisation between a `StateManager` and an
|
|
injectable `StateSyncTransport`. The first call to `sync()` SHALL send
|
|
a full snapshot. Subsequent calls SHALL send a delta unless the delta
|
|
payload exceeds the configurable `maxDeltaSize`, in which case a full
|
|
snapshot SHALL be sent instead. Transport send failures SHALL surface
|
|
as `GameServerError(GAME_SERVER.STATE_SYNC_TRANSPORT_FAILED)`.
|
|
|
|
#### Scenario: initial sync sends full snapshot, subsequent sends delta
|
|
|
|
- **WHEN** `sync()` is called twice on a freshly-constructed `StateSync`
|
|
with an intervening `StateManager.update`
|
|
- **THEN** the first transport message has `kind === "full"` and the
|
|
second has `kind === "delta"`.
|
|
|
|
#### Scenario: large delta forces full-snapshot fallback
|
|
|
|
- **WHEN** the computed delta exceeds `maxDeltaSize`
|
|
- **THEN** a full snapshot is sent instead.
|
|
|
|
#### Scenario: transport failure wrapped as STATE_SYNC_TRANSPORT_FAILED
|
|
|
|
- **WHEN** `transport.send` rejects or throws
|
|
- **THEN** a `GameServerError` with code
|
|
`GAME_SERVER.STATE_SYNC_TRANSPORT_FAILED` propagates to the caller.
|
|
|
|
**Verified by:**
|
|
`src/state/state-sync/state-sync.test.ts::"first sync() sends a full snapshot"`
|
|
AND
|
|
`src/state/state-sync/state-sync.test.ts::"subsequent sync() sends a delta"`
|
|
AND
|
|
`src/state/state-sync/state-sync.test.ts::"falls back to full snapshot when delta exceeds maxDeltaSize"`
|
|
AND
|
|
`src/state/state-sync/state-sync.test.ts::"wraps transport send errors as STATE_SYNC_TRANSPORT_FAILED"`.
|