Initial Commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
# Wave C-3: `@suaveplan/game-server` enhanced content slice
|
||||
|
||||
**Package**: `@suaveplan/game-server`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature-add (additive to existing 0.1.0 package)
|
||||
|
||||
## Why
|
||||
|
||||
Waves A and B landed the core foundation, engine, sessions, state,
|
||||
networking, matchmaking, and performance subsystems of
|
||||
`@suaveplan/game-server`. Wave C extends the package along the content
|
||||
axis with the four subsystems game operators consume directly:
|
||||
leaderboards, save persistence, replay capture, and tournament brackets.
|
||||
|
||||
The upstream reference
|
||||
(`/mnt/s/web/suaveplan/packages/universal/game-server/src/enhanced/`)
|
||||
ships these as `EventEmitter`-backed managers coupled to upstream `Date.now`
|
||||
calls, hand-rolled id generators, and an upstream-specific `StorageAdapter`
|
||||
contract. This slice ports the algorithms, replaces the couplings with the
|
||||
genesis-canonical `Clock`, `IdGenerator`, `Rng`, and `StorageLike`
|
||||
abstractions from `@suaveplan/types`, and adds Zod-validated boundaries +
|
||||
OTel-native instrumentation that the upstream lacked.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `src/enhanced/leaderboard/leaderboard-manager/` — multi-board
|
||||
manager with `score` / `wins` / `elo` ranking modes, per-board
|
||||
`maxEntries` rank-based eviction, injectable `Clock`, two OTel
|
||||
counters, four typed errors (`LEADERBOARD_NOT_FOUND`,
|
||||
`LEADERBOARD_ENTRY_INVALID`, `INVALID_LEADERBOARD_CONFIG`,
|
||||
`INVALID_SCORE_SUBMISSION`).
|
||||
- ADDED: `src/enhanced/persistence/game-persistence/` — JSON-over-`StorageLike`
|
||||
save manager with bounded snapshots and audit-trail history, injectable
|
||||
`Clock` + `IdGenerator`, two OTel counters + duration histogram, four
|
||||
typed errors (`PERSISTENCE_SAVE_FAILED`, `PERSISTENCE_LOAD_FAILED`,
|
||||
`PERSISTENCE_NOT_FOUND`, `INVALID_SAVE_RECORD`).
|
||||
- ADDED: `src/enhanced/replay/replay-recorder/` — tick-driven input +
|
||||
snapshot recorder with pause/resume, configurable auto-snapshot
|
||||
interval, capped input buffer, injectable `Clock` + `IdGenerator`,
|
||||
two OTel counters, four typed errors (`REPLAY_NOT_LOADED`,
|
||||
`REPLAY_PARSE_FAILED`, `REPLAY_RECORDING_STATE_INVALID`,
|
||||
`REPLAY_INVALID_SPEED`).
|
||||
- ADDED: `src/enhanced/replay/replay-player/` — tick-driven replay player
|
||||
with step/seek/speed controls, snapshot-aware backward step, loop
|
||||
mode, observer-style listeners, two OTel counters.
|
||||
- ADDED: `src/enhanced/tournament/tournament-manager/` — single-elim +
|
||||
round-robin brackets with bye propagation, deterministic seeded
|
||||
shuffling via injectable `Rng`, standings table with stable tie-break,
|
||||
one OTel counter (tagged), eight typed errors
|
||||
(`TOURNAMENT_INVALID_STATE`, `TOURNAMENT_FULL`,
|
||||
`TOURNAMENT_DUPLICATE_PARTICIPANT`,
|
||||
`TOURNAMENT_INSUFFICIENT_PARTICIPANTS`, `TOURNAMENT_MATCH_NOT_FOUND`,
|
||||
`TOURNAMENT_INVALID_RESULT`, `INVALID_BRACKET_SIZE`,
|
||||
`INVALID_BRACKET_TYPE`).
|
||||
- ADDED: 22 new `GAME_SERVER.*` error codes registered in `defineCodes`.
|
||||
- ADDED: five `package.json` subpath exports.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Double-elimination brackets — not requested by the dispatch spec and
|
||||
excluded to keep the manager under the 300-line hard limit. Can be
|
||||
added later as a sibling change without touching the public surface.
|
||||
- Wire serialisation (Replay export/compression, persistence CDC) —
|
||||
caller-side concern; the `ReplayData` document is JSON-clean.
|
||||
- React companion hooks — separate package (`@suaveplan/react-game-server`).
|
||||
|
||||
## Risks
|
||||
|
||||
- None new. All five subsystems are leaf modules that consume only the
|
||||
existing `errors/` + `types/` foundations plus the `@suaveplan/types`
|
||||
cross-cutting contracts.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bunx turbo lint typecheck test --filter=@suaveplan/game-server` green.
|
||||
- `bunx turbo test:coverage --filter=@suaveplan/game-server` at 100/100/100/100.
|
||||
- Zero `vitest`/`bun:test` direct imports under
|
||||
`src/enhanced/`.
|
||||
- Zero `// biome-ignore`, `TODO`, `FIXME`, `XXX` under `src/enhanced/`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,99 @@
|
||||
# Tasks: Wave C-3 — enhanced leaderboard + persistence + replay + tournament
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [x] `pwd && git worktree list && git status` in isolated worktree.
|
||||
- [x] §0a Sync: fast-forward worktree to genesis tip; confirm Wave-B
|
||||
matchmaking/performance available.
|
||||
- [x] Read CLAUDE.md, openspec/AGENTS.md, existing src structure.
|
||||
- [x] Read upstream reference under
|
||||
`/mnt/s/web/suaveplan/packages/universal/game-server/src/enhanced/`.
|
||||
|
||||
## 1. Error registry
|
||||
|
||||
- [x] Append 22 codes to `GAME_SERVER_CODES` (`LEADERBOARD_*`,
|
||||
`PERSISTENCE_*`, `REPLAY_*`, `TOURNAMENT_*`, `INVALID_BRACKET_*`).
|
||||
- [x] Extend `game-server-error.test.ts` with assertions on each new code.
|
||||
|
||||
## 2. Leaderboard
|
||||
|
||||
- [x] `leaderboard-manager.types.ts` — `RankingMode`, `LeaderboardConfig`,
|
||||
`ScoreSubmission`, `LeaderboardEntry`, `LeaderboardManagerConfig`,
|
||||
`LeaderboardStats`, Zod schemas.
|
||||
- [x] `leaderboard-manager.ts` — multi-board manager with score/wins/elo,
|
||||
ascending/descending, ranks, eviction, OTel counters, injectable
|
||||
`Clock`.
|
||||
- [x] `leaderboard-manager.test.ts` — 100% coverage incl. error paths.
|
||||
- [x] `leaderboard-manager.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 3. Persistence
|
||||
|
||||
- [x] `game-persistence.types.ts` — `SaveMetadata`, `Snapshot`,
|
||||
`HistoryEntry`, `SaveRecord`, `SaveResult`, `LoadResult`,
|
||||
`GamePersistenceConfig`, `PersistenceStats`, Zod schemas.
|
||||
- [x] `game-persistence.ts` — save / load / delete / snapshot / restore
|
||||
via injectable `StorageLike`; OTel counters + histogram; injectable
|
||||
`Clock` + `IdGenerator`.
|
||||
- [x] `game-persistence.test.ts` — 100% coverage incl. corrupt data,
|
||||
failing storage, history trim.
|
||||
- [x] `game-persistence.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 4. Replay recorder
|
||||
|
||||
- [x] `replay-recorder.types.ts` — `RecordingStatus`, `RecordedInput`,
|
||||
`RecordedSnapshot`, `ReplayMetadata`, `ReplayData`,
|
||||
`ReplayRecorderConfig`, `RecordingStats`, Zod schemas.
|
||||
- [x] `replay-recorder.ts` — start / pause / resume / tick / record /
|
||||
stop with monotonic sequence, auto-snapshot interval, paused-time
|
||||
accounting; injectable `Clock` + `IdGenerator`; OTel counters.
|
||||
- [x] `replay-recorder.test.ts` — 100% coverage incl. buffer overflow
|
||||
and FSM rejections.
|
||||
- [x] `replay-recorder.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 5. Replay player
|
||||
|
||||
- [x] `replay-player.types.ts` — `PlaybackStatus`, `PlaybackSpeed`,
|
||||
`PlaybackPosition`, `ReplayPlayerConfig`, Zod schemas.
|
||||
- [x] `replay-player.ts` — tick-driven player with step / seek / speed,
|
||||
snapshot-aware backward step, loop, observer listeners; OTel
|
||||
counters.
|
||||
- [x] `replay-player.test.ts` — 100% coverage incl. unloaded paths,
|
||||
unsupported version, end-of-replay.
|
||||
- [x] `replay-player.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 6. Tournament
|
||||
|
||||
- [x] `tournament-manager.types.ts` — `BracketType`, `TournamentStatus`,
|
||||
`MatchStatus`, `Participant`, `Match`, `Round`, `Standing`,
|
||||
`Tournament`, `TournamentConfig`, Zod schemas.
|
||||
- [x] `tournament-bracket.internal.ts` — pure bracket-builders
|
||||
(`buildSingleElim`, `buildRoundRobin`, `shuffleWithRng`,
|
||||
`padToPowerOfTwo`, `roundName`).
|
||||
- [x] `tournament-standings.internal.ts` — pure standings helpers
|
||||
(`applyStanding`, `markEliminated`, `findMatch`,
|
||||
`markRoundCompleteIfDone`).
|
||||
- [x] `tournament-manager.ts` — register / start / record / cancel with
|
||||
bye propagation, seeded shuffle via injectable `Rng`, injectable
|
||||
`Clock` + `IdGenerator`, OTel counter.
|
||||
- [x] `tournament-manager.test.ts` — 100% coverage via the public surface.
|
||||
- [x] `tournament-manager.md` + `tournament-bracket.internal.md` +
|
||||
`tournament-standings.internal.md` — six sections each, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 7. Package surface
|
||||
|
||||
- [x] Extend `package.json` `exports` with five new subpath blocks.
|
||||
- [x] No edits to `src/index.ts` (orchestrator wave consolidates).
|
||||
|
||||
## 8. Verification
|
||||
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/game-server` green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/game-server` at
|
||||
100/100/100/100 on touched modules.
|
||||
- [x] Zero `from 'vitest'` / `from 'bun:test'` under
|
||||
`src/enhanced/`.
|
||||
- [x] Zero `biome-ignore` / `TODO` / `FIXME` / `XXX` under `src/enhanced/`.
|
||||
Reference in New Issue
Block a user