174 lines
6.7 KiB
Markdown
174 lines
6.7 KiB
Markdown
# 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:<gameId>:<id>`.
|
|
|
|
#### 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<TState, TInput>` 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<TState, TInput>` 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
|