Initial Commit
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
# 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"`.
|
||||
Reference in New Issue
Block a user