diff --git a/README.md b/README.md index 72ea8f2..59cc9dc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block - **Persistent chat** with 30-day history - **Transaction history** - view payment and payout records - **Multiple game modes**: Free play, per-game payments, or credit bundles +- **Real-time multiplayer races**: free, prestige-credit, or crypto winner-take-pot races every block - Configurable difficulty with house edge tuning - Automatic wallet RPC integration for payments and payouts @@ -37,6 +38,19 @@ Open http://localhost:3000 to play. When both modes are enabled (`ALLOW_MIXED_MODE=true`), players with credits can start games instantly without the payment modal. +## Match Mode (Multiplayer Races) + +Optional real-time multiplayer races are available when `MATCH_ENABLED=true`. A new race starts every +crypto block as long as at least two players are queued. Supported economies: + +| Economy | Entry | Payout | Leaderboard | +|---|---|---|---| +| **Free** | None | None | Pleb | +| **Prestige Credits** | Credits | None | Prestige | +| **Crypto Race** | Race-entry ticket | Winner-take-pot minus house fee | Hall of Champions | + +See `docs/MATCH_MODE.md` for full architecture, configuration, and API details. + ## Difficulty Presets | Preset | Dungeon Size | Monster Aggression | House Win Rate | diff --git a/docs/MATCH_MODE.md b/docs/MATCH_MODE.md new file mode 100644 index 0000000..2deaa2c --- /dev/null +++ b/docs/MATCH_MODE.md @@ -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. diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md index 627bb36..c0c3729 100644 --- a/docs/TAVERN_AND_MULTIPLAYER.md +++ b/docs/TAVERN_AND_MULTIPLAYER.md @@ -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 diff --git a/docs/TODO.md b/docs/TODO.md index 2aeff6d..567fb1e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/html/js/matchClient.js b/html/js/matchClient.js new file mode 100644 index 0000000..d2ffcd4 --- /dev/null +++ b/html/js/matchClient.js @@ -0,0 +1,175 @@ +(function () { + 'use strict'; + + const socket = io({ transports: ['websocket', 'polling'] }); + + const els = { + economies: document.getElementById('economies'), + status: document.getElementById('status'), + queueBtn: document.getElementById('queueBtn'), + leaveBtn: document.getElementById('leaveBtn'), + queuePanel: document.getElementById('queuePanel'), + racePanel: document.getElementById('racePanel'), + raceEconomy: document.getElementById('raceEconomy'), + raceTimer: document.getElementById('raceTimer'), + racePlace: document.getElementById('racePlace'), + stage: document.getElementById('stage') + }; + + let gameModeInfo = null; + let selectedEconomy = 'free'; + let inQueue = false; + let inRace = false; + let mySocketId = null; + let renderer = null; + let mode = 'tiles'; + let keyState = {}; + + function setStatus(msg) { + els.status.textContent = msg; + } + + function renderEconomies() { + if (!gameModeInfo || !gameModeInfo.modes || !gameModeInfo.modes.match || !gameModeInfo.modes.match.enabled) { + els.economies.innerHTML = ''; + setStatus('Match mode is not enabled on this server.'); + els.queueBtn.disabled = true; + return; + } + + const available = gameModeInfo.modes.match.economies || {}; + const labels = { + free: 'Free', + credits_prestige: 'Prestige Credits', + crypto_race: 'Crypto Race' + }; + + els.economies.innerHTML = ''; + Object.keys(labels).forEach(function (key) { + const locked = !available[key]; + const el = document.createElement('div'); + el.className = 'economy' + (key === selectedEconomy ? ' active' : '') + (locked ? ' locked' : ''); + el.textContent = labels[key]; + if (!locked) { + el.addEventListener('click', function () { + selectedEconomy = key; + renderEconomies(); + }); + } + els.economies.appendChild(el); + }); + + els.queueBtn.disabled = false; + } + + socket.on('connect', function () { + mySocketId = socket.id; + setStatus('Connected. Waiting for server info…'); + }); + + socket.on('game_mode_info', function (info) { + gameModeInfo = info; + renderEconomies(); + if (gameModeInfo?.modes?.match?.enabled) { + setStatus('Ready to queue. Choose an economy and click Join Queue.'); + } + }); + + socket.on('match_queue_joined', function (res) { + if (res.success) { + inQueue = true; + els.queueBtn.classList.add('hidden'); + els.leaveBtn.classList.remove('hidden'); + setStatus('Queued for ' + selectedEconomy + ' (position ' + (res.position || '?') + '). Waiting for next race…'); + } else { + setStatus('Queue failed: ' + (res.reason || 'unknown')); + } + }); + + socket.on('match_queue_left', function (res) { + inQueue = false; + els.queueBtn.classList.remove('hidden'); + els.leaveBtn.classList.add('hidden'); + setStatus(res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown')); + }); + + socket.on('match_joined', function (data) { + setStatus('Race starting! Seed: ' + data.seedHash.slice(0, 12) + '…'); + }); + + socket.on('match_start', function (data) { + inQueue = false; + inRace = true; + els.queuePanel.classList.add('hidden'); + els.racePanel.classList.remove('hidden'); + els.raceEconomy.textContent = 'Economy: ' + selectedEconomy; + els.raceTimer.textContent = 'Timer: 0s'; + els.racePlace.textContent = 'Place: —'; + + if (!renderer) { + renderer = RK.createRenderer(mode, els.stage, {}); + } + }); + + socket.on('match_tick', function (data) { + if (!renderer || !data.state) return; + const scene = RK.sceneFromGameState ? RK.sceneFromGameState(data.state, mySocketId) : null; + if (scene) renderer.render(scene); + + const me = data.state.players.find(function (p) { return p.you; }); + if (me) { + els.racePlace.textContent = 'Place: ' + (me.placement || '—') + (me.alive ? '' : ' 💀'); + } + }); + + socket.on('match_end', function (data) { + inRace = false; + const me = data.players.find(function (p) { return p.id === mySocketId; }); + setStatus('Race over! ' + (data.reason || '') + ' — your placement: ' + (me ? me.placement : '—')); + setTimeout(function () { + if (!inRace) return; + els.racePanel.classList.add('hidden'); + els.queuePanel.classList.remove('hidden'); + els.queueBtn.classList.remove('hidden'); + els.leaveBtn.classList.add('hidden'); + }, 5000); + }); + + socket.on('match_error', function (err) { + setStatus('Match error: ' + (err.reason || 'unknown')); + }); + + socket.on('disconnect', function () { + setStatus('Disconnected. Reconnecting…'); + inQueue = false; + inRace = false; + }); + + els.queueBtn.addEventListener('click', function () { + if (inQueue || inRace) return; + socket.emit('match_queue', { economy: selectedEconomy, action: 'join' }); + }); + + els.leaveBtn.addEventListener('click', function () { + if (!inQueue) return; + socket.emit('match_queue', { economy: selectedEconomy, action: 'leave' }); + }); + + function sendMove(dx, dy) { + if (!inRace) return; + socket.emit('match_move', { dx: dx, dy: dy }); + } + + window.addEventListener('keydown', function (e) { + if (keyState[e.key]) return; // already pressed + keyState[e.key] = true; + if (['ArrowUp', 'w', 'W'].includes(e.key)) { e.preventDefault(); sendMove(0, -1); } + else if (['ArrowDown', 's', 'S'].includes(e.key)) { e.preventDefault(); sendMove(0, 1); } + else if (['ArrowLeft', 'a', 'A'].includes(e.key)) { e.preventDefault(); sendMove(-1, 0); } + else if (['ArrowRight', 'd', 'D'].includes(e.key)) { e.preventDefault(); sendMove(1, 0); } + }); + + window.addEventListener('keyup', function (e) { + keyState[e.key] = false; + }); +}()); diff --git a/html/match.html b/html/match.html new file mode 100644 index 0000000..447ccf3 --- /dev/null +++ b/html/match.html @@ -0,0 +1,72 @@ + + + + + + Wownerogue — Match Race + + + + + + + + + + + + + + +
+

🏁 Race Queue

+

Multiplayer dungeon race. A new race starts every block when at least two players are queued.

+ +
+
+
Select economy:
+
+
Connect to server to see available economies.
+ +
+ + +
+
+ + +
+
+ + + + + diff --git a/html/tavern.html b/html/tavern.html index 2880997..c96827f 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -87,6 +87,22 @@
+ + + +