Initial Commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Wave B-5a: `@suaveplan/game-server` matchmaking slice
|
||||
|
||||
**Package**: `@suaveplan/game-server`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature-add (additive to existing 0.1.0 package)
|
||||
|
||||
## Why
|
||||
|
||||
Wave A landed the `@suaveplan/game-server` core foundation (DI
|
||||
container, error registry, Zod-validated boundary types). Five parallel
|
||||
waves now extend the package along orthogonal axes; B-5 owns the
|
||||
**matchmaking + performance** axis. This change covers the matchmaking
|
||||
half; a sibling change (`game-server-performance`) covers pooling and
|
||||
network-tier adapter.
|
||||
|
||||
The upstream reference
|
||||
(`/mnt/s/web/suaveplan/packages/universal/game-server/src/matchmaking/`)
|
||||
ships five modules — `MatchmakingEngine`, `QueueManager`,
|
||||
`SpectatorManager`, `skill-matcher`, `winner-stays-on` — but couples
|
||||
them to upstream-only types (`Logger`, `GameEventEmitter`,
|
||||
`AbstractStorage`) and emits events that do not appear in the genesis
|
||||
`GameEvents` map. This slice ports the algorithms, replaces the
|
||||
upstream couplings with `@suaveplan/types` (`Clock`, `IdGenerator`,
|
||||
`Rng`) plus typed observer callbacks, and adds OTel-native
|
||||
instrumentation that the upstream lacked.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `src/matchmaking/queue-manager/` — priority-queue manager with
|
||||
premium / wait-time / explicit-offset priority math, configurable
|
||||
`maxQueueSize`, four OTel instruments, Zod-validated boundary, typed
|
||||
`MATCHMAKING_QUEUE_FULL` / `INVALID_QUEUE_REQUEST` errors.
|
||||
- ADDED: `src/matchmaking/skill-matcher/` — Elo-style matcher with
|
||||
pluggable scoring fn, base + wait-time range, injectable `Clock` and
|
||||
`Rng`, two OTel instruments.
|
||||
- ADDED: `src/matchmaking/spectator-manager/` — per-session spectator
|
||||
roster with cap enforcement, three OTel counters, Zod-validated
|
||||
permissions, `onSpectatorJoined` / `onSpectatorLeft` callbacks.
|
||||
- ADDED: `src/matchmaking/winner-stays-on/` — winner-stays-on strategy
|
||||
with champion / streak / challenger-queue tracking, two OTel
|
||||
counters, configurable streak cap.
|
||||
- ADDED: `src/matchmaking/matchmaking-engine/` — top-level façade
|
||||
composing the above; `createMatch(players, criteria, algorithm)` +
|
||||
`createMatchFromQueue(queueKey, criteria, algorithm)` (drain + match
|
||||
+ restore on shortfall); `quick` and `skill-based` algorithms;
|
||||
injectable `IdGenerator`; three OTel instruments.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `openspec/specs/game-server/spec.md` (game-server
|
||||
capability).
|
||||
- New error codes: `MATCHMAKING_QUEUE_FULL`, `INVALID_QUEUE_REQUEST`,
|
||||
`INVALID_MATCH_CRITERIA`, `NO_MATCH_FOUND`, `INVALID_SKILL_SCORE`,
|
||||
`SPECTATOR_LIMIT_REACHED`, `INVALID_SPECTATOR_REQUEST`,
|
||||
`UNKNOWN_MATCHING_ALGORITHM`.
|
||||
- New subpath exports: `./matchmaking/{queue-manager, skill-matcher,
|
||||
spectator-manager, winner-stays-on, matchmaking-engine}`.
|
||||
- No `core/` / `state/` / `engine/` / `network/` modifications.
|
||||
@@ -0,0 +1,126 @@
|
||||
# game-server — matchmaking spec delta (Wave B-5a)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: QueueManager primary-priority queue
|
||||
|
||||
`QueueManager` SHALL maintain an in-memory priority queue keyed by an
|
||||
arbitrary string (typically a game mode or queue type) and prioritise
|
||||
entries by a documented mix of premium bonus, wait-time bonus, and an
|
||||
optional caller-supplied base offset.
|
||||
|
||||
#### Scenario: addToQueue accepts a valid request
|
||||
|
||||
- **WHEN** a caller invokes `addToQueue("ranked", {...validRequest})`
|
||||
- **THEN** the request is inserted into the queue and the queue's
|
||||
size grows by one
|
||||
- **AND** the `game_server.matchmaking.queue_enqueue_total` counter is
|
||||
incremented
|
||||
|
||||
#### Scenario: addToQueue rejects when the queue is at capacity
|
||||
|
||||
- **GIVEN** a `QueueManager` configured with `maxQueueSize: 1`
|
||||
- **AND** one request has been accepted
|
||||
- **WHEN** a second request is submitted
|
||||
- **THEN** `addToQueue` throws
|
||||
`GameServerError(GAME_SERVER.MATCHMAKING_QUEUE_FULL)`
|
||||
- **AND** the `game_server.matchmaking.queue_reject_total{reason=full}`
|
||||
counter is incremented
|
||||
|
||||
#### Scenario: findMatch atomically pops the top N entries
|
||||
|
||||
- **GIVEN** three queued players
|
||||
- **WHEN** the caller invokes `findMatch("ranked", 2)`
|
||||
- **THEN** the top two entries (by priority) are returned in order
|
||||
- **AND** the queue's size shrinks by two
|
||||
- **AND** the wait-time histogram is sampled twice
|
||||
|
||||
### Requirement: SkillMatcher Elo-style closest-rating selection
|
||||
|
||||
`SkillMatcher` SHALL select the candidate closest in rating to the
|
||||
seeker, widening the acceptance band proportional to the seeker's
|
||||
waiting time and respecting a configurable hard ceiling.
|
||||
|
||||
#### Scenario: closest opponent is returned
|
||||
|
||||
- **GIVEN** a seeker rated 1500 and candidates rated 1400, 1510, 1700
|
||||
- **AND** the configured `ratingRange` is 500
|
||||
- **WHEN** the caller invokes `findMatch(seeker, candidates)`
|
||||
- **THEN** the matcher returns the 1510-rated candidate
|
||||
|
||||
#### Scenario: rejection on a non-finite rating
|
||||
|
||||
- **WHEN** the caller submits a `SkillEntry` whose rating is `NaN`
|
||||
- **THEN** `findMatch` throws
|
||||
`GameServerError(GAME_SERVER.INVALID_SKILL_SCORE)`
|
||||
|
||||
### Requirement: SpectatorManager per-session cap
|
||||
|
||||
`SpectatorManager` SHALL enforce a configurable per-session spectator
|
||||
cap and surface typed-error rejections on cap violation or duplicate
|
||||
spectator registration.
|
||||
|
||||
#### Scenario: cap rejection
|
||||
|
||||
- **GIVEN** a `SpectatorManager` configured with
|
||||
`maxSpectatorsPerSession: 1`
|
||||
- **AND** one spectator has been added to session "s1"
|
||||
- **WHEN** a second spectator is added to the same session
|
||||
- **THEN** `addSpectator` throws
|
||||
`GameServerError(GAME_SERVER.SPECTATOR_LIMIT_REACHED)`
|
||||
|
||||
#### Scenario: duplicate spectator rejection
|
||||
|
||||
- **GIVEN** a spectator "alice" already attached to session "s1"
|
||||
- **WHEN** `addSpectator("s1", "alice", perms)` is invoked again
|
||||
- **THEN** the call throws
|
||||
`GameServerError(GAME_SERVER.INVALID_SPECTATOR_REQUEST)`
|
||||
|
||||
### Requirement: WinnerStaysOnManager streak tracking
|
||||
|
||||
`WinnerStaysOnManager` SHALL track the current champion, their
|
||||
consecutive win streak, and a FIFO challenger queue, and expose an
|
||||
`isChampionRotationDue` flag once the streak meets `maxWinStreak`.
|
||||
|
||||
#### Scenario: throne transfer
|
||||
|
||||
- **GIVEN** "alice" recorded as winner against "bob"
|
||||
- **WHEN** the manager records "bob" as winner against "alice"
|
||||
- **THEN** the champion becomes "bob" with `winStreak === 1`
|
||||
- **AND** "alice" is enqueued at the back of the challenger list
|
||||
|
||||
### Requirement: MatchmakingEngine façade
|
||||
|
||||
`MatchmakingEngine` SHALL expose `createMatch(players, criteria,
|
||||
algorithm)` and `createMatchFromQueue(queueKey, criteria, algorithm)`
|
||||
that pick the algorithm, validate inputs via Zod, mint a stable
|
||||
`matchId` via an injected `IdGenerator`, and notify any registered
|
||||
`onMatchFound` listener.
|
||||
|
||||
#### Scenario: quick algorithm matches FIFO
|
||||
|
||||
- **GIVEN** three queued players and `MatchCriteria` of
|
||||
`{ minPlayers: 2, maxPlayers: 4 }`
|
||||
- **WHEN** `createMatch(players, criteria, "quick")` is invoked
|
||||
- **THEN** the engine returns a `Match` containing all three players
|
||||
|
||||
#### Scenario: skill-based algorithm uses the largest viable group
|
||||
|
||||
- **GIVEN** four players with ratings 1000, 1100, 1200, 2500
|
||||
- **AND** `MatchCriteria` of `{ minPlayers: 2, maxPlayers: 4, skillRange: 250 }`
|
||||
- **WHEN** `createMatch(players, criteria, "skill-based")` is invoked
|
||||
- **THEN** the engine returns the three rated 1000/1100/1200
|
||||
|
||||
#### Scenario: unknown algorithm rejection
|
||||
|
||||
- **WHEN** the caller passes `algorithm: "nuke"`
|
||||
- **THEN** `createMatch` throws
|
||||
`GameServerError(GAME_SERVER.UNKNOWN_MATCHING_ALGORITHM)`
|
||||
|
||||
#### Scenario: createMatchFromQueue restores on shortfall
|
||||
|
||||
- **GIVEN** a `QueueManager` with one queued player and
|
||||
`MatchCriteria` of `{ minPlayers: 2, maxPlayers: 4 }`
|
||||
- **WHEN** `createMatchFromQueue("ranked", criteria)` is invoked
|
||||
- **THEN** the call returns `null`
|
||||
- **AND** the queue's size remains 1
|
||||
94
openspec/archive/2026-05-24-game-server-matchmaking/tasks.md
Normal file
94
openspec/archive/2026-05-24-game-server-matchmaking/tasks.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Tasks: Wave B-5a game-server matchmaking
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [x] `pwd && git worktree list && git status` — confirm isolated worktree.
|
||||
- [x] Confirm worktree HEAD is descended from genesis tip (Wave A + B-1/3/4 merged).
|
||||
- [x] Read `CLAUDE.md`, `openspec/AGENTS.md`, existing `src/core`, `src/errors`, `src/types`.
|
||||
- [x] Read upstream reference:
|
||||
`/mnt/s/web/suaveplan/packages/universal/game-server/src/matchmaking/{MatchmakingEngine,QueueManager,SpectatorManager,skill-matcher,winner-stays-on}.ts`.
|
||||
|
||||
## 1. Error registry
|
||||
|
||||
- [x] Append eight matchmaking codes to `GAME_SERVER_CODES`
|
||||
(`MATCHMAKING_QUEUE_FULL`, `INVALID_QUEUE_REQUEST`,
|
||||
`INVALID_MATCH_CRITERIA`, `NO_MATCH_FOUND`, `INVALID_SKILL_SCORE`,
|
||||
`SPECTATOR_LIMIT_REACHED`, `INVALID_SPECTATOR_REQUEST`,
|
||||
`UNKNOWN_MATCHING_ALGORITHM`).
|
||||
- [x] Extend `game-server-error.test.ts` with assertions on the new codes.
|
||||
- [x] Update `game-server-error.md` to enumerate the new codes.
|
||||
|
||||
## 2. `winner-stays-on`
|
||||
|
||||
- [x] `winner-stays-on.types.ts` — `WinnerStaysOnConfig`,
|
||||
`WinnerStaysOnStats`, Zod config schema.
|
||||
- [x] `winner-stays-on.ts` — `recordResult`/`addChallenger`/
|
||||
`nextChallenger`/`forceRotateChampion`/`reset`/`getStats`, two OTel
|
||||
counters, typed-error boundary.
|
||||
- [x] `winner-stays-on.test.ts` — 100% coverage incl. error paths.
|
||||
- [x] `winner-stays-on.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 3. `skill-matcher`
|
||||
|
||||
- [x] `skill-matcher.types.ts` — `SkillEntry`, `SkillMatcherConfig`,
|
||||
`SkillScoringFn`, Zod schemas.
|
||||
- [x] `skill-matcher.ts` — `findMatch`/`effectiveRange`, pluggable
|
||||
scoring, injectable `Clock`/`Rng`, OTel counter + histogram.
|
||||
- [x] `skill-matcher.test.ts` — 100% coverage incl. tie-break paths.
|
||||
- [x] `skill-matcher.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 4. `queue-manager`
|
||||
|
||||
- [x] `queue-manager.types.ts` — `QueueRequest`, `QueueConfig`,
|
||||
`QueueStats`, Zod schemas.
|
||||
- [x] `queue-manager.ts` — `addToQueue`/`removeFromQueue`/`findMatch`/
|
||||
`getQueueSize`/`getQueuePosition`/`getStats`/`clear`, four OTel
|
||||
instruments, typed-error boundary.
|
||||
- [x] `queue-manager.test.ts` — 100% coverage incl. cap rejection +
|
||||
wait-time priority.
|
||||
- [x] `queue-manager.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 5. `spectator-manager`
|
||||
|
||||
- [x] `spectator-manager.types.ts` — `Spectator`,
|
||||
`SpectatorPermissions`, `SpectatorManagerConfig`, Zod schemas.
|
||||
- [x] `spectator-manager.ts` — `addSpectator`/`removeSpectator`/
|
||||
`removeAllSpectators`/getters, three OTel counters, typed-error
|
||||
boundary, optional join/leave listeners.
|
||||
- [x] `spectator-manager.test.ts` — 100% coverage incl. cap rejection +
|
||||
duplicate spectator rejection.
|
||||
- [x] `spectator-manager.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 6. `matchmaking-engine`
|
||||
|
||||
- [x] `matchmaking-engine.types.ts` — `Match`, `MatchCriteria`,
|
||||
`MatchingAlgorithm`, Zod schemas.
|
||||
- [x] `matchmaking-engine.ts` — `createMatch` (quick / skill-based) +
|
||||
`createMatchFromQueue` (drain with max→min fallback) + `getMatch` /
|
||||
`removeMatch` / `dispose`, three OTel instruments, injectable
|
||||
`IdGenerator`/`Clock`/`Rng`, optional `onMatchFound` callback.
|
||||
- [x] `matchmaking-engine.test.ts` — 100% coverage incl. unknown
|
||||
algorithm + invalid criteria + skill-range edge cases.
|
||||
- [x] `matchmaking-engine.md` — six sections, ≥200 words.
|
||||
- [x] `index.ts` barrel.
|
||||
|
||||
## 7. Wiring
|
||||
|
||||
- [x] Re-export every public surface from `src/index.ts`.
|
||||
- [x] Add subpath exports to `package.json` for each module.
|
||||
|
||||
## 8. Verification
|
||||
|
||||
- [x] `bunx turbo lint typecheck test --filter=@suaveplan/game-server` green.
|
||||
- [x] `bunx turbo test:coverage --filter=@suaveplan/game-server` =
|
||||
100/100/100/100.
|
||||
- [x] `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/gaming/games/game-server/src/`
|
||||
→ zero output.
|
||||
- [x] `grep -rn "biome-ignore|TODO|FIXME|XXX"
|
||||
packages/gaming/games/game-server/src/matchmaking` → zero output.
|
||||
- [x] All source files ≤300 lines; all test files ≤500 lines.
|
||||
- [x] `bun run docs:check` reports 100% coverage.
|
||||
Reference in New Issue
Block a user