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,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/`.

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

View File

@@ -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/`.