6.5 KiB
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_totalOTel 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
restoreis invoked with a snapshot the validator rejects - THEN a
GameServerErrorwith codeGAME_SERVER.INVALID_SNAPSHOTis thrown andgetState()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
GameServerErrorwith codeGAME_SERVER.STATE_RESTORE_FAILEDis 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 valuesaandb - 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
GameServerErrorwith codeGAME_SERVER.STATE_DIFF_FAILEDorGAME_SERVER.STATE_PATCH_FAILEDrespectively.
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 withdecompress(a, packet) - THEN the result equals
b.
Scenario: version mismatch raises typed error
- WHEN
decompressreceives a packet whoseversiondiffers fromCOMPRESSED_DELTA_VERSION - THEN a
GameServerErrorwith codeGAME_SERVER.STATE_DECOMPRESS_FAILEDis thrown.
Scenario: schema-invalid payload raises typed error
- WHEN
decompressreceives a packet whose data parses to a value that fails the operation schema - THEN a
GameServerErrorwith codeGAME_SERVER.STATE_DECOMPRESS_FAILEDis 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-constructedStateSyncwith an interveningStateManager.update - THEN the first transport message has
kind === "full"and the second haskind === "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.sendrejects or throws - THEN a
GameServerErrorwith codeGAME_SERVER.STATE_SYNC_TRANSPORT_FAILEDpropagates 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".