7.9 KiB
Capability Spec Delta: game-server (game-server-enhanced-infra)
Package: @suaveplan/game-server
Status: PROPOSED
ADDED Requirements
Requirement: Game instance registry with TTL eviction and load-balancing
The package SHALL expose a GameInstanceRegistry<TCustom> class that registers live game-server instances, evicts them when their last heartbeat is older than instanceTTL milliseconds (as reported by an injected Clock), supports filter-based discovery, and selects one healthy instance per request using one of four load-balancing strategies (least-players, random, region-based, round-robin). All clock and randomness reads SHALL flow through injected Clock and Rng dependencies.
Scenario: register / discover / deregister round-trip
- WHEN the caller registers an instance, then calls
discover()with no filter - THEN the registered instance appears in the result, defaulted to
HEALTHY - WHEN the caller deregisters
- THEN the next
discover()returns an empty array
Scenario: heartbeat expiry triggers eviction
- WHEN the injected clock advances past
instanceTTL * 2since the last heartbeat - THEN the registry transitions the instance to
UNHEALTHYfirst, then deregisters it on the second sweep - AND the corresponding
GameInstanceEvent.HEALTH_CHANGEDandGameInstanceEvent.DEREGISTEREDevents fire
Scenario: least-players strategy picks lowest player count
- WHEN two healthy instances exist with
playerCount = 5andplayerCount = 2 - THEN
selectInstance(..., 'least-players')returns the second one withscore = 2
Scenario: region-based strategy honours preferred region
- WHEN the caller passes
filter.region = "eu-west"and at least one EU instance exists - THEN the selected instance has
metadata.region === "eu-west"
Scenario: round-robin rotates across calls
- WHEN three instances are available and
selectInstance(..., 'round-robin')is called three times - THEN each call returns a different instance, then the rotation repeats
Verified by: src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"registers and discovers an instance" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"evicts after TTL expiry" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"least-players selects lowest count" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"region-based prefers configured region" AND src/enhanced/service-discovery/game-instance-registry/game-instance-registry.test.ts::"round-robin rotates across calls"
Requirement: BitPacker-backed binary state synchronisation
The package SHALL expose a BitPackerStateSync<TState, TSchema extends BitPackerSchema> class that packs TState snapshots to Uint8Array via @suaveplan/bitpacker, supports client-side prediction with a bounded history window, supports server reconciliation against a tick-tagged authoritative state, supports linear interpolation between two history snapshots, and aggregates wire-byte counters into a SyncStats view. All timestamps SHALL flow through an injected Clock. Pack/unpack failures SHALL throw GameServerError with BITPACK_ENCODE_FAILED / BITPACK_DECODE_FAILED.
Scenario: pack then unpack round-trip preserves state
- WHEN the caller packs a
TStatevalue and unpacks the resultingUint8Array - THEN the unpacked value equals the original
Scenario: prediction records a history frame
- WHEN
predict(state, input, simulate)is called - THEN the returned state equals
simulate(state, input.data) - AND
getStats().predictionsMadeincrements by one
Scenario: reconciliation re-applies pending inputs above server tick
- WHEN the client has predicted three frames at ticks 11, 12, 13 and the server delivers a state at tick 10
- AND the client's snapshot at tick 10 differs from the server state beyond the threshold
- THEN
reconcile(...)returnsneeded: truewithframesResimulated = 3and a state derived from re-applying the three pending inputs
Scenario: interpolation lerps between snapshots
- WHEN the caller passes two snapshots at timestamps 100 and 200 and a render time bracketing the targetTime
- THEN the returned state equals
lerp(before, after, t)for somet ∈ [0, 1]
Scenario: bitpack failure routes to typed error
- WHEN
BitPacker.packthrows - THEN
packStatere-throws aGameServerErrorcarrying codeGAME_SERVER.BITPACK_ENCODE_FAILED
Verified by: src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"packs and unpacks state" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"prediction records a history frame" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"reconciliation re-applies pending inputs above the server tick" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"interpolation lerps between two snapshots" AND src/enhanced/state-sync/bitpacker-state-sync/bitpacker-state-sync.test.ts::"throws BITPACK_ENCODE_FAILED when bitpacker throws"
Requirement: Vector-clock distributed action ordering
The package SHALL expose a VectorClockSync<TEventData> class that records game actions per session, tags each action with a vector-clock snapshot, detects concurrent actions, resolves conflicts via one of three strategies (last-write-wins, merge, custom), produces a causality chain in happens-before order, and exposes request/response sync round-trips between nodes. The internal vector clock SHALL be the createVectorClock primitive from @suaveplan/timing. Action IDs SHALL be minted through an injected IdGenerator; timestamps SHALL flow through an injected Clock.
Scenario: recordAction tags with current vector clock
- WHEN
recordAction(sessionId, "move", data, tick)is called - THEN the returned
GameAction.vectorClockcarries this node's counter at >= 1 - AND
getStats().totalActionsequals the count of recordings
Scenario: receiveAction detects concurrent conflict
- WHEN two actions are recorded by different nodes with vector clocks that are mutually concurrent (
compare === "concurrent") - THEN
receiveAction(remote)returns aConflictResolutionwithconflictDetected: true - AND the chosen winner under
last-write-winsis the one with the larger timestamp
Scenario: custom resolver overrides default strategy
- WHEN the registry is constructed with a
conflictResolver - THEN the resolver function is called with each (winner, candidate) pair and its return value becomes the resolved action
- AND
ConflictResolution.strategy === "custom"
Scenario: causality chain sorts by happens-before
- WHEN action A is recorded, then action B is recorded on the same node
- THEN
getCausalityChain(sessionId).actions[0]is A and[1]is B
Scenario: sync request returns missing actions
- WHEN node X has recorded actions [a1, a2] and node Y has recorded none
- AND Y issues
requestSync(X, sessionId)with its current (empty) vector clock - THEN X's
handleSyncRequest(request)returns a response carrying botha1anda2
Verified by: src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"recordAction tags with the current vector clock" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"receiveAction detects concurrent conflict and resolves last-write-wins" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"custom resolver overrides default strategy" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"causality chain sorts by happens-before" AND src/enhanced/timing/vector-clock-sync/vector-clock-sync.test.ts::"sync request returns missing actions"