mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
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.
195 lines
7.5 KiB
Markdown
195 lines
7.5 KiB
Markdown
# 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.
|