# game-server — enhanced content spec delta (Wave C-3) ## ADDED Requirements ### Requirement: LeaderboardManager multi-board ranking `LeaderboardManager` SHALL maintain independent leaderboards keyed by string id, each configured with one of three ranking modes (`score` / `wins` / `elo`) and an optional `maxEntries` cap. #### Scenario: submitScore inserts and re-ranks - **GIVEN** a leaderboard `"global"` configured for `score` mode - **WHEN** the caller invokes `submitScore("global", { playerId: "alice", displayName: "Alice", score: 100 })` - **THEN** Alice's entry has `rank === 1` - **AND** the `game_server.leaderboard.submit_total` counter is incremented #### Scenario: maxEntries evicts the lowest-ranked entry - **GIVEN** a leaderboard with `maxEntries: 2` and entries `(a:10, b:20, c:30)` - **WHEN** all three submissions complete - **THEN** the leaderboard retains only two entries - **AND** the lowest-ranked entry (`a`) has been evicted #### Scenario: submitScore rejects an unknown leaderboard - **WHEN** the caller invokes `submitScore("missing", ...)` - **THEN** `GameServerError(GAME_SERVER.LEADERBOARD_NOT_FOUND)` is thrown #### Scenario: Elo update follows the standard formula - **GIVEN** a leaderboard configured for `elo` mode with `initialElo: 1200` - **WHEN** a player submits `{ isWin: true, opponentElo: 1200 }` - **THEN** the player's Elo strictly increases above 1200 ### Requirement: GamePersistence StorageLike-backed saves `GamePersistence` SHALL persist game state through any conforming `StorageLike` implementation, encoding `SaveRecord` payloads as UTF-8 JSON under the key `game-server:save::`. #### Scenario: save writes a record and returns its id - **WHEN** the caller invokes `save(state, { name: "quicksave" })` - **THEN** the result's `success === true` - **AND** subsequent `load(id)` returns the same `state` - **AND** the `game_server.persistence.save_total{outcome=ok}` counter is incremented #### Scenario: load() of unknown id throws PERSISTENCE_NOT_FOUND - **WHEN** the caller invokes `load("missing")` - **THEN** `GameServerError(GAME_SERVER.PERSISTENCE_NOT_FOUND)` is thrown #### Scenario: createSnapshot rotates past maxSnapshots - **GIVEN** a persistence configured with `maxSnapshots: 2` - **WHEN** three snapshots are created in sequence - **THEN** only the most recent two are retained on the record #### Scenario: corrupt storage throws PERSISTENCE_LOAD_FAILED - **GIVEN** a `StorageLike` returning invalid JSON bytes - **WHEN** the caller invokes `load(id)` - **THEN** `GameServerError(GAME_SERVER.PERSISTENCE_LOAD_FAILED)` is thrown ### Requirement: ReplayRecorder deterministic input + snapshot capture `ReplayRecorder` SHALL record inputs with monotonic `sequence` numbers and periodic state snapshots into an in-memory buffer that is emitted as a `ReplayData` document on `stop()`. #### Scenario: start from non-idle is rejected - **GIVEN** a recorder in `recording` status - **WHEN** the caller invokes `start(initialState)` - **THEN** `GameServerError(GAME_SERVER.REPLAY_RECORDING_STATE_INVALID)` is thrown #### Scenario: recordInput respects buffer cap - **GIVEN** a recorder configured with `maxInputBuffer: 1` - **AND** one input recorded - **WHEN** the caller invokes `recordInput(input)` - **THEN** `GameServerError(GAME_SERVER.REPLAY_RECORDING_STATE_INVALID)` is thrown #### Scenario: tick auto-snapshots at the configured interval - **GIVEN** a recorder with `snapshotInterval: 2` - **WHEN** `tick(state)` is invoked twice with a state - **THEN** the buffer contains at least one snapshot with `reason === "interval"` #### Scenario: pause/resume excludes paused interval from elapsed time - **GIVEN** a recorder started at clock 1000 - **AND** paused at clock 2000 then resumed at clock 5000 - **WHEN** elapsed time is read - **THEN** the elapsed time equals 1000 (the unpaused gameplay duration) ### Requirement: ReplayPlayer tick-driven deterministic playback `ReplayPlayer` SHALL load `ReplayData` documents and advance playback exclusively in response to `tick()` calls, applying the configured `PlaybackSpeed` multiplier to each tick. #### Scenario: play before load throws REPLAY_NOT_LOADED - **WHEN** the caller invokes `play()` without a loaded replay - **THEN** `GameServerError(GAME_SERVER.REPLAY_NOT_LOADED)` is thrown #### Scenario: tick delivers replayed inputs to listeners - **GIVEN** a player with a loaded replay containing two inputs - **AND** an `onInput` listener subscribed - **WHEN** `tick()` is invoked enough times to reach the second input's tick - **THEN** the listener receives both inputs in `sequence` order #### Scenario: setSpeed rejects values outside the canonical set - **WHEN** the caller invokes `setSpeed(7)` - **THEN** `GameServerError(GAME_SERVER.REPLAY_INVALID_SPEED)` is thrown #### Scenario: loop=true rewinds at end - **GIVEN** a player configured with `loop: true` - **AND** a replay of 3 ticks - **WHEN** `tick()` is invoked 5 times during playback - **THEN** status remains `"playing"` and the cursor wraps to ≤ 3 ### Requirement: TournamentManager bracket generation + advancement `TournamentManager` SHALL accept participants in the `pending` phase, build a single-elimination or round-robin bracket on `start()`, and advance winners through `nextMatchId` links as results are recorded. #### Scenario: start requires at least 2 participants - **GIVEN** a tournament with one registered participant - **WHEN** the caller invokes `start()` - **THEN** `GameServerError(GAME_SERVER.TOURNAMENT_INSUFFICIENT_PARTICIPANTS)` is thrown #### Scenario: single-elimination propagates first-round byes - **GIVEN** a single-elimination tournament with 3 participants - **WHEN** `start()` is invoked - **THEN** the first round contains at least one match with `status === "bye"` - **AND** the surviving participant is advanced into the next round #### Scenario: round-robin completes after every match recorded - **GIVEN** a round-robin tournament with 4 participants - **WHEN** every scheduled match has a recorded result - **THEN** the tournament status transitions to `"completed"` - **AND** `getWinner()` returns the standings leader #### Scenario: recordResult rejects a non-participant winner - **GIVEN** an in-progress match between `a` and `b` - **WHEN** the caller invokes `recordResult(matchId, "z")` - **THEN** `GameServerError(GAME_SERVER.TOURNAMENT_INVALID_RESULT)` is thrown #### Scenario: shuffleSeeds uses injected Rng deterministically - **GIVEN** a tournament configured with `shuffleSeeds: true` and a deterministic `Rng` - **WHEN** `start()` is invoked - **THEN** the participants are re-ordered using the `Rng`'s `nextIntInRange` and their `seed` fields are renumbered 1..N