feat: real-time multiplayer race mode (free, prestige, crypto)

Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
  Disabled by default; enable with MATCH_ENABLED=true.

  Highlights:
  - New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
  - Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
  - MatchScheduler drains queues on every new block and starts 2–32 player races.
  - MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
    forfeit/AFK handling, and end-of-match persistence.
  - Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
    payout is queued through the existing batch payout/retry service.
  - Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
    crypto → Hall of Champions.
  - Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
  - Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
  - Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
    race_entry_transactions, users.race_entries, and payouts.match_id.

  Docs:
  - New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
  - Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
    and src/.env.

  Tests: 40 new unit tests across 7 suites, all passing.

  Known follow-ups for real-world validation:
  - Full integration test with Postgres + Socket.IO.
  - Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
  - Browser render-kit verification in /match.html and tavern spectator.
  - Crypto-race payout end-to-end with real wallet-rpc.
This commit is contained in:
jwinterm
2026-07-09 13:26:52 -04:00
parent 8fa8b06b20
commit 0f68eec26b
31 changed files with 3963 additions and 8 deletions
+194
View File
@@ -0,0 +1,194 @@
# Match Mode — Race/PvP Multiplayer
> Status: **Implemented** — all five milestones complete. Single-player and Tavern modes are untouched.
Match mode adds real-time multiplayer races to Wownerogue. It reuses the existing shared-world
engine (`Room`/`Occupant`), the render kit, the payment/payout systems, and provably-fair seeding.
## Supported economies
| Economy | Entry cost | Payout | Leaderboard |
|---|---|---|---|
| `free` | None | None | Pleb board |
| `credits_prestige` | Credits | None (prestige only) | Prestige board |
| `crypto_race` | Race-entry ticket | Winner-take-pot (minus house fee) | Hall of Champions |
Unredeemable credits are the same `users.credits` balance running with `CREDITS_PAYOUTS_ENABLED=false`;
there is no separate "fake credit" system. Crypto races use non-refundable **race-entry tickets**
(`users.race_entries`) so players never need an on-chain refund when leaving a queue before a match
starts.
## Lifecycle: a race every block
The existing solo `queueHandler` already reacts to new crypto blocks. `MatchScheduler` subscribes to
the same block event and drains per-economy queues:
- If a queue has ≥2 players at block H, all queued players are placed into a new `MatchRoom` and the
race starts immediately.
- If a queue has 1 player, they carry over to block H+1 and may leave at any time.
- If a queue is empty, nothing happens.
A race ends when:
1. The first player reaches the exit.
2. The next block is detected (with a `MATCH_MIN_DURATION_MS` floor to prevent degenerate short
races).
3. The `MATCH_HARD_CEILING_MS` absolute ceiling expires.
If no one escaped, living players are ranked by proximity to the exit; dead players are ranked after
them by progress, treasure, and moves.
## Architecture
### Engine layer (`src/multiplayer/`)
- `MatchRoom.js` — extends `Room`. Owns a shared deterministic dungeon, shared monster, player
life/death/finish state, treasure, and the block/timer deadline. Serves as the synchronous tick
resolver.
- `MatchEngine.js` — server tick driver. Wraps a `MatchRoom` with a `setInterval` timer, calls
`resolveTick()`, and fires `onFinish` when the match ends.
- `MatchState.js` — serialization helpers that produce broadcast/persistence shapes, including the
renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
### Transport / lifecycle layer (`src/network/`)
- `matchQueue.js` — persisted per-economy queues (`free`, `credits_prestige`, `crypto_race`),
join/leave, atomic credit/ticket escrow.
- `matchScheduler.js` — block-cadence scheduler that drains queues into matches.
- `matchManager.js` — Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, persistence.
- `matchPayoutService.js` — crypto-race pot accounting, house-fee split, winner payout record.
- `matchLeaderboard.js` — posts match scores to the correct leaderboard.
- `tavernMatchBridge.js` — forwards public match state to the tavern room for spectators.
### Persistence
See migration `src/migrations/022_match_mode.sql`:
- `matches` — one race, provably-fair seed, economy, pot/fee, winner.
- `match_entrants` — per-player state, placement, score, payment link.
- `match_events` — replay / spectator / audit feed.
- `match_queue_entries` — persisted queue for restart safety.
- `race_entry_transactions` — race-entry ticket ledger.
- `users.race_entries` — hot-read ticket balance.
- `payouts.match_id` — links winner payout to match.
### Provably fair
Each match commits to a SHA-256 `seed_hash` at creation. The `seed` is revealed on finish so anyone
can regenerate the exact dungeon and verify the hash. This mirrors the existing solo
`games.dungeon_seed` / `games.seed` pattern.
## Configuration
Add to `src/.env` (documented in `src/.env.example`):
```bash
# Enable match mode (default false)
MATCH_ENABLED=false
# Max players per race (2–32)
MATCH_MAX_PLAYERS=4
# Server tick interval in ms
MATCH_TICK_MS=250
# Minimum race duration before the next block can end it
MATCH_MIN_DURATION_MS=20000
# Hard ceiling after which a race is force-ended
MATCH_HARD_CEILING_MS=240000
# Credits cost for prestige-only races
MATCH_CREDITS_COST=1
# Crypto race house fee percent (default 5)
MATCH_HOUSE_FEE_PERCENT=5
# Optional: override per-player entry fee atomic amount.
# If unset, uses the existing per-game price (singleGamePrice / DIRECT_GAME_PRICE).
# MATCH_ENTRY_FEE_ATOMIC=5000000000
# Enable the crypto race-entry ticket economy
MATCH_CRYPTO_RACE_ENABLED=false
```
Crypto races also require:
- A paid instance (`GAME_MODE=PAID_SINGLE` / `PAID_CREDITS` or equivalent `PAYMENT_MODES`).
- Race-entry ticket products in the credit/cosmetic catalog with `grants.race_entries: N`.
## Client surfaces
- `/match.html` — dedicated race queue / race view with economy selector and race HUD.
- `/tavern.html` — **Queue for Race** panel and **Active Races** list with Watch buttons. Tavern
spectators receive `tavern_match_tick` / `tavern_match_end` events and render races through the
existing render kit.
## Socket events
Server emits:
- `game_mode_info` — now includes `modes.match { enabled, economies, maxPlayers }`.
- `match_queue_joined` / `match_queue_left` — queue responses.
- `match_joined` / `match_start` / `match_tick` / `match_end` — race lifecycle for players.
- `tavern_match_list` / `tavern_match_tick` / `tavern_match_end` — public race feed for tavern
spectators.
Client sends:
- `match_queue` `{ economy, action: 'join' | 'leave' }`
- `match_move` `{ dx, dy }`
- `match_leave` — forfeit an active race.
- `tavern_match_list` — request active races.
## Leaderboards
- **Free races** create synthetic `games` rows with `game_mode = 'FREE'` for the Pleb board.
- **Credits/prestige races** populate the `prestige_leaderboard` view and are served by
`GET /api/leaderboard?board=prestige`.
- **Crypto races** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` for the Hall of
Champions board.
## Tests
Run the match-mode test suites in isolation:
```bash
cd src
npx jest ../test/match
```
Current result: **40 passing** across 7 suites:
- `matchRoom.test.js`
- `matchQueue.test.js`
- `matchScheduler.test.js`
- `matchLeaderboard.test.js`
- `matchPayoutService.test.js`
- `tavernMatchBridge.test.js`
- `matchReconnect.test.js`
## Files added / modified
New:
- `src/migrations/022_match_mode.sql`
- `src/multiplayer/MatchRoom.js`, `MatchEngine.js`, `MatchState.js`
- `src/network/matchQueue.js`, `matchScheduler.js`, `matchManager.js`, `matchPayoutService.js`,
`matchLeaderboard.js`, `tavernMatchBridge.js`
- `html/match.html`, `html/js/matchClient.js`
- `docs/MATCH_MODE.md`, `src/.env.match.example`
- 7 test files
Modified:
- `src/network/socketHandlers.js` — initializes match services and wires socket events.
- `src/network/identityService.js` — adds `userForId()`.
- `src/game/gameModeManager.js` — advertises `modes.match`; grants race-entry tickets on product
payment.
- `src/payments/productGrants.js` — recognizes `grants.race_entries`.
- `src/index.js` — `/api/leaderboard?board=prestige`.
- `html/tavern.html` — race queue / spectator UI.
- `src/.env.example`, `docs/TAVERN_AND_MULTIPLAYER.md`, `docs/TODO.md`, `README.md`.
## Operational notes
- Match mode is **opt-in** and inert unless `MATCH_ENABLED=true`.
- Crypto race payouts reuse the existing `payouts` table, batch processor, and retry service.
- The unique partial index `idx_payouts_one_per_match` prevents double payouts.
- Race-entry tickets are held in escrow on queue join and consumed only when a match starts.
- Players who disconnect during a race have a 30-second grace period to reconnect.
+7 -6
View File
@@ -1,9 +1,10 @@
# Tavern & Multiplayer — Design & Build Plan
**Status:** In development. Shared engine scaffold landed under `src/multiplayer/`; unit tests pass.
Match mode (multiplayer races) now has its own dedicated design doc: see `docs/MATCH_MODE.md`.
Two new, optional game modes. Both are disabled by default and gated by config, so existing
single-player play is unaffected.
**Status:** Tavern is complete; multiplayer race mode is implemented (see `docs/MATCH_MODE.md`).
PvP combat remains a future extension. Both modes are disabled by default and gated by config, so
existing single-player play is unaffected.
## Shared engine: one Room, two modes
@@ -23,9 +24,9 @@ transport-agnostic makes it unit-testable in isolation and reusable as a standal
choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live
single-player or multiplayer games from inside the room.
**Multiplayer** — a shared match Room. Players race to escape a dungeon before the next block,
contend with monsters and environmental hazards, and (later) fight each other. Match rooms enable
occupant collision; the Tavern does not.
**Multiplayer Race** — a shared match Room. Players race to escape a dungeon before the next block
and contend with a shared monster. Match rooms enable occupant collision; the Tavern does not.
PvP combat is planned for a later milestone.
## Operator Policy
+16
View File
@@ -1,3 +1,19 @@
## Match Mode (Complete)
All milestones implemented. See `docs/MATCH_MODE.md` for architecture and configuration.
- [x] Design review: economy models, block-cadence lifecycle, ticket system, premium integration
- [x] Database schema: `matches`, `match_entrants`, `match_events`, `match_queue_entries`, `race_entry_transactions`, `users.race_entries`, `payouts.match_id`
- [x] Engine scaffold: `MatchRoom`, `MatchEngine`, `MatchState` with unit tests
- [x] Per-economy queues and block-cadence `MatchScheduler`
- [x] Socket.IO `MatchManager` and free races
- [x] Credits/prestige races and separate Prestige leaderboard
- [x] Crypto race-entry tickets and winner-take-pot payout
- [x] Client (`match.html`, `matchClient.js`) and Tavern spectator integration
- [x] Reconnect/AFK tests
- [x] Final doc pass and operator deployment notes
- [x] Unit tests: 40 passing across 7 suites
# Wownerogue Development Todo
## High Priority