Initial Commit
This commit is contained in:
55
openspec/archive/2026-05-24-game-server-state/proposal.md
Normal file
55
openspec/archive/2026-05-24-game-server-state/proposal.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Wave B-3: `@suaveplan/game-server` state slice
|
||||
|
||||
**Package**: `@suaveplan/game-server`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature-add (additive to existing 0.1.0 package)
|
||||
|
||||
## Why
|
||||
|
||||
Wave A landed the `@suaveplan/game-server` core foundation (DI container,
|
||||
error registry, Zod-validated boundary types). Five parallel waves now
|
||||
extend the package along orthogonal axes; B-3 owns the **state** axis:
|
||||
the observable in-memory game state plus the diff/compress/sync pipeline
|
||||
that lets clients and servers exchange compact mutations over any
|
||||
transport.
|
||||
|
||||
The upstream reference (`packages/universal/game-server/src/state/` on
|
||||
`/mnt/s/web/suaveplan`) ships four modules — `StateManager`,
|
||||
`StateDiffer`, `DeltaCompressor`, `StateSync` — but couples them to
|
||||
upstream-only types (`Logger`, `GameEventEmitter`, `NetworkAdapter`)
|
||||
that do not yet exist on genesis. This slice ports the algorithms,
|
||||
replaces the upstream couplings with `@suaveplan/types` (`Clock`,
|
||||
`IdGenerator`) plus a slim `StateSyncTransport` interface, and adds
|
||||
OTel-native instrumentation that the upstream lacked.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `src/state/state-manager/` — `StateManager<TState>` with
|
||||
`getState`/`get`/`update`/`updateMany`/`setState`/`reset`/`restore`/
|
||||
`subscribe`/history APIs. Deep-frozen reads. Bounded history.
|
||||
Injectable `Clock` + `IdGenerator`. Three OTel instruments
|
||||
(`mutation_total`, `listener_error_total`, `mutation_duration_ms`).
|
||||
- ADDED: `src/state/state-differ/` — `StateDiffer.diff(a,b)`/`patch(s,d)`/
|
||||
`isEmpty`/`mergeDeltas`. Three-op grammar (`add | remove | replace`).
|
||||
Symmetric round-trip on plain JSON shapes.
|
||||
- ADDED: `src/state/delta-compressor/` — `DeltaCompressor.compress`/
|
||||
`compressSelective`/`batchCompress`/`decompress`/`estimateSavings`.
|
||||
UTF-8 JSON payload, Zod-validated on receipt, versioned wire format.
|
||||
Three OTel instruments.
|
||||
- ADDED: `src/state/state-sync/` — `StateSync<TState>.sync`/`syncFull`/
|
||||
`requestFullSync` over an injectable `StateSyncTransport`. Automatic
|
||||
full-snapshot fallback when delta exceeds `maxDeltaSize`. Three OTel
|
||||
instruments.
|
||||
- MODIFIED: `errors/game-server-error.ts` — six new codes:
|
||||
`STATE_DIFF_FAILED`, `STATE_PATCH_FAILED`, `STATE_RESTORE_FAILED`,
|
||||
`INVALID_SNAPSHOT`, `STATE_DECOMPRESS_FAILED`,
|
||||
`STATE_SYNC_TRANSPORT_FAILED`.
|
||||
- MODIFIED: `package.json` — four new subpath exports
|
||||
(`./state/state-manager`, `./state/state-differ`,
|
||||
`./state/delta-compressor`, `./state/state-sync`).
|
||||
|
||||
## Out of scope (other waves)
|
||||
|
||||
- `src/network/` — Wave B-other (transport implementations).
|
||||
- `src/engine/`, `src/core/` — Wave A.
|
||||
- Top-level `src/index.ts` consolidation — orchestrator wave.
|
||||
@@ -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"`.
|
||||
79
openspec/archive/2026-05-24-game-server-state/tasks.md
Normal file
79
openspec/archive/2026-05-24-game-server-state/tasks.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Tasks: Wave B-3 game-server state
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [x] `pwd && git worktree list && git status` — confirm isolated worktree, clean baseline.
|
||||
- [x] Read `CLAUDE.md`, `openspec/AGENTS.md`, existing `src/core`, `src/errors`, `src/types`, `src/engine/di-container`.
|
||||
- [x] Read upstream reference: `/mnt/s/web/suaveplan/packages/universal/game-server/src/state/{StateManager,StateDiffer,DeltaCompressor,StateSync}.ts`.
|
||||
|
||||
## 1. Error registry
|
||||
|
||||
- [x] Add six codes to `GAME_SERVER_CODES` (`STATE_DIFF_FAILED`,
|
||||
`STATE_PATCH_FAILED`, `STATE_RESTORE_FAILED`, `INVALID_SNAPSHOT`,
|
||||
`STATE_DECOMPRESS_FAILED`, `STATE_SYNC_TRANSPORT_FAILED`).
|
||||
- [x] Extend `game-server-error.test.ts` with assertions on the six new codes.
|
||||
- [x] Update `game-server-error.md` to enumerate the new codes.
|
||||
|
||||
## 2. `state-differ`
|
||||
|
||||
- [x] `state-differ.types.ts` — `DiffOperation`, `StateDelta`, Zod schemas.
|
||||
- [x] `state-differ.ts` — `diff`/`patch`/`isEmpty`/`mergeDeltas`,
|
||||
injectable `Clock`, typed errors.
|
||||
- [x] `state-differ.test.ts` — 100% coverage incl. round-trip,
|
||||
nested arrays, class-instance opaqueness, typed-error paths.
|
||||
- [x] `state-differ.md` — six sections, ≥ 200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 3. `delta-compressor`
|
||||
|
||||
- [x] `delta-compressor.types.ts` — `CompressedDelta`, `CompressionStats`,
|
||||
Zod payload schema, `COMPRESSED_DELTA_VERSION`.
|
||||
- [x] `delta-compressor.ts` — `compress`/`compressSelective`/`decompress`/
|
||||
`batchCompress`/`estimateSavings`, three OTel instruments.
|
||||
- [x] `delta-compressor.test.ts` — 100% coverage incl. version-mismatch,
|
||||
malformed JSON, schema-invalid payload, selective-prefix matching,
|
||||
divide-by-zero guard.
|
||||
- [x] `delta-compressor.md` — six sections, ≥ 200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 4. `state-manager`
|
||||
|
||||
- [x] `state-manager.types.ts` — `StateChangeEvent`, `StateChangeListener`,
|
||||
`StateManagerStats`.
|
||||
- [x] `state-manager.ts` — full API with deep-frozen reads, bounded
|
||||
history, injectable `Clock`/`IdGenerator`, three OTel instruments,
|
||||
typed `restore` validator path.
|
||||
- [x] `state-manager.test.ts` — 100% coverage incl. listener isolation,
|
||||
history trim, typed-error restore, default ID/clock fallback.
|
||||
- [x] `state-manager.md` — six sections, ≥ 200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 5. `state-sync`
|
||||
|
||||
- [x] `state-sync.types.ts` — `StateSyncMessage` discriminated union,
|
||||
`StateSyncTransport`, `SyncStats`, `StateSyncConfig`.
|
||||
- [x] `state-sync.ts` — `sync`/`syncFull`/`requestFullSync`/`getStats`/
|
||||
`resetStats`/`dispose`, automatic full-snapshot fallback, three OTel
|
||||
instruments.
|
||||
- [x] `state-sync.test.ts` — 100% coverage with in-process
|
||||
`StateSyncTransport`, includes transport-error wrapping, malformed
|
||||
full-payload, request/full handshake.
|
||||
- [x] `state-sync.md` — six sections, ≥ 200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 6. Package wiring
|
||||
|
||||
- [x] Extend `package.json` exports with four new subpaths.
|
||||
- [x] Leave `src/index.ts` untouched (orchestrator wave consolidates).
|
||||
|
||||
## 7. Verification gates
|
||||
|
||||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/game-server` — green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/game-server` — 100/100/100/100.
|
||||
- [ ] `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/gaming/games/game-server/src/` — empty.
|
||||
- [ ] `grep -rn "biome-ignore\|TODO\|FIXME\|XXX" packages/gaming/games/game-server/src/` — empty.
|
||||
|
||||
## 8. Ship
|
||||
|
||||
- [ ] Single commit on `worktree-agent-*` branch with the message specified
|
||||
in the brief. No push, no `--no-verify`.
|
||||
Reference in New Issue
Block a user