Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
# 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