Files
2026-05-27 23:29:53 +01:00

6.7 KiB

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