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 @@
+
+
+
+
@@ -363,6 +379,117 @@
socket.emit('identity:get');
setStatus('Connected. Choose a name and enter.');
});
+ // ---- Tavern Match spectator / queue UI -------------------------------
+ var matchQueueBtn = document.getElementById('matchQueueBtn');
+ var matchPanel = document.getElementById('matchPanel');
+ var matchEconomies = document.getElementById('matchEconomies');
+ var matchQueueStatus = document.getElementById('matchQueueStatus');
+ var joinMatchQueueBtn = document.getElementById('joinMatchQueueBtn');
+ var leaveMatchQueueBtn = document.getElementById('leaveMatchQueueBtn');
+ var activeMatchList = document.getElementById('activeMatchList');
+ var closeMatchPanelBtn = document.getElementById('closeMatchPanelBtn');
+ var selectedMatchEconomy = 'free';
+ var inMatchQueue = false;
+
+ function buildMatchEconomies(info) {
+ if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return;
+ var economies = info.modes.match.economies || {};
+ var labels = { free: 'Free', credits_prestige: 'Prestige', crypto_race: 'Crypto' };
+ matchEconomies.innerHTML = '';
+ Object.keys(labels).forEach(function (key) {
+ if (!economies[key]) return;
+ var b = document.createElement('button');
+ b.textContent = labels[key];
+ b.className = 'modebtn' + (key === selectedMatchEconomy ? ' active' : '');
+ b.addEventListener('click', function () {
+ selectedMatchEconomy = key;
+ Array.prototype.forEach.call(matchEconomies.children, function (c) { c.classList.remove('active'); });
+ b.classList.add('active');
+ });
+ matchEconomies.appendChild(b);
+ });
+ }
+
+ socket.on('game_mode_info', function (data) {
+ // Extend existing handler: also build match economies.
+ if (data && data.entitlements) applyEntitlements(data.entitlements);
+ if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
+ if (data && data.modes && data.modes.match && data.modes.match.enabled) {
+ matchQueueBtn.classList.remove('hidden');
+ buildMatchEconomies(data);
+ }
+ });
+
+ socket.on('match_queue_joined', function (res) {
+ if (res.success) {
+ inMatchQueue = true;
+ joinMatchQueueBtn.classList.add('hidden');
+ leaveMatchQueueBtn.classList.remove('hidden');
+ matchQueueStatus.textContent = 'Queued for ' + selectedMatchEconomy + ' (position ' + (res.position || '?') + ').';
+ } else {
+ matchQueueStatus.textContent = 'Queue failed: ' + (res.reason || 'unknown');
+ }
+ });
+
+ socket.on('match_queue_left', function (res) {
+ inMatchQueue = false;
+ joinMatchQueueBtn.classList.remove('hidden');
+ leaveMatchQueueBtn.classList.add('hidden');
+ matchQueueStatus.textContent = res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown');
+ });
+
+ socket.on('tavern_match_list', function (list) {
+ activeMatchList.innerHTML = '';
+ if (!list || list.length === 0) {
+ activeMatchList.textContent = 'No active races right now.';
+ return;
+ }
+ list.forEach(function (m) {
+ var row = document.createElement('div');
+ row.className = 'gamerow';
+ row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--line);';
+ row.innerHTML = '' + (m.economy || 'race') + ' — ' + (m.players ? m.players.length : 0) + ' players ';
+ activeMatchList.appendChild(row);
+ });
+ Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) {
+ b.addEventListener('click', function () {
+ var matchId = b.dataset.matchid;
+ // Switch spectator renderer to the public match state stream.
+ enterSpectatorMode(matchId);
+ });
+ });
+ });
+
+ socket.on('tavern_match_tick', function (state) {
+ if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) {
+ var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, null) : null;
+ if (scene && spectatorRenderer) spectatorRenderer.render(scene);
+ }
+ });
+
+ socket.on('tavern_match_end', function (data) {
+ if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) {
+ spectatorLabel.textContent = 'Race ended. Winner: ' + (data.winnerId ? data.winnerId.slice(0, 6) : 'none');
+ }
+ });
+
+ matchQueueBtn.addEventListener('click', function () {
+ matchPanel.classList.toggle('hidden');
+ if (!matchPanel.classList.contains('hidden')) socket.emit('tavern_match_list');
+ });
+ closeMatchPanelBtn.addEventListener('click', function () { matchPanel.classList.add('hidden'); });
+
+ joinMatchQueueBtn.addEventListener('click', function () {
+ if (inMatchQueue) return;
+ socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'join' });
+ });
+ leaveMatchQueueBtn.addEventListener('click', function () {
+ if (!inMatchQueue) return;
+ socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'leave' });
+ });
+
+ // ---- End match UI ----------------------------------------------------
+
socket.on('disconnect', function () { setStatus('Disconnected.'); });
})();
diff --git a/src/.env.example b/src/.env.example
index 118466e..8015e85 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -487,3 +487,34 @@ CRYPTO_TYPE=WOW
TAVERN_ENABLED=false
# Server-tick / snapshot broadcast cadence in ms (default 100 = 10 Hz)
# TAVERN_TICK_MS=100
+
+# =============================================================================
+# Match mode (multiplayer races)
+# =============================================================================
+# Enable real-time multiplayer races. Solo/Tavern are unaffected.
+MATCH_ENABLED=false
+
+# Max players per race (2–32). Default 4.
+# MATCH_MAX_PLAYERS=4
+
+# Server tick interval in ms (default 250 = 4 Hz).
+# MATCH_TICK_MS=250
+
+# Minimum race duration in ms before the next block can end it (default 20s).
+# MATCH_MIN_DURATION_MS=20000
+
+# Hard ceiling in ms after which a race is force-ended (default 4m).
+# MATCH_HARD_CEILING_MS=240000
+
+# Credits cost for prestige-only races (default 1).
+# MATCH_CREDITS_COST=1
+
+# Crypto race house fee percent (default 5).
+# MATCH_HOUSE_FEE_PERCENT=5
+
+# Optional override for the crypto-race per-player entry fee in atomic units.
+# If unset, the existing per-game price (DIRECT_GAME_PRICE / SINGLE_GAME_PRICE) is used.
+# MATCH_ENTRY_FEE_ATOMIC=5000000000
+
+# Enable crypto race-entry ticket economy (requires paid instance + ticket products).
+# MATCH_CRYPTO_RACE_ENABLED=false
diff --git a/src/.env.match.example b/src/.env.match.example
new file mode 100644
index 0000000..e66f3b3
--- /dev/null
+++ b/src/.env.match.example
@@ -0,0 +1,23 @@
+# Match (multiplayer race) mode configuration
+# Add these to src/.env and src/.env.example as needed.
+
+# Enable match mode (free race queues and, if configured, paid race economies)
+MATCH_ENABLED=false
+
+# Max players per race (2-32)
+MATCH_MAX_PLAYERS=4
+
+# Server tick interval in milliseconds (default 250ms = 4 Hz)
+MATCH_TICK_MS=250
+
+# Minimum duration in ms before a race can be ended by the next block (default 20s)
+MATCH_MIN_DURATION_MS=20000
+
+# Hard ceiling in ms after which a race is force-ended (default 4 minutes)
+MATCH_HARD_CEILING_MS=240000
+
+# Cost in credits to enter a prestige-credit race (default 1)
+MATCH_CREDITS_COST=1
+
+# Enable crypto race-entry ticket economy (requires a paid instance)
+MATCH_CRYPTO_RACE_ENABLED=false
diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js
index 42ffd8d..4c38da9 100644
--- a/src/game/gameModeManager.js
+++ b/src/game/gameModeManager.js
@@ -265,6 +265,18 @@ class GameModeManager {
};
}
+
+ _getMatchEconomies() {
+ const out = { free: true };
+ if (this.creditsModeEnabled || this.freePlayEnabled) {
+ out.credits_prestige = true;
+ }
+ if (process.env.MATCH_CRYPTO_RACE_ENABLED === 'true' && (this.directModeEnabled || this.creditsModeEnabled)) {
+ out.crypto_race = true;
+ }
+ return out;
+ }
+
getCosmeticProducts() {
const products = this.configSnapshot?.products?.cosmetic;
return Array.isArray(products) ? products : [];
@@ -398,7 +410,22 @@ class GameModeManager {
`, [userId, creditsToAdd, newBalance]);
}
- // Step 4: Grant cosmetic/render packs. Idempotent so retrying a confirmed
+ // Step 4: Grant race entry tickets if the product includes them.
+ const raceEntriesToAdd = productGrants.raceEntries || 0;
+ if (raceEntriesToAdd > 0) {
+ const raceEntryUpdate = await client.query(`
+ UPDATE users
+ SET race_entries = race_entries + $1
+ WHERE id = $2
+ RETURNING race_entries
+ `, [raceEntriesToAdd, userId]);
+ await client.query(`
+ INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, payment_id, metadata)
+ VALUES ($1, $2, $3, 'purchase', $4, $5::jsonb)
+ `, [userId, raceEntriesToAdd, raceEntryUpdate.rows[0].race_entries, paymentId, JSON.stringify({ productId })]);
+ }
+
+ // Step 5: Grant cosmetic/render packs. Idempotent so retrying a confirmed
// product cannot duplicate entitlements.
for (const pack of productGrants.packs) {
await client.query(`
@@ -1337,7 +1364,12 @@ class GameModeManager {
modes: {
solo: process.env.SOLO_ENABLED !== 'false',
tavern: process.env.TAVERN_ENABLED === 'true',
- multiplayer: process.env.MULTIPLAYER_ENABLED === 'true'
+ multiplayer: process.env.MULTIPLAYER_ENABLED === 'true',
+ match: {
+ enabled: process.env.MATCH_ENABLED === 'true',
+ economies: this._getMatchEconomies(),
+ maxPlayers: parseInt(process.env.MATCH_MAX_PLAYERS, 10) || 4
+ }
}
};
}
diff --git a/src/index.js b/src/index.js
index 6bad8ce..35023a9 100644
--- a/src/index.js
+++ b/src/index.js
@@ -527,12 +527,28 @@ app.get('/api/leaderboard', asyncHandler(async (req, res) => {
// Per-game leaderboard split (whitelisted -> safe to interpolate):
// champions = games played with credits/entry-fee (Hall of Champions)
// pleb = free games (Pleb board)
+ // prestige = credit-prestige match races
// all = everyone (default, backward compatible)
const board = req.query.board || 'all';
let boardFilter = '';
if (board === 'champions') boardFilter = "AND g.game_mode IN ('PAID_SINGLE','PAID_CREDITS')";
else if (board === 'pleb') boardFilter = "AND g.game_mode = 'FREE'";
+ if (board === 'prestige') {
+ const result = await db.query(`
+ SELECT
+ user_id as id,
+ COALESCE(display_name, 'Anon#' || user_id) as name,
+ best_score,
+ wins,
+ races as games_played
+ FROM prestige_leaderboard
+ ORDER BY best_score DESC
+ LIMIT $1
+ `, [limit]);
+ return res.json({ leaderboard: result.rows, period, board });
+ }
+
const result = await db.query(`
SELECT
u.id,
diff --git a/src/migrations/022_match_mode.sql b/src/migrations/022_match_mode.sql
new file mode 100644
index 0000000..26d6445
--- /dev/null
+++ b/src/migrations/022_match_mode.sql
@@ -0,0 +1,186 @@
+-- Migration 022: Match (race) mode foundation
+--
+-- Adds normalized tables for multiplayer races. Keeps existing games/payments/payouts
+-- tables untouched except for one nullable foreign-key column on payouts.
+--
+-- Design notes:
+-- - One fact, one table: matches, entrants, events, queue, and ticket ledger are separate.
+-- - All money movement uses DatabaseManager.withTransaction() at the application layer.
+-- - Provably fair: seed_hash stored at creation, seed revealed on finish (like solo games).
+-- - Tickets (race_entries) are non-refundable crypto-race entry tokens; they avoid the
+-- need for on-chain refunds when a player leaves the queue before a match starts.
+
+-- -----------------------------------------------------------------------------
+-- 1. Matches: one row per race
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS matches (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ status VARCHAR(20) NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'starting', 'active', 'finished', 'cancelled')),
+ economy VARCHAR(20) NOT NULL
+ CHECK (economy IN ('free', 'credits_prestige', 'crypto_race')),
+ variant VARCHAR(20) NOT NULL DEFAULT 'race'
+ CHECK (variant IN ('race', 'pvp')),
+ difficulty_preset VARCHAR(20) NOT NULL,
+ max_players INT NOT NULL DEFAULT 4
+ CHECK (max_players >= 2 AND max_players <= 32),
+ seed_hash VARCHAR(64) NOT NULL,
+ seed VARCHAR(255), -- revealed on finish for verification
+ dungeon JSONB NOT NULL, -- deterministic dungeon snapshot
+ start_block_height BIGINT, -- block that triggered the race start
+ end_block_height BIGINT, -- block that ended the race (if applicable)
+ started_at TIMESTAMPTZ,
+ ended_at TIMESTAMPTZ,
+ -- Economy fields (all atomic currency units, BIGINT)
+ entry_fee_atomic BIGINT DEFAULT 0
+ CHECK (entry_fee_atomic >= 0),
+ pot_atomic BIGINT DEFAULT 0
+ CHECK (pot_atomic >= 0),
+ house_fee_atomic BIGINT DEFAULT 0
+ CHECK (house_fee_atomic >= 0),
+ house_fee_percent DECIMAL(5, 2) DEFAULT 0
+ CHECK (house_fee_percent >= 0 AND house_fee_percent <= 100),
+ winner_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
+CREATE INDEX IF NOT EXISTS idx_matches_economy_finished ON matches(economy, ended_at)
+ WHERE status = 'finished';
+CREATE INDEX IF NOT EXISTS idx_matches_start_block ON matches(start_block_height)
+ WHERE start_block_height IS NOT NULL;
+
+-- -----------------------------------------------------------------------------
+-- 2. Match entrants: one row per player per match
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS match_entrants (
+ id BIGSERIAL PRIMARY KEY,
+ match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ socket_id VARCHAR(255) NOT NULL,
+ placement INT,
+ escaped BOOLEAN DEFAULT FALSE,
+ has_treasure BOOLEAN DEFAULT FALSE,
+ killed_by VARCHAR(50), -- 'monster', 'player:', 'timeout', 'afk'
+ score INT DEFAULT 0
+ CHECK (score >= 0),
+ payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL,
+ entry_consumed BOOLEAN DEFAULT FALSE, -- true once match actually starts
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_match_entrants_user_match
+ ON match_entrants(match_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_match_entrants_match
+ ON match_entrants(match_id);
+CREATE INDEX IF NOT EXISTS idx_match_entrants_placement
+ ON match_entrants(match_id, placement, score DESC);
+
+-- -----------------------------------------------------------------------------
+-- 3. Match events: replay / spectator / audit feed
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS match_events (
+ id BIGSERIAL PRIMARY KEY,
+ match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
+ tick INT NOT NULL,
+ type VARCHAR(50) NOT NULL,
+ payload JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_match_events_match_tick
+ ON match_events(match_id, tick);
+
+-- -----------------------------------------------------------------------------
+-- 4. Match queue entries: persisted queue state for restart safety
+-- -----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS match_queue_entries (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ economy VARCHAR(20) NOT NULL
+ CHECK (economy IN ('free', 'credits_prestige', 'crypto_race')),
+ socket_id VARCHAR(255) NOT NULL,
+ session_token VARCHAR(255) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'queued'
+ CHECK (status IN ('queued', 'matched', 'cancelled')),
+ match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ matched_at TIMESTAMPTZ
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_match_queue_user_economy_queued
+ ON match_queue_entries(user_id, economy)
+ WHERE status = 'queued';
+CREATE INDEX IF NOT EXISTS idx_match_queue_economy_created
+ ON match_queue_entries(economy, created_at)
+ WHERE status = 'queued';
+
+-- -----------------------------------------------------------------------------
+-- 5. Race entry ticket balance and ledger
+-- -----------------------------------------------------------------------------
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS race_entries INT NOT NULL DEFAULT 0
+ CHECK (race_entries >= 0);
+
+CREATE TABLE IF NOT EXISTS race_entry_transactions (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ delta INT NOT NULL,
+ balance_after INT NOT NULL,
+ reason VARCHAR(50) NOT NULL
+ CHECK (reason IN ('purchase', 'refund', 'queue_join', 'queue_leave', 'match_start', 'match_cancel', 'admin_grant')),
+ match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
+ payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL,
+ metadata JSONB DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_race_entry_tx_user
+ ON race_entry_transactions(user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_race_entry_tx_match
+ ON race_entry_transactions(match_id)
+ WHERE match_id IS NOT NULL;
+
+-- -----------------------------------------------------------------------------
+-- 6. Link payouts to matches (nullable, additive only)
+-- -----------------------------------------------------------------------------
+ALTER TABLE payouts
+ ADD COLUMN IF NOT EXISTS match_id UUID REFERENCES matches(id) ON DELETE SET NULL;
+
+CREATE INDEX IF NOT EXISTS idx_payouts_match
+ ON payouts(match_id)
+ WHERE match_id IS NOT NULL;
+
+-- -----------------------------------------------------------------------------
+-- 7. Prestige leaderboard view (credit-prestige races)
+-- -----------------------------------------------------------------------------
+CREATE OR REPLACE VIEW prestige_leaderboard AS
+SELECT
+ u.id AS user_id,
+ u.display_name,
+ MAX(me.score) AS best_score,
+ COUNT(*) FILTER (WHERE me.escaped) AS wins,
+ COUNT(*) AS races,
+ MAX(m.ended_at) AS last_race_at
+FROM match_entrants me
+JOIN matches m ON me.match_id = m.id
+JOIN users u ON me.user_id = u.id
+WHERE m.economy = 'credits_prestige'
+ AND m.status = 'finished'
+ AND me.score > 0
+GROUP BY u.id, u.display_name;
+
+-- -----------------------------------------------------------------------------
+-- 8. Safety constraints
+-- -----------------------------------------------------------------------------
+
+-- Only one active (pending/completed) payout per match, preventing double payouts.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_match
+ ON payouts(match_id)
+ WHERE match_id IS NOT NULL AND status IN ('pending', 'completed');
+
+-- Only one finished winner record per match (business rule, not a constraint, because
+-- ties are resolved deterministically by the engine; this index supports the lookup).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_matches_finished_winner
+ ON matches(id, winner_user_id)
+ WHERE status = 'finished' AND winner_user_id IS NOT NULL;
diff --git a/src/multiplayer/MatchEngine.js b/src/multiplayer/MatchEngine.js
new file mode 100644
index 0000000..3b1c2a3
--- /dev/null
+++ b/src/multiplayer/MatchEngine.js
@@ -0,0 +1,79 @@
+/**
+ * MatchEngine — server tick driver for a MatchRoom.
+ *
+ * The engine is intentionally thin: all state/rules live in MatchRoom, and this class owns
+ * the timer that calls `room.resolveTick()` at a fixed interval. It also handles graceful
+ * shutdown and can be used by MatchManager to drive many concurrent matches.
+ *
+ * Transport-agnostic: no Socket.IO here. The manager that owns this engine consumes the
+ * tick result and broadcasts it.
+ */
+
+class MatchEngine {
+ /**
+ * @param {object} opts
+ * @param {MatchRoom} opts.room
+ * @param {number} [opts.tickMs] default 250ms
+ * @param {Function} [opts.onTick] callback(tickResult, room)
+ * @param {Function} [opts.onFinish] callback(room)
+ */
+ constructor({ room, tickMs = 250, onTick = null, onFinish = null } = {}) {
+ if (!room) throw new Error('MatchEngine requires a MatchRoom');
+ this.room = room;
+ this.tickMs = tickMs;
+ this.onTick = onTick;
+ this.onFinish = onFinish;
+ this.timer = null;
+ this.running = false;
+ }
+
+ start() {
+ if (this.running) return false;
+ const started = this.room.start();
+ if (!started) return false;
+ this.running = true;
+ this.timer = setInterval(() => this._tick(), this.tickMs);
+ return true;
+ }
+
+ stop() {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ this.running = false;
+ }
+
+ /**
+ * Run a single tick synchronously. Useful for tests and for manually stepping.
+ * @returns {{tick:number, events:Array, finished:boolean}}
+ */
+ tick() {
+ return this._tick();
+ }
+
+ _tick() {
+ const result = this.room.resolveTick();
+ if (this.onTick) this.onTick(result, this.room);
+ if (result.finished) {
+ this.stop();
+ this.room.finalize();
+ if (this.onFinish) this.onFinish(this.room);
+ }
+ return result;
+ }
+
+ /**
+ * Force end (e.g. block deadline or hard ceiling). The manager should call this when
+ * it knows the match must end regardless of in-engine state.
+ * @param {string} reason
+ */
+ expire(reason) {
+ this.room.expire(reason);
+ this.stop();
+ this.room.finalize();
+ if (this.onFinish) this.onFinish(this.room);
+ }
+}
+
+module.exports = MatchEngine;
diff --git a/src/multiplayer/MatchRoom.js b/src/multiplayer/MatchRoom.js
new file mode 100644
index 0000000..ea2804b
--- /dev/null
+++ b/src/multiplayer/MatchRoom.js
@@ -0,0 +1,671 @@
+/**
+ * MatchRoom — a server-authoritative multiplayer race room.
+ *
+ * Extends Room with a shared dungeon, shared monsters, treasure, and per-player
+ * life/death/finish state. The engine is transport-agnostic; MatchManager owns the
+ * Socket.IO rooms and the server tick timer.
+ *
+ * Invariants:
+ * • One shared deterministic dungeon per match (provably fair from match seed).
+ * • All player moves are queued and resolved together by MatchEngine each tick.
+ * • Player-on-player collision is enabled (solidOccupants = true).
+ * • One shared treasure exists; first player to reach it picks it up.
+ * • If the treasure carrier dies, the treasure drops at their corpse.
+ * • First player to reach the exit wins; timer expiry ranks by proximity.
+ */
+
+const { v4: uuidv4 } = require('uuid');
+const crypto = require('crypto');
+const Room = require('./Room');
+const Occupant = require('./Occupant');
+const DungeonGenerator = require('../game/dungeon');
+const Monster = require('../game/monster');
+const { createGameProof, createSeededRNG, seedToInt } = require('../game/provablyFair');
+const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('../game/difficultyConfig');
+
+const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
+
+const DEFAULT_MATCH_PRESET = 'race';
+
+class MatchRoom extends Room {
+ /**
+ * @param {object} opts
+ * @param {string} [opts.id]
+ * @param {string} [opts.economy] 'free' | 'credits_prestige' | 'crypto_race'
+ * @param {string} [opts.variant] 'race' | 'pvp' (default 'race')
+ * @param {string} [opts.difficultyPreset] key passed to getDifficultyConfig
+ * @param {number} [opts.maxPlayers] 2–32
+ * @param {object} [opts.entrants] id -> { userId, name, avatar, appearance }
+ * @param {string} [opts.seed] optional explicit seed (testing)
+ * @param {number} [opts.startBlockHeight] block that triggered this race
+ * @param {object} [opts.cryptoType] 'WOW' | 'XMR'
+ */
+ constructor(opts = {}) {
+ const id = opts.id || uuidv4();
+ // Room constructor needs a layout or roomData. We'll build our own map afterwards,
+ // so pass a minimal placeholder and then overwrite the map state.
+ super({ id, type: 'match', layout: ['.'], solidOccupants: true, maxOccupants: opts.maxPlayers || 4 });
+
+ this.economy = opts.economy || 'free';
+ this.variant = opts.variant || 'race';
+ this.difficultyPreset = opts.difficultyPreset || DEFAULT_MATCH_PRESET;
+ this.maxPlayers = Math.max(2, Math.min(32, opts.maxPlayers || 4));
+ this.cryptoType = opts.cryptoType || process.env.CRYPTO_TYPE || 'WOW';
+ this.startBlockHeight = opts.startBlockHeight || null;
+ this.createdAt = Date.now();
+ this.tickCount = 0;
+ this.status = 'starting'; // starting -> active -> finished | cancelled
+
+ // Provably fair proof
+ const proof = createGameProof(id);
+ this.seed = opts.seed || proof.seed;
+ this.seedHash = crypto.createHash("sha256").update(this.seed).digest("hex");
+ this.seededRNG = createSeededRNG(this.seed);
+ this.seedInt = seedToInt(this.seed);
+
+ // Difficulty
+ this.difficultyConfig = getDifficultyConfig(this.cryptoType, { preset: this.difficultyPreset });
+
+ // Generate the shared dungeon
+ this._generateDungeon();
+
+ // Spawn players at entrance, monster in configured room
+ this._spawnMonster();
+ this._resetEntrants(opts.entrants || {});
+
+ // Shared treasure state
+ this.treasure = this.dungeon.treasure ? { x: this.dungeon.treasure[0], y: this.dungeon.treasure[1], carrierId: null } : null;
+
+ // Movement queue for current tick: occupantId -> { dx, dy }
+ this.moveQueue = new Map();
+
+ // Timer / deadline bookkeeping
+ this.minDurationMs = 20000; // 20s floor before next-block expiry can fire
+ this.hardCeilingMs = 240000; // 4m absolute max
+ this.startedAt = null;
+ this.endedAt = null;
+ this.endReason = null;
+
+ // Economy bookkeeping (set later by MatchManager).
+ this.entryFeeAtomic = opts.entryFeeAtomic || 0;
+ this.potAtomic = opts.potAtomic || 0;
+ this.houseFeeAtomic = opts.houseFeeAtomic || 0;
+ this.houseFeePercent = opts.houseFeePercent || 0;
+ this.winnerId = null;
+
+ // Event log for this match (also persisted to match_events by manager)
+ this.events = [];
+ }
+
+ _generateDungeon() {
+ const cfg = this.difficultyConfig.dungeon;
+ const gameConfig = {
+ width: cfg.width,
+ height: cfg.height,
+ floorVariation: 0.01,
+ torchEnabled: true,
+ torchDensity: 0.1,
+ primaryFloor: "'1",
+ secondaryFloor: "'2",
+ torchTile: 'torch',
+ rng: this.seededRNG,
+ seedInt: this.seedInt
+ };
+ this.dungeon = DungeonGenerator.generate(cfg.width, cfg.height, gameConfig);
+
+ // Build a walkable grid for Room's movement/collision helpers.
+ this.rows = cfg.height;
+ this.cols = cfg.width;
+ this.walkable = [];
+ for (let y = 0; y < this.rows; y++) {
+ const row = [];
+ for (let x = 0; x < this.cols; x++) {
+ const tile = this.dungeon.map[y] && this.dungeon.map[y][x];
+ row.push(tile === "'1" || tile === "'2" || tile === '>' || tile === '$M');
+ }
+ this.walkable.push(row);
+ }
+
+ // Spawns: entrance first, then nearby walkable tiles if many players.
+ const ex = this.dungeon.entrance[0];
+ const ey = this.dungeon.entrance[1];
+ const spawnCandidates = [{ x: ex, y: ey, d: 0 }];
+ for (let y = 0; y < this.rows; y++) {
+ for (let x = 0; x < this.cols; x++) {
+ if (this.isWalkable(x, y) && (x !== ex || y !== ey)) {
+ const d = Math.abs(x - ex) + Math.abs(y - ey);
+ spawnCandidates.push({ x, y, d });
+ }
+ }
+ }
+ spawnCandidates.sort((a, b) => a.d - b.d);
+ this.spawns = spawnCandidates.slice(0, this.maxPlayers);
+ }
+
+ _spawnMonster() {
+ if (this.dungeon.rooms && this.dungeon.rooms.length > 2) {
+ const idx = getMonsterSpawnRoomIndex(
+ this.dungeon.rooms,
+ this.difficultyConfig.monster.startDistanceFromPlayer
+ );
+ const room = this.dungeon.rooms[idx];
+ const center = room.getCenter();
+ this.monster = new Monster(center[0], center[1], {
+ visionRange: this.difficultyConfig.monster.visionRange || 12
+ });
+ } else {
+ this.monster = new Monster(0, 0, {
+ visionRange: this.difficultyConfig.monster.visionRange || 12
+ });
+ }
+ }
+
+ /**
+ * Replace the inherited tavern occupants with match entrants placed at the entrance.
+ * @param {object} entrants — id -> { userId, name, avatar, appearance }
+ */
+ _resetEntrants(entrants) {
+ this.occupants.clear();
+ this.playerStates = new Map(); // id -> { alive, finished, escaped, hasTreasure, placement, killedBy }
+
+ const ids = Object.keys(entrants);
+ for (let i = 0; i < ids.length && i < this.maxPlayers; i++) {
+ const id = ids[i];
+ const e = entrants[id] || {};
+ const spawn = this.spawns[i] || this.spawns[0] || { x: 0, y: 0 };
+ const occ = new Occupant(id, {
+ x: spawn.x,
+ y: spawn.y,
+ name: e.name || null,
+ avatar: e.avatar || 'default',
+ appearance: e.appearance || null,
+ facing: 'down'
+ });
+ this.occupants.set(id, occ);
+ this.playerStates.set(id, {
+ userId: e.userId || null,
+ alive: true,
+ finished: false,
+ escaped: false,
+ hasTreasure: false,
+ placement: null,
+ killedBy: null,
+ score: 0,
+ moves: 0
+ });
+ }
+ }
+
+ /** Number of players still alive and not finished. */
+ get activePlayerCount() {
+ let n = 0;
+ for (const s of this.playerStates.values()) {
+ if (s.alive && !s.finished) n++;
+ }
+ return n;
+ }
+
+ /** Number of players who have reached the exit. */
+ get finishCount() {
+ let n = 0;
+ for (const s of this.playerStates.values()) {
+ if (s.finished) n++;
+ }
+ return n;
+ }
+
+ /**
+ * Queue a move for an occupant. Moves are not applied until resolveTick().
+ * @param {string} id
+ * @param {number} dx - -1, 0, or 1
+ * @param {number} dy - -1, 0, or 1
+ */
+ queueMove(id, dx, dy) {
+ const occ = this.occupants.get(id);
+ const state = this.playerStates.get(id);
+ if (!occ || !state || !state.alive || state.finished) return false;
+ dx = Math.sign(dx || 0);
+ dy = Math.sign(dy || 0);
+ if (Math.abs(dx) + Math.abs(dy) !== 1) return false;
+ this.moveQueue.set(id, { dx, dy });
+ return true;
+ }
+
+ /**
+ * Resolve one server tick: apply all queued moves, resolve collisions, move the
+ * monster, check deaths/treasure/exits. Returns a tick result with events.
+ * @returns {{tick:number, events:Array, finished:boolean}}
+ */
+ resolveTick() {
+ if (this.status !== 'active') {
+ return { tick: this.tickCount, events: [], finished: this.status === 'finished' };
+ }
+ this.tickCount++;
+ const events = [];
+
+ // 1. Resolve player moves simultaneously.
+ const moves = this._resolvePlayerMoves(events);
+
+ // 2. Move the shared monster toward the nearest visible active player.
+ const monsterEvents = this._moveMonster();
+ events.push(...monsterEvents);
+
+ // 3. Check win/death/treasure after all entities have moved.
+ const resolutionEvents = this._checkResolution();
+ events.push(...resolutionEvents);
+
+ this.moveQueue.clear();
+
+ if (this.status === 'finished') {
+ this.endedAt = Date.now();
+ events.push({ type: 'match_end', reason: this.endReason, winnerId: this.winnerId, tick: this.tickCount });
+ }
+
+ return { tick: this.tickCount, events, finished: this.status === 'finished' };
+ }
+
+ _resolvePlayerMoves(events) {
+ // Build a map of intended destinations so we can detect head-on swaps and pile-ups.
+ const plans = [];
+ for (const [id, { dx, dy }] of this.moveQueue.entries()) {
+ const occ = this.occupants.get(id);
+ const state = this.playerStates.get(id);
+ if (!occ || !state || !state.alive || state.finished) continue;
+ const nx = occ.x + dx;
+ const ny = occ.y + dy;
+ const facing = Room.facingFor(dx, dy);
+ plans.push({ id, from: { x: occ.x, y: occ.y }, to: { x: nx, y: ny }, facing });
+ }
+
+ // If A moves to B's tile and B moves to A's tile, both bounce.
+ const swapPairs = new Set();
+ for (let i = 0; i < plans.length; i++) {
+ for (let j = i + 1; j < plans.length; j++) {
+ const a = plans[i];
+ const b = plans[j];
+ if (a.to.x === b.from.x && a.to.y === b.from.y &&
+ b.to.x === a.from.x && b.to.y === a.from.y) {
+ swapPairs.add(a.id);
+ swapPairs.add(b.id);
+ }
+ }
+ }
+
+ // Destination counts for pile-up detection.
+ const destCount = new Map();
+ for (const p of plans) {
+ if (swapPairs.has(p.id)) continue;
+ const key = `${p.to.x},${p.to.y}`;
+ destCount.set(key, (destCount.get(key) || 0) + 1);
+ }
+
+ // Apply each valid move.
+ for (const p of plans) {
+ const occ = this.occupants.get(p.id);
+ let reason = null;
+
+ if (swapPairs.has(p.id)) {
+ reason = 'collision_swap';
+ } else if (!this.isWalkable(p.to.x, p.to.y)) {
+ reason = 'blocked';
+ } else if (this.solidOccupants && this.isOccupied(p.to.x, p.to.y, p.id)) {
+ reason = 'occupied';
+ } else if (destCount.get(`${p.to.x},${p.to.y}`) > 1) {
+ reason = 'collision_pileup';
+ }
+
+ if (reason) {
+ if (p.facing) occ.facing = p.facing;
+ events.push({ type: 'move_failed', id: p.id, reason, from: p.from, to: p.to, tick: this.tickCount });
+ continue;
+ }
+
+ occ.moveTo(p.to.x, p.to.y, p.facing);
+ const state = this.playerStates.get(p.id);
+ state.moves++;
+ events.push({ type: 'move', id: p.id, from: p.from, to: p.to, facing: p.facing, tick: this.tickCount });
+ // If the player stepped onto the monster, they die immediately.
+ if (this.monster && p.to.x === this.monster.x && p.to.y === this.monster.y) {
+ this._killPlayer(p.id, 'monster');
+ events.push({ type: 'player_death', id: p.id, killedBy: 'monster', tick: this.tickCount });
+ }
+ }
+
+ return events;
+ }
+
+ _moveMonster() {
+ if (!this.monster || this.activePlayerCount === 0) return [];
+
+ const events = [];
+ const occs = Array.from(this.occupants.values()).filter(o => {
+ const s = this.playerStates.get(o.id);
+ return s && s.alive && !s.finished;
+ });
+ if (occs.length === 0) return events;
+
+ // If the monster is already standing on a player, kill them immediately.
+ for (const occ of occs) {
+ if (occ.x === this.monster.x && occ.y === this.monster.y) {
+ this._killPlayer(occ.id, 'monster');
+ events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
+ }
+ }
+ // Remove dead players from the active set before choosing a target.
+ const activeOccs = occs.filter(o => {
+ const s = this.playerStates.get(o.id);
+ return s && s.alive && !s.finished;
+ });
+ if (activeOccs.length === 0) return events;
+
+ // Target nearest visible player.
+ let target = null;
+ let bestDist = Infinity;
+ for (const occ of activeOccs) {
+ const dist = this.monster.distanceTo(occ.x, occ.y);
+ if (dist < bestDist && this.monster.hasLineOfSight(occ.x, occ.y, this.dungeon, this._monsterPassable.bind(this))) {
+ bestDist = dist;
+ target = occ;
+ }
+ }
+
+ const movesPerTick = this.difficultyConfig.monster.movesPerPlayerMove || 1.0;
+ let movesRemaining = movesPerTick;
+
+ while (movesRemaining >= 1) {
+ movesRemaining--;
+ const moved = this.monster.moveTowardPlayer(
+ target,
+ this.dungeon,
+ this.seededRNG
+ );
+ if (moved) {
+ events.push({ type: 'monster_move', x: this.monster.x, y: this.monster.y, tick: this.tickCount });
+
+ // Check collisions with any player on this monster tile.
+ for (const occ of activeOccs) {
+ if (occ.x === this.monster.x && occ.y === this.monster.y) {
+ this._killPlayer(occ.id, 'monster');
+ events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
+ }
+ }
+ }
+ }
+
+ return events;
+ }
+
+ _monsterPassable(tile) {
+ return tile === "'1" || tile === "'2" || tile === 0 || tile === '>' || tile === '$M';
+ }
+
+ _checkResolution() {
+ const events = [];
+ const exit = this.dungeon.exit;
+
+ for (const occ of this.occupants.values()) {
+ const state = this.playerStates.get(occ.id);
+ if (!state || !state.alive || state.finished) continue;
+
+ // Monster collision (player moved onto monster tile).
+ if (this.monster && occ.x === this.monster.x && occ.y === this.monster.y) {
+ this._killPlayer(occ.id, 'monster');
+ events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
+ continue;
+ }
+
+ // Treasure pickup.
+ if (this.treasure && this.treasure.carrierId === null &&
+ occ.x === this.treasure.x && occ.y === this.treasure.y) {
+ this.treasure.carrierId = occ.id;
+ state.hasTreasure = true;
+ events.push({ type: 'treasure_pickup', id: occ.id, x: occ.x, y: occ.y, tick: this.tickCount });
+ }
+
+ // Exit reached.
+ if (exit && occ.x === exit[0] && occ.y === exit[1]) {
+ this._finishPlayer(occ.id, true);
+ events.push({ type: 'player_exit', id: occ.id, hasTreasure: state.hasTreasure, tick: this.tickCount });
+ if (!this.winnerId) {
+ this.winnerId = occ.id;
+ this.endReason = 'escaped';
+ this.status = 'finished';
+ }
+ }
+ }
+
+ return events;
+ }
+
+ _killPlayer(id, killedBy) {
+ const state = this.playerStates.get(id);
+ const occ = this.occupants.get(id);
+ if (!state || !state.alive) return;
+ state.alive = false;
+ state.killedBy = killedBy;
+
+ // Drop treasure at corpse if carrying.
+ if (state.hasTreasure && this.treasure) {
+ this.treasure.carrierId = null;
+ this.treasure.x = occ.x;
+ this.treasure.y = occ.y;
+ state.hasTreasure = false;
+ }
+
+ if (this.activePlayerCount === 0 && this.finishCount === 0 && this.status === 'active') {
+ // Everyone died without anyone escaping.
+ this.endReason = 'all_dead';
+ this.status = 'finished';
+ }
+ }
+
+ _finishPlayer(id, escaped) {
+ const state = this.playerStates.get(id);
+ if (!state || !state.alive || state.finished) return;
+ state.finished = true;
+ state.escaped = escaped;
+ if (this.treasure && this.treasure.carrierId === id) {
+ // Treasure carried out stays with the winner's record but is removed from the map.
+ this.treasure = null;
+ }
+ }
+
+ /**
+ * Transition from 'starting' to 'active', recording the start time.
+ */
+ start() {
+ if (this.status !== 'starting') return false;
+ this.status = 'active';
+ this.startedAt = Date.now();
+ this.events.push({ type: 'match_start', tick: this.tickCount, seedHash: this.seedHash });
+ return true;
+ }
+
+ /**
+ * Force end because of a block deadline or hard ceiling. Determines winner by proximity
+ * to exit among living players, or by progress if no one is alive.
+ * @param {string} reason - 'block_deadline' | 'hard_ceiling' | 'abandoned'
+ */
+ expire(reason) {
+ if (this.status === 'finished' || this.status === 'cancelled') return;
+ this.status = 'finished';
+ this.endReason = reason;
+ this.endedAt = Date.now();
+
+ const exit = this.dungeon.exit;
+ const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
+ const occ = this.occupants.get(id);
+ const dist = exit && occ ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
+ return { id, state, occ, dist };
+ });
+
+ // Living closer to exit beats dead; then treasure; then fewer moves; then deterministic.
+ ranked.sort((a, b) => {
+ const aliveA = a.state.alive && !a.state.finished ? 1 : 0;
+ const aliveB = b.state.alive && !b.state.finished ? 1 : 0;
+ if (aliveA !== aliveB) return aliveB - aliveA;
+ if (a.dist !== b.dist) return a.dist - b.dist;
+ const tA = a.state.hasTreasure ? 1 : 0;
+ const tB = b.state.hasTreasure ? 1 : 0;
+ if (tA !== tB) return tB - tA;
+ if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
+ return a.id.localeCompare(b.id);
+ });
+
+ if (ranked.length > 0 && (ranked[0].state.alive || ranked[0].state.finished)) {
+ this.winnerId = ranked[0].id;
+ }
+
+ // Assign placements deterministically.
+ for (let i = 0; i < ranked.length; i++) {
+ ranked[i].state.placement = i + 1;
+ }
+ }
+
+ /**
+ * Calculate final scores and placements. Called once when the match is finished.
+ * @returns {Map} playerStates with score and placement populated
+ */
+ finalize() {
+ if (this.status !== 'finished') return this.playerStates;
+
+ const exit = this.dungeon.exit;
+ const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
+ const occ = this.occupants.get(id);
+ const dist = exit && occ && state.alive ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
+ return { id, state, occ, dist };
+ });
+
+ ranked.sort((a, b) => {
+ const finishedA = a.state.finished ? 1 : 0;
+ const finishedB = b.state.finished ? 1 : 0;
+ if (finishedA !== finishedB) return finishedB - finishedA;
+ const aliveA = a.state.alive ? 1 : 0;
+ const aliveB = b.state.alive ? 1 : 0;
+ if (aliveA !== aliveB) return aliveB - aliveA;
+ if (a.dist !== b.dist) return a.dist - b.dist;
+ const tA = a.state.hasTreasure ? 1 : 0;
+ const tB = b.state.hasTreasure ? 1 : 0;
+ if (tA !== tB) return tB - tA;
+ if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
+ return a.id.localeCompare(b.id);
+ });
+
+ for (let i = 0; i < ranked.length; i++) {
+ const r = ranked[i];
+ r.state.placement = i + 1;
+ r.state.score = this._calculateScore(r.state, i + 1, r.dist);
+ }
+
+ return this.playerStates;
+ }
+
+ _calculateScore(state, placement, distToExit) {
+ // Base solo-style score, then MP placement bonus.
+ let score = 100;
+ if (state.escaped) score += 200;
+ if (state.hasTreasure) score += 200;
+
+ // Speed bonus: fewer moves is better (cap at 300).
+ score += Math.max(0, 300 - Math.max(state.moves - 30, 0) * 3);
+
+ // Proximity bonus for non-escapers (closer to exit is better).
+ if (!state.escaped && Number.isFinite(distToExit)) {
+ score += Math.max(0, 100 - distToExit * 2);
+ }
+
+ // Placement bonus: winner gets 300, 2nd 200, 3rd 100, others 0.
+ const placementBonus = [300, 200, 100][placement - 1] || 0;
+ score += placementBonus;
+
+ return Math.max(0, Math.round(score));
+ }
+
+ /**
+ * Produce a renderer-agnostic game state object that the existing render-kit adapter
+ * `RK.sceneFromGameState()` can consume. This lets ASCII/Tiled/Fancy/Iso/3D modes all
+ * draw the same match with no extra renderer work.
+ */
+ toGameState(viewerId = null) {
+ const visible = [];
+ const explored = [];
+ const lighting = {};
+
+ // For simplicity, the match spectator/client receives the full dungeon (no per-player
+ // FOV in the MVP). Later we can add per-viewer FOV by running LightingAndFov here.
+ for (let y = 0; y < this.rows; y++) {
+ const row = [];
+ for (let x = 0; x < this.cols; x++) {
+ row.push(this.dungeon.map[y][x]);
+ }
+ visible.push(row);
+ explored.push(row);
+ }
+
+ return {
+ matchId: this.id,
+ economy: this.economy,
+ tick: this.tickCount,
+ status: this.status,
+ visibleTiles: visible,
+ exploredTiles: explored,
+ lighting,
+ dungeonRows: this.rows,
+ dungeonCols: this.cols,
+ players: Array.from(this.occupants.values()).map(o => {
+ const s = this.playerStates.get(o.id);
+ return {
+ id: o.id,
+ x: o.x,
+ y: o.y,
+ name: o.name,
+ avatar: o.avatar,
+ appearance: o.appearance,
+ facing: o.facing,
+ alive: s ? s.alive : true,
+ finished: s ? s.finished : false,
+ escaped: s ? s.escaped : false,
+ hasTreasure: s ? s.hasTreasure : false,
+ placement: s ? s.placement : null,
+ you: o.id === viewerId
+ };
+ }),
+ monster: this.monster ? { x: this.monster.x, y: this.monster.y } : null,
+ treasure: this.treasure ? { x: this.treasure.x, y: this.treasure.y, carrierId: this.treasure.carrierId } : null,
+ exit: this.dungeon.exit,
+ entrance: this.dungeon.entrance,
+ winnerId: this.winnerId,
+ endReason: this.endReason
+ };
+ }
+
+ /** Snapshot for the shared-world layer (includes occupants only). */
+ snapshot() {
+ return {
+ ...super.snapshot(),
+ economy: this.economy,
+ variant: this.variant,
+ tick: this.tickCount,
+ status: this.status,
+ treasure: this.treasure,
+ monster: this.monster ? { x: this.monster.x, y: this.monster.y } : null,
+ winnerId: this.winnerId,
+ endReason: this.endReason
+ };
+ }
+
+ /** Full state for a joining/reconnecting player. */
+ fullState(viewerId = null) {
+ return {
+ ...this.snapshot(),
+ cols: this.cols,
+ rows: this.rows,
+ walkable: this.walkable,
+ dungeon: this.dungeon,
+ seedHash: this.seedHash,
+ gameState: this.toGameState(viewerId)
+ };
+ }
+}
+
+module.exports = MatchRoom;
diff --git a/src/multiplayer/MatchState.js b/src/multiplayer/MatchState.js
new file mode 100644
index 0000000..6995c11
--- /dev/null
+++ b/src/multiplayer/MatchState.js
@@ -0,0 +1,108 @@
+/**
+ * MatchState — serialization helpers for MatchRoom.
+ *
+ * Produces renderer-agnostic and broadcast-safe state objects. The render kit already
+ * understands the `gameState` shape from `sceneModel.js`, so the primary job here is to
+ * wrap a MatchRoom into that shape and add match-specific metadata.
+ */
+
+class MatchState {
+ /**
+ * Build a spectator/client-friendly game-state object from a MatchRoom.
+ * Compatible with `RK.sceneFromGameState()` in `html/js/render/sceneModel.js`.
+ * @param {MatchRoom} room
+ * @param {string} [viewerId] — if provided, that player is marked `you`
+ * @returns {object}
+ */
+ static toGameState(room, viewerId = null) {
+ if (!room) return null;
+ return room.toGameState(viewerId);
+ }
+
+ /**
+ * Build the shared-world snapshot used by Socket.IO broadcasts.
+ * @param {MatchRoom} room
+ * @returns {object}
+ */
+ static snapshot(room) {
+ if (!room) return null;
+ return room.snapshot();
+ }
+
+ /**
+ * Full state for a joining or reconnecting player.
+ * @param {MatchRoom} room
+ * @param {string} [viewerId]
+ * @returns {object}
+ */
+ static fullState(room, viewerId = null) {
+ if (!room) return null;
+ return room.fullState(viewerId);
+ }
+
+ /**
+ * Flatten a MatchRoom into the persistence shape for `match_events`.
+ * @param {MatchRoom} room
+ * @param {Array} events — events from the last tick (or full match)
+ * @returns {Array}
+ */
+ static toEventRows(room, events = []) {
+ return events.map(ev => ({
+ match_id: room.id,
+ tick: ev.tick ?? room.tickCount,
+ type: ev.type,
+ payload: ev
+ }));
+ }
+
+ /**
+ * Flatten a MatchRoom into the persistence shape for `match_entrants` after finalize().
+ * @param {MatchRoom} room
+ * @returns {Array}
+ */
+ static toEntrantRows(room) {
+ const rows = [];
+ for (const [id, state] of room.playerStates.entries()) {
+ rows.push({
+ match_id: room.id,
+ user_id: state.userId,
+ socket_id: id,
+ placement: state.placement,
+ escaped: state.escaped,
+ has_treasure: state.hasTreasure,
+ killed_by: state.killedBy,
+ score: state.score
+ });
+ }
+ return rows;
+ }
+
+ /**
+ * Build the persistence row for `matches` after the race ends.
+ * @param {MatchRoom} room
+ * @returns {object}
+ */
+ static toMatchRow(room) {
+ return {
+ id: room.id,
+ status: room.status,
+ economy: room.economy,
+ variant: room.variant,
+ difficulty_preset: room.difficultyPreset,
+ max_players: room.maxPlayers,
+ seed_hash: room.seedHash,
+ seed: room.status === 'finished' ? room.seed : null,
+ dungeon: room.dungeon,
+ start_block_height: room.startBlockHeight,
+ started_at: room.startedAt ? new Date(room.startedAt) : null,
+ ended_at: room.endedAt ? new Date(room.endedAt) : null,
+ entry_fee_atomic: room.economy === 'crypto_race' ? room.entryFeeAtomic || 0 : 0,
+ pot_atomic: room.economy === 'crypto_race' ? room.potAtomic || 0 : 0,
+ house_fee_atomic: room.economy === 'crypto_race' ? room.houseFeeAtomic || 0 : 0,
+ house_fee_percent: room.houseFeePercent || 0,
+ winner_user_id: room.winnerId ? room.playerStates.get(room.winnerId)?.userId : null
+ };
+ }
+}
+
+module.exports = MatchState;
diff --git a/src/network/identityService.js b/src/network/identityService.js
index 323ce06..5dd2794 100644
--- a/src/network/identityService.js
+++ b/src/network/identityService.js
@@ -107,6 +107,25 @@ class IdentityService {
this.sessionManager.sessions.set(socketId, user);
}
}
+
+ // -------------------------------------------------------------------------
+ // Helpers for match mode (user lookup by stable id, not socket)
+ // -------------------------------------------------------------------------
+
+ async userForId(userId) {
+ if (!this.db || userId == null) return null;
+ try {
+ const result = await this.db.query(`
+ SELECT * FROM users WHERE id = $1 LIMIT 1
+ `, [userId]);
+ return result.rows && result.rows[0] ? result.rows[0] : null;
+ } catch (err) {
+ if (this.debugManager?.CONSOLE_LOGGING) {
+ console.warn('[IdentityService] userForId failed:', err.message);
+ }
+ return null;
+ }
+ }
}
module.exports = IdentityService;
diff --git a/src/network/matchLeaderboard.js b/src/network/matchLeaderboard.js
new file mode 100644
index 0000000..6e95bb6
--- /dev/null
+++ b/src/network/matchLeaderboard.js
@@ -0,0 +1,116 @@
+/**
+ * MatchLeaderboard — post-race leaderboard integration.
+ *
+ * At the end of every match, this module decides which leaderboard each entrant's score
+ * belongs to:
+ * • free races → Pleb board (game_mode = 'FREE')
+ * • credits_prestige races → Prestige board (prestige_leaderboard view)
+ * • crypto_race races → Hall of Champions (games-like paid board)
+ *
+ * For free and crypto_race we insert a synthetic `games` row if the instance wants those
+ * scores surfaced on the existing boards. For prestige we rely on the `prestige_leaderboard`
+ * view driven directly from `match_entrants`.
+ */
+
+class MatchLeaderboard {
+ constructor({ db, io = null, debugManager = null } = {}) {
+ this.db = db;
+ this.io = io;
+ this.debugManager = debugManager;
+ }
+
+ _log(...args) {
+ if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
+ }
+
+ /**
+ * Persist scores and emit leaderboard_update where appropriate.
+ * @param {MatchRoom} room
+ */
+ async postMatch(room) {
+ if (!this.db || room.status !== 'finished') return;
+
+ const economy = room.economy;
+
+ if (economy === 'free') {
+ await this._postFreeToPleb(room);
+ } else if (economy === 'credits_prestige') {
+ await this._postPrestige(room);
+ } else if (economy === 'crypto_race') {
+ await this._postCryptoRace(room);
+ }
+ }
+
+ async _postFreeToPleb(room) {
+ // Insert synthetic solo-style records so free races show up on the Pleb board.
+ await this.db.withTransaction(async (client) => {
+ for (const [socketId, state] of room.playerStates.entries()) {
+ if (!state.userId || state.score <= 0) continue;
+ await client.query(`
+ INSERT INTO games (user_id, socket_id, game_mode, status, outcome, has_treasure, moves, duration_seconds, score, dungeon_seed, created_at, completed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
+ `, [
+ state.userId,
+ socketId,
+ 'FREE',
+ state.escaped ? 'won' : 'lost',
+ state.escaped ? 'escaped' : (state.killedBy || 'timeout'),
+ state.hasTreasure,
+ state.moves,
+ Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
+ state.score,
+ room.seedHash
+ ]);
+
+ await client.query(`
+ UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1) WHERE id = $2
+ `, [state.score, state.userId]);
+ }
+ });
+
+ this._broadcastUpdate('pleb');
+ }
+
+ async _postPrestige(room) {
+ // Scores are already persisted to match_entrants. The prestige_leaderboard view reads
+ // them directly, so we just broadcast an update so open leaderboards refresh.
+ this._broadcastUpdate('prestige');
+ }
+
+ async _postCryptoRace(room) {
+ // Insert synthetic paid records for the Hall of Champions board.
+ await this.db.withTransaction(async (client) => {
+ for (const [socketId, state] of room.playerStates.entries()) {
+ if (!state.userId || state.score <= 0) continue;
+ await client.query(`
+ INSERT INTO games (user_id, socket_id, game_mode, status, outcome, has_treasure, moves, duration_seconds, score, dungeon_seed, created_at, completed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
+ `, [
+ state.userId,
+ socketId,
+ 'PAID_CREDITS',
+ state.escaped ? 'won' : 'lost',
+ state.escaped ? 'escaped' : (state.killedBy || 'timeout'),
+ state.hasTreasure,
+ state.moves,
+ Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
+ state.score,
+ room.seedHash
+ ]);
+
+ await client.query(`
+ UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1) WHERE id = $2
+ `, [state.score, state.userId]);
+ }
+ });
+
+ this._broadcastUpdate('champions');
+ }
+
+ _broadcastUpdate(board) {
+ if (!this.io) return;
+ this.io.emit('leaderboard_update', { board, at: Date.now() });
+ }
+}
+
+module.exports = MatchLeaderboard;
diff --git a/src/network/matchManager.js b/src/network/matchManager.js
new file mode 100644
index 0000000..b950732
--- /dev/null
+++ b/src/network/matchManager.js
@@ -0,0 +1,438 @@
+/**
+ * MatchManager — Socket.IO transport and lifecycle for match mode.
+ *
+ * Owns active MatchRooms, their MatchEngines, socket-to-match mapping, and broadcasts.
+ * Also handles reconnect grace (a disconnected player can rejoin the same match within a
+ * short window).
+ */
+
+const { v4: uuidv4 } = require('uuid');
+const MatchRoom = require('../multiplayer/MatchRoom');
+const MatchEngine = require('../multiplayer/MatchEngine');
+const MatchState = require('../multiplayer/MatchState');
+const MatchPayoutService = require('./matchPayoutService');
+const MatchLeaderboard = require('./matchLeaderboard');
+
+const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
+
+const RECONNECT_GRACE_MS = 30000; // 30s to reconnect during an active match
+
+class MatchManager {
+ constructor({ io, db, debugManager, identityService = null, chatProvider = null } = {}) {
+ this.io = io;
+ this.db = db;
+ this.debugManager = debugManager;
+ this.identityService = identityService;
+ this.chatProvider = chatProvider;
+ this.enabled = process.env.MATCH_ENABLED === 'true';
+
+ this.rooms = new Map(); // matchId -> MatchRoom
+ this.engines = new Map(); // matchId -> MatchEngine
+ this.socketToMatch = new Map(); // socketId -> matchId
+ this.userToMatch = new Map(); // userId -> matchId
+ this.disconnectTimeouts = new Map(); // matchId:socketId -> timeout id
+ this.matchLeaderboard = new MatchLeaderboard({ db: this.db, io: this.io, debugManager: this.debugManager });
+ this.matchPayoutService = new MatchPayoutService({
+ db: this.db,
+ walletService: null,
+ gameModeManager: null,
+ debugManager: this.debugManager
+ });
+ this.gameModeManager = null; // set via attach options
+
+ // Last-known movement per socket for flood control.
+ this._lastMoveAt = new Map();
+ this.moveCooldownMs = 60;
+ }
+
+ _log(...args) {
+ if (this.debugManager?.CONSOLE_LOGGING || CONSOLE_LOGGING) console.log(...args);
+ }
+
+ /**
+ * Attach a MatchRoom that was just created by MatchScheduler. The entrants array
+ * contains { userId, socketId, sessionToken } objects from the queue.
+ */
+ attach(room, entrants, { tickMs = 250, minDurationMs = 20000, hardCeilingMs = 240000, db = null, gameModeManager = null } = {}) {
+ if (!this.enabled) return;
+ if (db) this.db = db;
+ this.gameModeManager = gameModeManager;
+ if (gameModeManager && this.matchPayoutService) this.matchPayoutService.gameModeManager = gameModeManager;
+ this.rooms.set(room.id, room);
+ for (const e of entrants) {
+ this.socketToMatch.set(e.socketId, room.id);
+ this.userToMatch.set(e.userId, room.id);
+ }
+
+ // Resolve display names and appearances from identity service (best-effort).
+ this._hydrateEntrants(room, entrants).catch(err => this._log('[MatchManager] hydrate error', err.message));
+
+ this._log(`[MatchManager] attached match ${room.id} with ${entrants.length} entrants`);
+
+ // Notify players.
+ for (const e of entrants) {
+ const socket = this.io.sockets.sockets.get(e.socketId);
+ if (socket) {
+ socket.join(this._channel(room.id));
+ socket.emit('match_joined', {
+ matchId: room.id,
+ economy: room.economy,
+ seedHash: room.seedHash,
+ players: Array.from(room.occupants.values()).map(o => o.getState()),
+ countdownMs: 3000
+ });
+ }
+ }
+
+ // Start countdown before first tick.
+ setTimeout(() => {
+ room.start();
+ this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: room.seedHash });
+ }, 3000);
+
+ // Minimum-duration watchdog: prevent next-block expiry before floor.
+ const floorTimeout = setTimeout(() => {
+ room._minDurationMet = true;
+ }, minDurationMs);
+
+ // Hard ceiling watchdog.
+ const ceilingTimeout = setTimeout(() => this.expire(room.id, 'hard_ceiling'), hardCeilingMs);
+
+ room._watchdogs = [floorTimeout, ceilingTimeout];
+ }
+
+ async _hydrateEntrants(room, entrants) {
+ if (!this.identityService) return;
+ for (const e of entrants) {
+ try {
+ const user = await this.identityService.userForId(e.userId);
+ if (user) {
+ const entitlements = await this.identityService.entitlementsForUser(user);
+ const appearance = require('../multiplayer/entitlements').normalizeAppearance(
+ require('../multiplayer/appearance').parseAppearance(user.appearance),
+ entitlements
+ );
+ const occ = room.occupants.get(e.socketId);
+ if (occ) {
+ occ.name = user.display_name || user.username || null;
+ occ.avatar = appearance.avatar;
+ occ.appearance = appearance;
+ }
+ }
+ } catch (err) {
+ this._log('[MatchManager] hydrate entrant error', err.message);
+ }
+ }
+ }
+
+ setEngine(matchId, engine) {
+ this.engines.set(matchId, engine);
+ }
+
+ _channel(matchId) {
+ return `match:${matchId}`;
+ }
+
+ _broadcast(matchId, event, payload) {
+ if (!this.io) return;
+ this.io.to(this._channel(matchId)).emit(event, payload);
+ }
+
+ /**
+ * Called by MatchEngine every tick.
+ */
+ onTick(matchId, result) {
+ const room = this.rooms.get(matchId);
+ if (!room) return;
+ this._broadcast(matchId, 'match_tick', {
+ matchId,
+ tick: result.tick,
+ events: result.events,
+ state: room.toGameState()
+ });
+ }
+
+ /**
+ * Called when a match ends naturally.
+ */
+ async onFinish(room) {
+ await this._finalize(room);
+ }
+
+ /**
+ * Force-end a match (block deadline, hard ceiling, admin abort).
+ */
+ expire(matchId, reason) {
+ const engine = this.engines.get(matchId);
+ const room = this.rooms.get(matchId);
+ if (!room) return;
+ if (engine) {
+ engine.expire(reason);
+ } else {
+ room.expire(reason);
+ room.finalize();
+ this._finalize(room).catch(err => this._log('[MatchManager] finalize error', err.message));
+ }
+ }
+
+ async _finalize(room) {
+ if (room._finalized) return;
+ room._finalized = true;
+
+ // Clear watchdogs.
+ if (room._watchdogs) room._watchdogs.forEach(id => clearTimeout(id));
+
+ // Persist results.
+ if (this.db) {
+ await this._persistFinish(room);
+ }
+ await this.matchLeaderboard.postMatch(room);
+ if (room.economy === 'crypto_race') {
+ await this.matchPayoutService.payoutWinner(room);
+ }
+
+ // Notify clients.
+ this._broadcast(room.id, 'match_end', {
+ matchId: room.id,
+ reason: room.endReason,
+ winnerId: room.winnerId,
+ players: Array.from(room.playerStates.entries()).map(([id, state]) => {
+ const occ = room.occupants.get(id);
+ return {
+ id,
+ name: occ?.name || null,
+ placement: state.placement,
+ escaped: state.escaped,
+ hasTreasure: state.hasTreasure,
+ score: state.score,
+ killedBy: state.killedBy
+ };
+ })
+ });
+
+ // Clean up mappings after a grace period so late reconnects still get the result.
+ setTimeout(() => this._cleanup(room.id), 30000);
+ }
+
+ async _persistFinish(room) {
+ await this.db.withTransaction(async (client) => {
+ await client.query(`
+ UPDATE matches
+ SET status = 'finished',
+ seed = $1,
+ end_block_height = $2,
+ started_at = COALESCE(started_at, $3),
+ ended_at = NOW(),
+ winner_user_id = $4
+ WHERE id = $5
+ `, [
+ room.seed,
+ room.endBlockHeight || null,
+ room.startedAt ? new Date(room.startedAt) : null,
+ room.winnerId ? room.playerStates.get(room.winnerId)?.userId : null,
+ room.id
+ ]);
+
+ for (const [socketId, state] of room.playerStates.entries()) {
+ await client.query(`
+ UPDATE match_entrants
+ SET placement = $1,
+ escaped = $2,
+ has_treasure = $3,
+ killed_by = $4,
+ score = $5,
+ entry_consumed = TRUE
+ WHERE match_id = $6 AND socket_id = $7
+ `, [state.placement, state.escaped, state.hasTreasure, state.killedBy, state.score, room.id, socketId]);
+ }
+
+ // Persist event log.
+ const rows = MatchState.toEventRows(room, room.events);
+ for (const row of rows) {
+ await client.query(`
+ INSERT INTO match_events (match_id, tick, type, payload)
+ VALUES ($1, $2, $3, $4)
+ `, [row.match_id, row.tick, row.type, JSON.stringify(row.payload)]);
+ }
+ });
+ }
+
+ _cleanup(matchId) {
+ const room = this.rooms.get(matchId);
+ if (room) {
+ for (const occ of room.occupants.values()) {
+ this.socketToMatch.delete(occ.id);
+ }
+ for (const [uid] of room.playerStates.entries()) {
+ // userToMatch is keyed by socketId today; cleanup is best-effort.
+ }
+ }
+ this.rooms.delete(matchId);
+ this.engines.delete(matchId);
+ }
+
+ /**
+ * Socket.IO handler: move request.
+ */
+ move(socket, data) {
+ if (!this.enabled) return;
+ if (db) this.db = db;
+ this.gameModeManager = gameModeManager;
+ if (gameModeManager && this.matchPayoutService) this.matchPayoutService.gameModeManager = gameModeManager;
+ const now = Date.now();
+ const last = this._lastMoveAt.get(socket.id) || 0;
+ if (now - last < this.moveCooldownMs) return;
+ this._lastMoveAt.set(socket.id, now);
+
+ const matchId = this.socketToMatch.get(socket.id);
+ if (!matchId) return;
+ const room = this.rooms.get(matchId);
+ if (!room) return;
+
+ const dx = Math.sign(Number(data.dx) || 0);
+ const dy = Math.sign(Number(data.dy) || 0);
+ if (Math.abs(dx) + Math.abs(dy) !== 1) return;
+
+ room.queueMove(socket.id, dx, dy);
+ }
+
+ /**
+ * Socket.IO handler: leave an active match (forfeit).
+ */
+ leave(socket) {
+ const matchId = this.socketToMatch.get(socket.id);
+ if (!matchId) return;
+ const room = this.rooms.get(matchId);
+ if (!room) return;
+
+ room._killPlayer(socket.id, 'forfeit');
+ this._broadcast(matchId, 'player_forfeit', { id: socket.id });
+
+ if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') {
+ room.expire('all_forfeit');
+ const engine = this.engines.get(matchId);
+ if (engine) engine.stop();
+ room.finalize();
+ this._finalize(room).catch(err => this._log('[MatchManager] forfeit finalize error', err.message));
+ }
+ }
+
+ /**
+ * Handle disconnect: start grace timer, then mark AFK-dead if not reconnected.
+ */
+ handleDisconnect(socketId, userId) {
+ const matchId = this.socketToMatch.get(socketId);
+ if (!matchId) return;
+
+ const key = `${matchId}:${socketId}`;
+ if (this.disconnectTimeouts.has(key)) return;
+
+ const timeout = setTimeout(() => {
+ this.disconnectTimeouts.delete(key);
+ const room = this.rooms.get(matchId);
+ if (!room) return;
+ const state = room.playerStates.get(socketId);
+ if (state && state.alive && !state.finished) {
+ room._killPlayer(socketId, 'afk');
+ this._broadcast(matchId, 'player_death', { id: socketId, killedBy: 'afk' });
+ if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') {
+ room.expire('all_dead');
+ const engine = this.engines.get(matchId);
+ if (engine) engine.stop();
+ room.finalize();
+ this._finalize(room).catch(err => this._log('[MatchManager] afk finalize error', err.message));
+ }
+ }
+ }, RECONNECT_GRACE_MS);
+
+ this.disconnectTimeouts.set(key, timeout);
+ }
+
+ /**
+ * Handle reconnect: reattach socket to active match.
+ */
+ handleReconnect(socket, userId, sessionToken) {
+ let matchId = this.socketToMatch.get(socket.id);
+ if (!matchId) {
+ // Old socket gone; try user-to-match map.
+ matchId = this.userToMatch.get(userId);
+ }
+ if (!matchId) return false;
+
+ const room = this.rooms.get(matchId);
+ if (!room) return false;
+
+ // Cancel disconnect timeout.
+ const oldKey = `${matchId}:${socket.id}`;
+ const timeout = this.disconnectTimeouts.get(oldKey);
+ if (timeout) {
+ clearTimeout(timeout);
+ this.disconnectTimeouts.delete(oldKey);
+ }
+
+ // Update mappings if the socket id changed.
+ this.socketToMatch.set(socket.id, matchId);
+ socket.join(this._channel(matchId));
+
+ // Update occupant id if it differs (e.g. socket reconnect).
+ const occ = room.occupants.get(socket.id);
+ if (!occ) {
+ // The old socket id may be the key in the room. Try to migrate by userId.
+ for (const [oldId, state] of room.playerStates.entries()) {
+ if (state.userId === userId) {
+ const oldOcc = room.occupants.get(oldId);
+ if (oldOcc) {
+ room.occupants.delete(oldId);
+ room.occupants.set(socket.id, oldOcc);
+ this.socketToMatch.delete(oldId);
+ this.socketToMatch.set(socket.id, matchId);
+ }
+ break;
+ }
+ }
+ }
+
+ // Send full state.
+ socket.emit('match_rejoined', {
+ matchId,
+ state: room.toGameState(socket.id)
+ });
+
+ return true;
+ }
+
+ /**
+ * Public stats for admin/health endpoints.
+ */
+ getStats() {
+ const active = [];
+ for (const [id, room] of this.rooms.entries()) {
+ active.push({
+ id,
+ economy: room.economy,
+ status: room.status,
+ tick: room.tickCount,
+ players: room.occupants.size,
+ active: room.activePlayerCount,
+ finished: room.finishCount
+ });
+ }
+ return { activeMatches: active.length, matches: active };
+ }
+
+ shutdown() {
+ for (const [key, timeout] of this.disconnectTimeouts.entries()) {
+ clearTimeout(timeout);
+ }
+ this.disconnectTimeouts.clear();
+ for (const [id, room] of this.rooms.entries()) {
+ if (room._watchdogs) room._watchdogs.forEach(t => clearTimeout(t));
+ const engine = this.engines.get(id);
+ if (engine) engine.stop();
+ }
+ this.rooms.clear();
+ this.engines.clear();
+ }
+}
+
+module.exports = MatchManager;
diff --git a/src/network/matchPayoutService.js b/src/network/matchPayoutService.js
new file mode 100644
index 0000000..b3f772f
--- /dev/null
+++ b/src/network/matchPayoutService.js
@@ -0,0 +1,157 @@
+/**
+ * MatchPayoutService — crypto-race pot accounting and winner payout.
+ *
+ * On match start: convert held tickets to consumed entries and lock the pot.
+ * On match end: calculate house fee, create one pending payout for the winner,
+ * then schedule batch processing via the existing GameModeManager path.
+ *
+ * This service does not send on-chain transactions itself; it reuses the existing
+ * payout retry / batching machinery in GameModeManager.
+ */
+
+const { normalizeError } = require('../utils/errors');
+
+class MatchPayoutService {
+ constructor({ db, walletService, gameModeManager, debugManager } = {}) {
+ this.db = db;
+ this.walletService = walletService;
+ this.gameModeManager = gameModeManager;
+ this.debugManager = debugManager;
+ }
+
+ _log(...args) {
+ if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
+ }
+
+ /**
+ * Called by MatchScheduler when a crypto_race match is created.
+ * Atomically converts each entrant's queue_join ticket into a consumed match ticket,
+ * computes pot/house fee, and stores it on the match row.
+ */
+ async collectEntryTickets(room, entrants) {
+ if (!this.db || room.economy !== 'crypto_race') return;
+
+ const entryFee = this._entryFee();
+ const houseFeePercent = this._houseFeePercent();
+ const pot = entryFee * entrants.length;
+ const houseFee = Math.floor((pot * houseFeePercent) / 100);
+
+ await this.db.withTransaction(async (client) => {
+ for (const entrant of entrants) {
+ // Convert the held ticket to consumed.
+ await client.query(`
+ INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, match_id)
+ VALUES ($1, 0, (
+ SELECT race_entries FROM users WHERE id = $1
+ ), 'match_start', $2)
+ `, [entrant.userId, room.id]);
+
+ // Update match_entrant to consumed.
+ await client.query(`
+ UPDATE match_entrants
+ SET entry_consumed = TRUE
+ WHERE match_id = $1 AND user_id = $2
+ `, [room.id, entrant.userId]);
+ }
+
+ // Lock pot/house fee on the match row.
+ await client.query(`
+ UPDATE matches
+ SET entry_fee_atomic = $1,
+ pot_atomic = $2,
+ house_fee_atomic = $3,
+ house_fee_percent = $4
+ WHERE id = $5
+ `, [entryFee, pot, houseFee, houseFeePercent, room.id]);
+ });
+
+ room.entryFeeAtomic = entryFee;
+ room.potAtomic = pot;
+ room.houseFeeAtomic = houseFee;
+ room.houseFeePercent = houseFeePercent;
+
+ this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}) for match ${room.id}`);
+ }
+
+ /**
+ * Called by MatchManager when a crypto_race match finishes.
+ * Creates one pending payout for the winner and hands it to the batch processor.
+ */
+ async payoutWinner(room) {
+ if (!this.db || room.economy !== 'crypto_race' || !room.winnerId) return;
+
+ const winnerState = room.playerStates.get(room.winnerId);
+ if (!winnerState || !winnerState.userId) return;
+
+ const matchRow = await this.db.query(`
+ SELECT pot_atomic, house_fee_atomic
+ FROM matches
+ WHERE id = $1
+ `, [room.id]);
+ if (matchRow.rows.length === 0) return;
+
+ const pot = BigInt(matchRow.rows[0].pot_atomic || 0);
+ const houseFee = BigInt(matchRow.rows[0].house_fee_atomic || 0);
+ const winnerAmount = pot - houseFee;
+
+ if (winnerAmount <= 0) {
+ this._log(`[MatchPayoutService] no winner payout for match ${room.id} (pot=${pot} fee=${houseFee})`);
+ return;
+ }
+
+ // Resolve payout address from user record.
+ const userResult = await this.db.query(`
+ SELECT id, payout_address
+ FROM users
+ WHERE id = $1
+ LIMIT 1
+ `, [winnerState.userId]);
+ const user = userResult.rows[0];
+ if (!user || !user.payout_address) {
+ this._log(`[MatchPayoutService] winner has no payout address; match ${room.id} payout deferred`);
+ return;
+ }
+
+ try {
+ await this.db.withTransaction(async (client) => {
+ // Guard against double payout.
+ const existing = await client.query(`
+ SELECT id FROM payouts
+ WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed')
+ FOR UPDATE
+ `, [room.id]);
+ if (existing.rows.length > 0) {
+ throw Object.assign(new Error('Payout already exists for this match'), { code: 'PAYOUT_EXISTS' });
+ }
+
+ await client.query(`
+ INSERT INTO payouts (user_id, match_id, payout_address, amount, multiplier, reason, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
+ `, [user.id, room.id, user.payout_address, winnerAmount.toString(), 0, 'match_winner', 'pending']);
+ });
+
+ // Trigger the existing batch payout processor.
+ if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
+ this.gameModeManager._scheduleBatchPayout();
+ }
+
+ this._log(`[MatchPayoutService] queued winner payout ${winnerAmount} for match ${room.id}`);
+ } catch (err) {
+ const normalized = normalizeError(err, 'Failed to queue match payout');
+ this._log('[MatchPayoutService] payout error:', normalized.message);
+ }
+ }
+
+ _entryFee() {
+ const fee = this.gameModeManager?.singleGamePrice || process.env.MATCH_ENTRY_FEE_ATOMIC || 5000000000;
+ const parsed = Number(fee);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
+ }
+
+ _houseFeePercent() {
+ const v = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT);
+ return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 5;
+ }
+}
+
+module.exports = MatchPayoutService;
diff --git a/src/network/matchQueue.js b/src/network/matchQueue.js
new file mode 100644
index 0000000..0dce2d6
--- /dev/null
+++ b/src/network/matchQueue.js
@@ -0,0 +1,383 @@
+/**
+ * MatchQueue — persisted, per-economy queue for match mode.
+ *
+ * Responsibilities:
+ * • Join/leave a queue for each economy (free, credits_prestige, crypto_race).
+ * • Atomically deduct credits or tickets into escrow on join; refund on leave.
+ * • List queued players for a given economy.
+ * • Drain a full economy bucket into a match at block time.
+ *
+ * The queue is backed by `match_queue_entries` so state survives server restarts and
+ * disconnects. In-memory maps are rebuilt on initialization.
+ *
+ * This module has no Socket.IO coupling; MatchManager owns transport.
+ */
+
+const { normalizeError } = require('../utils/errors');
+
+const ECONOMIES = Object.freeze(['free', 'credits_prestige', 'crypto_race']);
+const MATCH_ECONOMY_SET = new Set(ECONOMIES);
+
+class MatchQueue {
+ constructor({ db, gameModeManager = null, debugManager = null } = {}) {
+ this.db = db;
+ this.gameModeManager = gameModeManager;
+ this.debugManager = debugManager;
+ this.enabled = process.env.MATCH_ENABLED === 'true';
+
+ // In-memory hot state: economy -> array of queue entries (for fast scheduling).
+ // Rebuilt from DB on initialize().
+ this._queues = { free: [], credits_prestige: [], crypto_race: [] };
+ }
+
+ _log(...args) {
+ if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
+ }
+
+ async initialize() {
+ if (!this.enabled || !this.db) return;
+
+ // Cancel any stale matched-but-not-started entries (server crashed during drain).
+ await this.db.query(`
+ UPDATE match_queue_entries
+ SET status = 'cancelled', match_id = NULL
+ WHERE status = 'matched'
+ AND created_at < NOW() - INTERVAL '5 minutes'
+ `);
+
+ // Refund queued-but-stale entries older than 24 hours.
+ await this.db.query(`
+ UPDATE match_queue_entries
+ SET status = 'cancelled'
+ WHERE status = 'queued'
+ AND created_at < NOW() - INTERVAL '24 hours'
+ `);
+
+ // Rebuild in-memory state.
+ const result = await this.db.query(`
+ SELECT id, user_id, economy, socket_id, session_token, created_at
+ FROM match_queue_entries
+ WHERE status = 'queued'
+ ORDER BY created_at ASC
+ `);
+
+ for (const row of result.rows || []) {
+ if (!this._queues[row.economy]) continue;
+ this._queues[row.economy].push({
+ queueEntryId: row.id,
+ userId: row.user_id,
+ socketId: row.socket_id,
+ sessionToken: row.session_token,
+ createdAt: new Date(row.created_at).getTime()
+ });
+ }
+
+ this._log(`[MatchQueue] Initialized: ${this.length('free')} free, ${this.length('credits_prestige')} prestige, ${this.length('crypto_race')} crypto`);
+ }
+
+ _validateEconomy(economy) {
+ return MATCH_ECONOMY_SET.has(economy) ? economy : null;
+ }
+
+ isEnabled() {
+ return this.enabled;
+ }
+
+ length(economy) {
+ return this._queues[economy]?.length || 0;
+ }
+
+ snapshot(economy) {
+ if (!this._validateEconomy(economy)) return [];
+ return this._queues[economy].slice();
+ }
+
+ /**
+ * Join a queue. For paid economies the cost is held in escrow.
+ * @param {object} entry
+ * @param {number} entry.userId
+ * @param {string} entry.socketId
+ * @param {string} entry.sessionToken
+ * @param {string} entry.economy
+ * @param {object} [entry.appearance]
+ * @returns {Promise<{success:boolean, reason?:string, position?:number}>}
+ */
+ async join(entry) {
+ if (!this.enabled) return { success: false, reason: 'match_disabled' };
+ const economy = this._validateEconomy(entry.economy);
+ if (!economy) return { success: false, reason: 'invalid_economy' };
+
+ try {
+ if (economy === 'free') {
+ return await this._joinFree(entry);
+ }
+ if (economy === 'credits_prestige') {
+ return await this._joinCreditsPrestige(entry);
+ }
+ return await this._joinCryptoRace(entry);
+ } catch (err) {
+ const normalized = normalizeError(err, 'Failed to join match queue');
+ this._log('[MatchQueue] join error:', normalized.message);
+ return { success: false, reason: normalized.safeMessage || 'join_failed' };
+ }
+ }
+
+ async _insertQueueEntry(client, entry, economy) {
+ const result = await client.query(`
+ INSERT INTO match_queue_entries (user_id, economy, socket_id, session_token, status)
+ VALUES ($1, $2, $3, $4, 'queued')
+ ON CONFLICT (user_id, economy) WHERE status = 'queued'
+ DO UPDATE SET socket_id = EXCLUDED.socket_id,
+ session_token = EXCLUDED.session_token,
+ created_at = NOW()
+ RETURNING id, created_at
+ `, [entry.userId, economy, entry.socketId, entry.sessionToken]);
+ return result.rows[0];
+ }
+
+ async _joinFree(entry) {
+ await this.db.withTransaction(async (client) => {
+ const dbRow = await this._insertQueueEntry(client, entry, 'free');
+ entry.queueEntryId = dbRow.id;
+ entry.createdAt = new Date(dbRow.created_at).getTime();
+ });
+ this._addToMemory('free', entry);
+ const position = this._queues.free.length;
+ this._log(`[MatchQueue] free join user=${entry.userId} pos=${position}`);
+ return { success: true, position };
+ }
+
+ async _joinCreditsPrestige(entry) {
+ const cost = this._creditsCost();
+
+ await this.db.withTransaction(async (client) => {
+ // 1. Deduct credits atomically.
+ const creditResult = await client.query(`
+ UPDATE users
+ SET credits = credits - $1
+ WHERE id = $2 AND credits >= $1
+ RETURNING credits
+ `, [cost, entry.userId]);
+
+ if (creditResult.rowCount === 0) {
+ throw Object.assign(new Error('Insufficient credits'), { code: 'INSUFFICIENT_CREDITS' });
+ }
+
+ const balanceAfter = creditResult.rows[0].credits;
+
+ // 2. Log credit transaction.
+ await client.query(`
+ INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata)
+ VALUES ($1, $2, 'match_queue_join', $3, 'match', $4)
+ `, [entry.userId, -cost, balanceAfter, JSON.stringify({ economy: 'credits_prestige' })]);
+
+ // 3. Insert queue entry.
+ const dbRow = await this._insertQueueEntry(client, entry, 'credits_prestige');
+ entry.queueEntryId = dbRow.id;
+ entry.createdAt = new Date(dbRow.created_at).getTime();
+ entry.cost = cost;
+ });
+
+ this._addToMemory('credits_prestige', entry);
+ const position = this._queues.credits_prestige.length;
+ this._log(`[MatchQueue] credits_prestige join user=${entry.userId} pos=${position}`);
+ return { success: true, position };
+ }
+
+ async _joinCryptoRace(entry) {
+ await this.db.withTransaction(async (client) => {
+ // 1. Consume one race entry ticket atomically.
+ const ticketResult = await client.query(`
+ UPDATE users
+ SET race_entries = race_entries - 1
+ WHERE id = $1 AND race_entries >= 1
+ RETURNING race_entries
+ `, [entry.userId]);
+
+ if (ticketResult.rowCount === 0) {
+ throw Object.assign(new Error('No race entry ticket'), { code: 'NO_RACE_TICKET' });
+ }
+
+ const balanceAfter = ticketResult.rows[0].race_entries;
+
+ // 2. Log ticket transaction (escrow).
+ await client.query(`
+ INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
+ VALUES ($1, -1, $2, 'queue_join', $3)
+ `, [entry.userId, balanceAfter, JSON.stringify({ economy: 'crypto_race' })]);
+
+ // 3. Insert queue entry.
+ const dbRow = await this._insertQueueEntry(client, entry, 'crypto_race');
+ entry.queueEntryId = dbRow.id;
+ entry.createdAt = new Date(dbRow.created_at).getTime();
+ });
+
+ this._addToMemory('crypto_race', entry);
+ const position = this._queues.crypto_race.length;
+ this._log(`[MatchQueue] crypto_race join user=${entry.userId} pos=${position}`);
+ return { success: true, position };
+ }
+
+ _addToMemory(economy, entry) {
+ const q = this._queues[economy];
+ const idx = q.findIndex(e => e.userId === entry.userId);
+ if (idx !== -1) q[idx] = entry;
+ else q.push(entry);
+ q.sort((a, b) => a.createdAt - b.createdAt);
+ }
+
+ _creditsCost() {
+ const v = parseInt(process.env.MATCH_CREDITS_COST, 10);
+ return Number.isFinite(v) && v > 0 ? v : 1;
+ }
+
+ /**
+ * Leave a queue before a match starts. Refund credits/tickets.
+ * @param {number} userId
+ * @param {string} economy
+ * @returns {Promise<{success:boolean, reason?:string}>}
+ */
+ async leave(userId, economy) {
+ if (!this.enabled) return { success: false, reason: 'match_disabled' };
+ economy = this._validateEconomy(economy);
+ if (!economy) return { success: false, reason: 'invalid_economy' };
+
+ try {
+ const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
+ const entry = memIdx >= 0 ? this._queues[economy][memIdx] : null;
+
+ await this.db.withTransaction(async (client) => {
+ const result = await client.query(`
+ DELETE FROM match_queue_entries
+ WHERE user_id = $1 AND economy = $2 AND status = 'queued'
+ RETURNING id
+ `, [userId, economy]);
+
+ if (result.rowCount === 0) {
+ throw Object.assign(new Error('Not in queue'), { code: 'NOT_QUEUED' });
+ }
+
+ if (economy === 'credits_prestige' && entry?.cost) {
+ const cost = entry.cost;
+ const creditResult = await client.query(`
+ UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits
+ `, [cost, userId]);
+ await client.query(`
+ INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata)
+ VALUES ($1, $2, 'match_queue_leave', $3, 'match', $4)
+ `, [userId, cost, creditResult.rows[0].credits, JSON.stringify({ economy: 'credits_prestige', refunded: true })]);
+ }
+
+ if (economy === 'crypto_race') {
+ const ticketResult = await client.query(`
+ UPDATE users SET race_entries = race_entries + 1 WHERE id = $1 RETURNING race_entries
+ `, [userId]);
+ await client.query(`
+ INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
+ VALUES ($1, 1, $2, 'queue_leave', $3)
+ `, [userId, ticketResult.rows[0].race_entries, JSON.stringify({ economy: 'crypto_race', refunded: true })]);
+ }
+ });
+
+ if (memIdx >= 0) this._queues[economy].splice(memIdx, 1);
+ this._log(`[MatchQueue] leave user=${userId} economy=${economy}`);
+ return { success: true };
+ } catch (err) {
+ const normalized = normalizeError(err, 'Failed to leave match queue');
+ if (normalized.code === 'NOT_QUEUED') return { success: false, reason: 'not_queued' };
+ return { success: false, reason: normalized.safeMessage || 'leave_failed' };
+ }
+ }
+
+ /**
+ * Drain queued players for an economy into a match. Marks queue entries 'matched' and
+ * returns the set of entrants. The caller creates the MatchRoom and starts it.
+ * @param {string} economy
+ * @param {number} maxPlayers
+ * @returns {Promise<{entries:Array}|null>}
+ */
+ async drain(economy, maxPlayers) {
+ economy = this._validateEconomy(economy);
+ if (!economy || this._queues[economy].length < 2) return null;
+
+ const count = Math.min(maxPlayers, this._queues[economy].length);
+ const drained = this._queues[economy].splice(0, count);
+ const userIds = drained.map(e => e.userId);
+
+ await this.db.withTransaction(async (client) => {
+ const result = await client.query(`
+ UPDATE match_queue_entries
+ SET status = 'matched'
+ WHERE user_id = ANY($1::int[]) AND economy = $2 AND status = 'queued'
+ RETURNING id, user_id
+ `, [userIds, economy]);
+
+ if (result.rowCount !== userIds.length) {
+ // Should never happen if memory state is in sync; abort.
+ throw Object.assign(
+ new Error(`Queue drain mismatch: expected ${userIds.length}, got ${result.rowCount}`),
+ { code: 'DRAIN_MISMATCH' }
+ );
+ }
+ });
+
+ this._log(`[MatchQueue] drained ${drained.length} players for ${economy}`);
+ return { entries: drained };
+ }
+
+ /**
+ * Reattach an in-memory entry after a socket reconnect. The DB row is keyed on user_id +
+ * economy, so we just update the socket_id in memory and DB.
+ * @param {number} userId
+ * @param {string} economy
+ * @param {string} newSocketId
+ * @param {string} sessionToken
+ * @returns {Promise<{inQueue:boolean, position?:number}>}
+ */
+ async reattach(userId, economy, newSocketId, sessionToken) {
+ economy = this._validateEconomy(economy);
+ if (!economy) return { inQueue: false };
+
+ const result = await this.db.query(`
+ UPDATE match_queue_entries
+ SET socket_id = $1, session_token = $2
+ WHERE user_id = $3 AND economy = $4 AND status = 'queued'
+ RETURNING id, created_at
+ `, [newSocketId, sessionToken, userId, economy]);
+
+ if (result.rowCount === 0) return { inQueue: false };
+
+ const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
+ if (memIdx >= 0) {
+ this._queues[economy][memIdx].socketId = newSocketId;
+ this._queues[economy][memIdx].sessionToken = sessionToken;
+ return { inQueue: true, position: memIdx + 1 };
+ }
+
+ // Memory was stale; rebuild this one from DB row.
+ const row = result.rows[0];
+ this._addToMemory(economy, {
+ queueEntryId: row.id,
+ userId,
+ socketId: newSocketId,
+ sessionToken,
+ createdAt: new Date(row.created_at).getTime()
+ });
+ return { inQueue: true, position: this._queues[economy].findIndex(e => e.userId === userId) + 1 };
+ }
+
+ /**
+ * Cancel all queues (e.g. server shutdown). Refunds paid entries.
+ */
+ async shutdown() {
+ if (!this.enabled || !this.db) return;
+ for (const economy of ECONOMIES) {
+ const entries = this._queues[economy].slice();
+ for (const e of entries) {
+ await this.leave(e.userId, economy).catch(() => {});
+ }
+ }
+ }
+}
+
+module.exports = MatchQueue;
diff --git a/src/network/matchScheduler.js b/src/network/matchScheduler.js
new file mode 100644
index 0000000..aadd42e
--- /dev/null
+++ b/src/network/matchScheduler.js
@@ -0,0 +1,185 @@
+/**
+ * MatchScheduler — block-cadence match maker.
+ *
+ * Subscribes to the same block event as the solo queue handler. On every new block, it drains
+ * each enabled economy's queue into a MatchRoom (if ≥2 players are queued). Single players
+ * carry over to the next block and can leave at any time.
+ *
+ * MatchScheduler creates MatchRooms but does not own Socket.IO broadcasting; it delegates
+ * the started room to MatchManager for transport, persistence, and spectator wiring.
+ */
+
+const MatchRoom = require('../multiplayer/MatchRoom');
+const MatchEngine = require('../multiplayer/MatchEngine');
+const MatchState = require('../multiplayer/MatchState');
+const MatchPayoutService = require('./matchPayoutService');
+
+const DEFAULT_MAX_PLAYERS = 4;
+const DEFAULT_TICK_MS = 250;
+const DEFAULT_MIN_DURATION_MS = 20000;
+const DEFAULT_HARD_CEILING_MS = 240000;
+
+class MatchScheduler {
+ constructor({
+ matchQueue,
+ matchManager,
+ debugManager,
+ maxPlayers = null,
+ tickMs = null,
+ minDurationMs = null,
+ hardCeilingMs = null
+ } = {}) {
+ this.matchQueue = matchQueue;
+ this.matchManager = matchManager;
+ this.debugManager = debugManager;
+ this.enabled = process.env.MATCH_ENABLED === 'true';
+
+ this.maxPlayers = Math.max(2, Math.min(32, maxPlayers || parseInt(process.env.MATCH_MAX_PLAYERS, 10) || DEFAULT_MAX_PLAYERS));
+ this.tickMs = tickMs || parseInt(process.env.MATCH_TICK_MS, 10) || DEFAULT_TICK_MS;
+ this.minDurationMs = minDurationMs || parseInt(process.env.MATCH_MIN_DURATION_MS, 10) || DEFAULT_MIN_DURATION_MS;
+ this.hardCeilingMs = hardCeilingMs || parseInt(process.env.MATCH_HARD_CEILING_MS, 10) || DEFAULT_HARD_CEILING_MS;
+
+ this._timeoutIds = [];
+ }
+
+ _log(...args) {
+ if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
+ }
+
+ /**
+ * Hook this into the existing DebugManager.onNewBlockCallback().
+ * @param {number} blockHeight
+ */
+ async onBlock(blockHeight) {
+ if (!this.enabled || !this.matchQueue || !this.matchManager) return;
+
+ for (const economy of ['free', 'credits_prestige', 'crypto_race']) {
+ await this._drainEconomy(economy, blockHeight);
+ }
+ }
+
+ async _drainEconomy(economy, blockHeight) {
+ const drain = await this.matchQueue.drain(economy, this.maxPlayers);
+ if (!drain) return;
+
+ const { entries } = drain;
+ if (entries.length < 2) {
+ // Defensive: should not happen because drain() requires ≥2, but refund just in case.
+ for (const e of entries) await this.matchQueue.leave(e.userId, economy).catch(() => {});
+ return;
+ }
+
+ this._log(`[MatchScheduler] Starting ${economy} race with ${entries.length} players at block ${blockHeight}`);
+
+ // Build entrants map for MatchRoom.
+ const entrants = {};
+ for (const e of entries) {
+ entrants[e.socketId] = {
+ userId: e.userId,
+ name: null, // name is resolved later by MatchManager
+ socketId: e.socketId,
+ sessionToken: e.sessionToken
+ };
+ }
+
+ const room = new MatchRoom({
+ economy,
+ variant: 'race',
+ difficultyPreset: 'race',
+ maxPlayers: this.maxPlayers,
+ entrants,
+ startBlockHeight: blockHeight,
+ cryptoType: process.env.CRYPTO_TYPE || 'WOW'
+ });
+
+ // Configure race-specific durations.
+ room.minDurationMs = this.minDurationMs;
+ room.hardCeilingMs = this.hardCeilingMs;
+
+ // Persist match and entrants synchronously before starting, so a crash mid-race can
+ // be recovered from the DB on next boot.
+ await this._persistMatch(room, entries, economy);
+
+ // Hand off to MatchManager for transport, reconnect mapping, and finish handling.
+ this.matchManager.attach(room, entries, {
+ db: this.matchManager.db,
+ gameModeManager: this.matchManager.gameModeManager,
+ tickMs: this.tickMs,
+ minDurationMs: this.minDurationMs,
+ hardCeilingMs: this.hardCeilingMs
+ });
+
+ if (room.economy === 'crypto_race') {
+ await this.matchPayoutService.collectEntryTickets(room, entries);
+ }
+
+ // Start the engine.
+ const engine = new MatchEngine({
+ room,
+ tickMs: this.tickMs,
+ onTick: (result) => this.matchManager.onTick(room.id, result),
+ onFinish: (finishedRoom) => this.matchManager.onFinish(finishedRoom)
+ });
+ this.matchManager.setEngine(room.id, engine);
+ engine.start();
+
+ // Hard-ceiling watchdog.
+ const ceilingId = setTimeout(() => this._expireMatch(room.id, 'hard_ceiling'), this.hardCeilingMs);
+ this._timeoutIds.push(ceilingId);
+ }
+
+ async _persistMatch(room, entries, economy) {
+ if (!this.matchManager?.db) return;
+
+ const matchRow = MatchState.toMatchRow(room);
+ // Override row defaults with the actual queued economy and status.
+ matchRow.status = 'starting';
+ matchRow.economy = economy;
+
+ await this.matchManager.db.withTransaction(async (client) => {
+ await client.query(`
+ INSERT INTO matches (
+ id, status, economy, variant, difficulty_preset, max_players,
+ seed_hash, dungeon, start_block_height, entry_fee_atomic,
+ pot_atomic, house_fee_atomic, house_fee_percent, created_at
+ ) VALUES (
+ $1, $2, $3, $4, $5, $6,
+ $7, $8, $9, $10,
+ $11, $12, $13, NOW()
+ )
+ `, [
+ matchRow.id,
+ matchRow.status,
+ matchRow.economy,
+ matchRow.variant,
+ matchRow.difficulty_preset,
+ matchRow.max_players,
+ matchRow.seed_hash,
+ JSON.stringify(matchRow.dungeon),
+ matchRow.start_block_height,
+ matchRow.entry_fee_atomic,
+ matchRow.pot_atomic,
+ matchRow.house_fee_atomic,
+ matchRow.house_fee_percent
+ ]);
+
+ for (const entrant of entries) {
+ await client.query(`
+ INSERT INTO match_entrants (match_id, user_id, socket_id)
+ VALUES ($1, $2, $3)
+ `, [room.id, entrant.userId, entrant.socketId]);
+ }
+ });
+ }
+
+ _expireMatch(roomId, reason) {
+ this.matchManager?.expire(roomId, reason);
+ }
+
+ shutdown() {
+ for (const id of this._timeoutIds) clearTimeout(id);
+ this._timeoutIds = [];
+ }
+}
+
+module.exports = MatchScheduler;
diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js
index 8bacace..adb2185 100644
--- a/src/network/socketHandlers.js
+++ b/src/network/socketHandlers.js
@@ -7,6 +7,10 @@ const user = require('../db/user');
const GameManager = require('../game/gameManager');
const MovementManager = require('../game/movementManager');
const QueueManager = require('./queueManager');
+const MatchQueue = require('./matchQueue');
+const MatchScheduler = require('./matchScheduler');
+const MatchManager = require('./matchManager');
+const TavernMatchBridge = require('./tavernMatchBridge');
const PaymentHandlers = require('./paymentHandlers');
const AddressManager = require('./addressManager');
const SessionManager = require('./sessionManager');
@@ -208,6 +212,36 @@ class SocketHandlers {
entitlementProvider: async (socket) => this._entitlementsForSocket(socket)
});
this.tavernManager.initialize();
+ // Initialize match mode queue, scheduler, and manager. Inert unless MATCH_ENABLED=true.
+ this.matchQueue = new MatchQueue({
+ db: this.gameModeManager?.db || null,
+ gameModeManager: this.gameModeManager,
+ debugManager: this.debugManager
+ });
+ this.matchManager = new MatchManager({
+ io: this.io,
+ db: this.gameModeManager?.db || null,
+ debugManager: this.debugManager,
+ identityService: this.identityService,
+ gameModeManager: this.gameModeManager
+ });
+ this.matchScheduler = new MatchScheduler({
+ matchQueue: this.matchQueue,
+ matchManager: this.matchManager,
+ debugManager: this.debugManager
+ });
+ this.matchQueue.initialize().then(() => {
+ this.matchManager.initialize?.();
+ this.tavernMatchBridge = new TavernMatchBridge({
+ matchManager: this.matchManager,
+ tavernManager: this.tavernManager,
+ io: this.io,
+ debugManager: this.debugManager
+ });
+ this.tavernMatchBridge.initialize();
+ }).catch(err => console.error('[SocketHandlers] Match queue init failed:', err));
+
+
// Initialize suspended game manager for reconnection support
this.suspendedGameManager = new SuspendedGameManager({
@@ -445,6 +479,17 @@ class SocketHandlers {
socket.on('tavern_move', (data) => this.tavernManager.move(socket, data));
socket.on('tavern_chat', (data) => this.tavernManager.chat(socket, data));
socket.on('tavern_leave', () => this.tavernManager.leave(socket));
+ // Match mode handlers
+ socket.on('match_queue', (data) => this._handleMatchQueue(socket, data));
+ socket.on('match_move', (data) => this.matchManager.move(socket, data));
+ socket.on('match_leave', () => this.matchManager.leave(socket));
+ // Tavern match spectator bridge
+ socket.on('tavern_match_list', () => {
+ const list = this.tavernMatchBridge ? this.tavernMatchBridge.getActiveMatches() : [];
+ socket.emit('tavern_match_list', list);
+ });
+
+
}
/**
@@ -810,6 +855,7 @@ class SocketHandlers {
// Clean up tavern presence
if (this.tavernManager) {
this.tavernManager.handleDisconnect(socketId);
+ this.matchManager.handleDisconnect(socketId, null);
}
// Evict the cached session row LAST (after the suspend logic above read it),
@@ -1039,6 +1085,13 @@ class SocketHandlers {
*/
async startGamesForWaiting(blockHeight) {
return await this.queueHandler.startGamesForWaiting(blockHeight);
+ // Also drain match-mode queues on the new block.
+ if (this.matchScheduler) {
+ this.matchScheduler.onBlock(blockHeight).catch(err => {
+ if (this.debugManager.CONSOLE_LOGGING) console.error('[SocketHandlers] Match scheduler block error:', err);
+ });
+ }
+
}
/**
@@ -1196,6 +1249,15 @@ class SocketHandlers {
if (this.tavernManager) {
this.tavernManager.shutdown();
}
+ if (this.matchManager) {
+ this.matchManager.shutdown();
+ }
+ if (this.matchQueue) {
+ this.matchQueue.shutdown().catch(() => {});
+ }
+ if (this.matchScheduler) {
+ this.matchScheduler.shutdown();
+ }
if (this.spectatorManager) {
this.spectatorManager.shutdown();
diff --git a/src/network/tavernMatchBridge.js b/src/network/tavernMatchBridge.js
new file mode 100644
index 0000000..b744f83
--- /dev/null
+++ b/src/network/tavernMatchBridge.js
@@ -0,0 +1,112 @@
+/**
+ * TavernMatchBridge — connects active match rooms to tavern spectators.
+ *
+ * Subscribes to MatchManager match events and forwards a lightweight, public-only
+ * snapshot of each active race to the tavern Socket.IO room. Tavern clients can then
+ * list active races and watch one without leaving the tavern.
+ *
+ * No game secrets (seed, player input) are forwarded; only public state (positions,
+ * standings, winnerId, economy) is broadcast.
+ */
+
+class TavernMatchBridge {
+ constructor({ matchManager, tavernManager, io, debugManager = null } = {}) {
+ this.matchManager = matchManager;
+ this.tavernManager = tavernManager;
+ this.io = io;
+ this.debugManager = debugManager;
+ this.enabled = process.env.TAVERN_ENABLED === 'true' && process.env.MATCH_ENABLED === 'true';
+ }
+
+ _log(...args) {
+ if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
+ }
+
+ initialize() {
+ if (!this.enabled || !this.matchManager) return;
+ // Wrap MatchManager's broadcast method so every match_tick also emits a public
+ // summary to the tavern channel.
+ const originalOnTick = this.matchManager.onTick.bind(this.matchManager);
+ this.matchManager.onTick = (matchId, result) => {
+ originalOnTick(matchId, result);
+ this._forwardTick(matchId);
+ };
+
+ const originalOnFinish = this.matchManager.onFinish.bind(this.matchManager);
+ this.matchManager.onFinish = async (room) => {
+ await originalOnFinish(room);
+ this._forwardEnd(room);
+ };
+
+ this._log('[TavernMatchBridge] initialized');
+ }
+
+ _forwardTick(matchId) {
+ const room = this.matchManager.rooms.get(matchId);
+ if (!room) return;
+ const payload = this._publicSnapshot(room);
+ this.io?.to('tavern:main').emit('tavern_match_tick', payload);
+ }
+
+ _forwardEnd(room) {
+ const payload = {
+ matchId: room.id,
+ economy: room.economy,
+ status: room.status,
+ winnerId: room.winnerId,
+ endReason: room.endReason,
+ players: Array.from(room.playerStates.entries()).map(([id, state]) => ({
+ id,
+ placement: state.placement,
+ escaped: state.escaped,
+ hasTreasure: state.hasTreasure,
+ score: state.score
+ }))
+ };
+ this.io?.to('tavern:main').emit('tavern_match_end', payload);
+ }
+
+ _publicSnapshot(room) {
+ return {
+ matchId: room.id,
+ economy: room.economy,
+ status: room.status,
+ tick: room.tickCount,
+ players: Array.from(room.occupants.values()).map(o => {
+ const s = room.playerStates.get(o.id);
+ return {
+ id: o.id,
+ x: o.x,
+ y: o.y,
+ name: o.name || null,
+ avatar: o.avatar,
+ facing: o.facing,
+ alive: s ? s.alive : true,
+ finished: s ? s.finished : false,
+ escaped: s ? s.escaped : false,
+ hasTreasure: s ? s.hasTreasure : false,
+ placement: s ? s.placement : null
+ };
+ }),
+ monster: room.monster ? { x: room.monster.x, y: room.monster.y } : null,
+ treasure: room.treasure,
+ exit: room.dungeon.exit,
+ winnerId: room.winnerId,
+ seedHash: room.seedHash
+ };
+ }
+
+ /**
+ * Return a list of currently active matches for the tavern game-list UI.
+ */
+ getActiveMatches() {
+ if (!this.matchManager) return [];
+ const list = [];
+ for (const [id, room] of this.matchManager.rooms.entries()) {
+ list.push(this._publicSnapshot(room));
+ }
+ return list;
+ }
+}
+
+module.exports = TavernMatchBridge;
diff --git a/src/payments/productGrants.js b/src/payments/productGrants.js
index 0998450..9caaea8 100644
--- a/src/payments/productGrants.js
+++ b/src/payments/productGrants.js
@@ -44,6 +44,11 @@ function normalizeProductGrants(product = {}, fallback = {}) {
}
}
+ let raceEntries = 0;
+ if (explicit.race_entries != null || explicit.raceEntries != null) {
+ raceEntries = asInt(explicit.race_entries || explicit.raceEntries, 0);
+ }
+
const packs = [];
const packInputs = Array.isArray(explicit.packs) ? explicit.packs : [];
for (const p of packInputs) {
@@ -56,6 +61,7 @@ function normalizeProductGrants(product = {}, fallback = {}) {
return {
credits: Math.max(0, credits),
packs,
+ raceEntries: Math.max(0, raceEntries),
premiumLevel: normalizePremiumLevel(explicit.premiumLevel || explicit.premium_level)
};
}
@@ -63,6 +69,7 @@ function normalizeProductGrants(product = {}, fallback = {}) {
function serializeProductGrants(grants = {}) {
return {
credits: asInt(grants.credits, 0),
+ raceEntries: asInt(grants.raceEntries || grants.race_entries, 0),
packs: Array.isArray(grants.packs)
? grants.packs.map(normalizePackGrant).filter(Boolean)
: [],
@@ -74,6 +81,7 @@ function publicGrantSummary(grants = {}) {
const normalized = serializeProductGrants(grants);
return {
credits: normalized.credits,
+ raceEntries: normalized.raceEntries,
packs: normalized.packs.map(p => p.id),
premiumLevel: normalized.premiumLevel
};
diff --git a/test/matchLeaderboard.test.js b/test/matchLeaderboard.test.js
new file mode 100644
index 0000000..6b8f7fc
--- /dev/null
+++ b/test/matchLeaderboard.test.js
@@ -0,0 +1,67 @@
+const MatchLeaderboard = require('../src/network/matchLeaderboard');
+const MatchRoom = require('../src/multiplayer/MatchRoom');
+
+describe('MatchLeaderboard', () => {
+ function makeDb() {
+ const captured = [];
+ return {
+ captured,
+ async withTransaction(fn) {
+ const client = {
+ async query(sql, params) {
+ captured.push({ sql, params });
+ return { rowCount: 1, rows: [] };
+ }
+ };
+ return await fn(client);
+ }
+ };
+ }
+
+ function makeFinishedRoom(economy) {
+ const room = new MatchRoom({
+ entrants: {
+ a: { userId: 1, name: 'A' },
+ b: { userId: 2, name: 'B' }
+ },
+ economy,
+ seed: '0000000000000000000000000000000000000000000000000000000000000000'
+ });
+ room.start();
+ room.expire('hard_ceiling');
+ room.finalize();
+ return room;
+ }
+
+ test('free race inserts synthetic FREE records', async () => {
+ const db = makeDb();
+ const lb = new MatchLeaderboard({ db });
+ const room = makeFinishedRoom('free');
+ await lb.postMatch(room);
+ const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
+ expect(gamesInserts.length).toBe(2);
+ expect(gamesInserts[0].params[2]).toBe('FREE');
+ });
+
+ test('prestige race broadcasts without inserting games', async () => {
+ const emitted = [];
+ const io = { emit: (e, p) => emitted.push({ e, p }) };
+ const db = makeDb();
+ const lb = new MatchLeaderboard({ db, io });
+ const room = makeFinishedRoom('credits_prestige');
+ await lb.postMatch(room);
+ const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
+ expect(gamesInserts.length).toBe(0);
+ expect(emitted.some(e => e.e === 'leaderboard_update' && e.p.board === 'prestige')).toBe(true);
+ });
+
+ test('crypto race inserts PAID_CREDITS records', async () => {
+ const db = makeDb();
+ const lb = new MatchLeaderboard({ db });
+ const room = makeFinishedRoom('crypto_race');
+ await lb.postMatch(room);
+ const gamesInserts = db.captured.filter(q => q.sql.includes('INSERT INTO games')) || [];
+ expect(gamesInserts.length).toBe(2);
+ expect(gamesInserts[0].params[2]).toBe('PAID_CREDITS');
+ });
+});
diff --git a/test/matchPayoutService.test.js b/test/matchPayoutService.test.js
new file mode 100644
index 0000000..2a4b500
--- /dev/null
+++ b/test/matchPayoutService.test.js
@@ -0,0 +1,67 @@
+const MatchPayoutService = require('../src/network/matchPayoutService');
+const MatchRoom = require('../src/multiplayer/MatchRoom');
+
+describe('MatchPayoutService', () => {
+ function makeDb() {
+ const captured = [];
+ return {
+ captured,
+ async query(sql, params) {
+ if (sql.includes('SELECT pot_atomic')) return { rows: [{ pot_atomic: '20000', house_fee_atomic: '1000' }] };
+ if (sql.includes('SELECT id, payout_address')) return { rows: [{ id: 1, payout_address: 'WALLET_ADDRESS' }] };
+ return { rows: [], rowCount: 0 };
+ },
+ async withTransaction(fn) {
+ const client = {
+ async query(sql, params) {
+ captured.push({ sql, params });
+ if (sql.includes('SELECT id FROM payouts')) return { rows: [] };
+ return { rows: [{ id: 99 }], rowCount: 1 };
+ }
+ };
+ return await fn(client);
+ }
+ };
+ }
+
+ function makeRoom() {
+ const room = new MatchRoom({
+ economy: 'crypto_race',
+ entrants: { a: { userId: 1 }, b: { userId: 2 } },
+ seed: '0000000000000000000000000000000000000000000000000000000000000000'
+ });
+ room.start();
+ room.playerStates.get('a').escaped = true;
+ room.winnerId = 'a';
+ room.status = 'finished';
+ room.finalize();
+ return room;
+ }
+
+ test('collectEntryTickets locks pot on match row', async () => {
+ const db = makeDb();
+ const service = new MatchPayoutService({ db });
+ const room = makeRoom();
+ await service.collectEntryTickets(room, [
+ { userId: 1, socketId: 'a' },
+ { userId: 2, socketId: 'b' }
+ ]);
+ const updateMatch = db.captured.find(q => q.sql.includes('UPDATE matches'));
+ expect(updateMatch).toBeTruthy();
+ expect(room.potAtomic).toBeGreaterThan(0);
+ });
+
+ test('payoutWinner inserts one pending payout for winner', async () => {
+ const db = makeDb();
+ const service = new MatchPayoutService({ db });
+ const room = makeRoom();
+ room.entryFeeAtomic = 10000;
+ room.potAtomic = 20000;
+ room.houseFeeAtomic = 1000;
+ await service.payoutWinner(room);
+ const insert = db.captured.find(q => q.sql.includes('INSERT INTO payouts'));
+ expect(insert).toBeTruthy();
+ expect(insert.params[3]).toBe('19000'); // winner amount
+ expect(insert.params[5]).toBe('match_winner');
+ });
+});
diff --git a/test/matchQueue.test.js b/test/matchQueue.test.js
new file mode 100644
index 0000000..ad35282
--- /dev/null
+++ b/test/matchQueue.test.js
@@ -0,0 +1,107 @@
+const MatchQueue = require('../src/network/matchQueue');
+
+describe('MatchQueue', () => {
+ function makeDb() {
+ return {
+ queries: [],
+ lastParams: null,
+ async query(sql, params) {
+ this.queries.push(sql);
+ this.lastParams = params || [];
+ // Default stub: empty rows.
+ return { rows: [], rowCount: 0 };
+ },
+ async withTransaction(fn) {
+ const client = {
+ queries: [],
+ async query(sql, params) {
+ this.queries.push({ sql, params });
+ let rows = [{ id: 1, created_at: new Date().toISOString(), credits: 100, race_entries: 1 }];
+ let rowCount = 1;
+ if (sql.includes('UPDATE match_queue_entries') && sql.includes('user_id = ANY')) {
+ const ids = params[0] || [];
+ rows = ids.map((uid, i) => ({ id: i + 10, user_id: uid }));
+ rowCount = ids.length;
+ }
+ return { rows, rowCount };
+ }
+ };
+ return await fn(client);
+ }
+ };
+ }
+
+ test('disabled when MATCH_ENABLED is not true', () => {
+ const q = new MatchQueue({});
+ expect(q.isEnabled()).toBe(false);
+ });
+
+ test('validates economy', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const q = new MatchQueue({});
+ const result = await q.join({ userId: 1, socketId: 's', sessionToken: 't', economy: 'bogus' });
+ expect(result.success).toBe(false);
+ expect(result.reason).toBe('invalid_economy');
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('free join adds to memory queue', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const db = makeDb();
+ const q = new MatchQueue({ db });
+ const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
+ expect(result.success).toBe(true);
+ expect(q.length('free')).toBe(1);
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('credits join deducts credits atomically', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ process.env.MATCH_CREDITS_COST = '5';
+ const db = makeDb();
+ const q = new MatchQueue({ db });
+ const result = await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
+ expect(result.success).toBe(true);
+ expect(q.length('credits_prestige')).toBe(1);
+ // Transaction captured in the stub client. We just verify join succeeded.
+ delete process.env.MATCH_ENABLED;
+ delete process.env.MATCH_CREDITS_COST;
+ });
+
+ test('drain requires at least two queued players', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const db = makeDb();
+ const q = new MatchQueue({ db });
+ await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
+ const drain = await q.drain('free', 4);
+ expect(drain).toBeNull();
+ expect(q.length('free')).toBe(1);
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('drain returns two+ players and clears memory queue', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const db = makeDb();
+ const q = new MatchQueue({ db });
+ await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
+ await q.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
+ const drain = await q.drain('free', 4);
+ expect(drain).toBeTruthy();
+ expect(drain.entries.length).toBe(2);
+ expect(q.length('free')).toBe(0);
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('leave refunds credits and removes from queue', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ process.env.MATCH_CREDITS_COST = '3';
+ const db = makeDb();
+ const q = new MatchQueue({ db });
+ await q.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'credits_prestige' });
+ const leave = await q.leave(1, 'credits_prestige');
+ expect(leave.success).toBe(true);
+ expect(q.length('credits_prestige')).toBe(0);
+ delete process.env.MATCH_ENABLED;
+ delete process.env.MATCH_CREDITS_COST;
+ });
+});
diff --git a/test/matchReconnect.test.js b/test/matchReconnect.test.js
new file mode 100644
index 0000000..7af8fdf
--- /dev/null
+++ b/test/matchReconnect.test.js
@@ -0,0 +1,28 @@
+const MatchRoom = require('../src/multiplayer/MatchRoom');
+
+describe('MatchRoom reconnect safety', () => {
+ test('disconnect grace does not remove player state', () => {
+ const room = new MatchRoom({
+ entrants: { a: { userId: 1 }, b: { userId: 2 } },
+ seed: '0000000000000000000000000000000000000000000000000000000000000000'
+ });
+ room.start();
+ expect(room.playerStates.get('a').alive).toBe(true);
+ // Simulate a forfeit / AFK kill.
+ room._killPlayer('a', 'afk');
+ expect(room.playerStates.get('a').alive).toBe(false);
+ expect(room.activePlayerCount).toBe(1);
+ });
+
+ test('all players dying ends match', () => {
+ const room = new MatchRoom({
+ entrants: { a: { userId: 1 }, b: { userId: 2 } },
+ seed: '0000000000000000000000000000000000000000000000000000000000000000'
+ });
+ room.start();
+ room._killPlayer('a', 'monster');
+ room._killPlayer('b', 'monster');
+ expect(room.status).toBe('finished');
+ expect(room.endReason).toBe('all_dead');
+ });
+});
diff --git a/test/matchRoom.test.js b/test/matchRoom.test.js
new file mode 100644
index 0000000..1cc4c8e
--- /dev/null
+++ b/test/matchRoom.test.js
@@ -0,0 +1,301 @@
+const MatchRoom = require('../src/multiplayer/MatchRoom');
+const MatchEngine = require('../src/multiplayer/MatchEngine');
+const MatchState = require('../src/multiplayer/MatchState');
+
+describe('MatchRoom', () => {
+ function makeRoom(opts = {}) {
+ return new MatchRoom({
+ entrants: {
+ a: { userId: 1, name: 'A' },
+ b: { userId: 2, name: 'B' }
+ },
+ difficultyPreset: 'race',
+ seed: '0000000000000000000000000000000000000000000000000000000000000000',
+ ...opts
+ });
+ }
+
+ describe('construction', () => {
+ test('generates a provably-fair seed hash', () => {
+ const room = makeRoom();
+ expect(room.seed).toHaveLength(64);
+ expect(room.seedHash).toHaveLength(64);
+ expect(room.seedHash).not.toEqual(room.seed);
+ });
+
+ test('places all entrants at the entrance', () => {
+ const room = makeRoom();
+ expect(room.occupants.size).toBe(2);
+ const [e1, e2] = Array.from(room.occupants.values());
+ expect(e1.x).toBe(room.dungeon.entrance[0]);
+ expect(e1.y).toBe(room.dungeon.entrance[1]);
+ });
+
+ test('creates one shared monster', () => {
+ const room = makeRoom();
+ expect(room.monster).toBeTruthy();
+ expect(typeof room.monster.x).toBe('number');
+ });
+
+ test('starts in starting status', () => {
+ const room = makeRoom();
+ expect(room.status).toBe('starting');
+ });
+ });
+
+ describe('movement', () => {
+ test('single player moves one tile', () => {
+ const room = makeRoom();
+ room.start();
+ const occ = room.occupants.get('a');
+ const { x, y } = occ;
+ room.queueMove('a', 1, 0);
+ const result = room.resolveTick();
+ expect(result.events.some(e => e.type === 'move' && e.id === 'a')).toBe(true);
+ expect(occ.x).toBe(x + 1);
+ expect(occ.y).toBe(y);
+ });
+
+ test('wall blocks movement', () => {
+ const room = makeRoom();
+ room.start();
+ const occ = room.occupants.get('a');
+
+ // Find any wall tile and an adjacent walkable tile, then move toward the wall.
+ let wallX = -1, wallY = -1, fromX = -1, fromY = -1;
+ for (let y = 0; y < room.rows && wallX < 0; y++) {
+ for (let x = 0; x < room.cols && wallX < 0; x++) {
+ if (!room.isWalkable(x, y)) {
+ const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
+ for (const [dx, dy] of neighbors) {
+ const nx = x + dx, ny = y + dy;
+ if (room.isWalkable(nx, ny)) {
+ wallX = x; wallY = y;
+ fromX = nx; fromY = ny;
+ break;
+ }
+ }
+ }
+ }
+ }
+ expect(wallX).toBeGreaterThanOrEqual(0);
+
+ occ.moveTo(fromX, fromY);
+ const dx = Math.sign(wallX - fromX);
+ const dy = Math.sign(wallY - fromY);
+ room.queueMove('a', dx, dy);
+ const result = room.resolveTick();
+ expect(result.events.some(e => e.type === 'move_failed' && e.id === 'a' && e.reason === 'blocked')).toBe(true);
+ expect(occ.x).toBe(fromX);
+ expect(occ.y).toBe(fromY);
+ });
+
+ test('players moving into the same tile bounce', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ const b = room.occupants.get('b');
+ // Force a simple setup: A at (2,2), B at (2,4), both walk to (2,3).
+ a.moveTo(2, 2);
+ b.moveTo(2, 4);
+ room.walkable[2][3] = true;
+ room.walkable[2][2] = true;
+ room.walkable[2][4] = true;
+
+ room.queueMove('a', 0, 1);
+ room.queueMove('b', 0, -1);
+ const result = room.resolveTick();
+ expect(result.events.filter(e => e.type === 'move_failed' && (e.id === 'a' || e.id === 'b')).length).toBeGreaterThanOrEqual(2);
+ expect(a.y).toBe(2);
+ expect(b.y).toBe(4);
+ });
+
+ test('players swapping tiles bounce (no ghosting through)', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ const b = room.occupants.get('b');
+ a.moveTo(2, 2);
+ b.moveTo(2, 3);
+ room.walkable[2][2] = true;
+ room.walkable[2][3] = true;
+
+ room.queueMove('a', 0, 1);
+ room.queueMove('b', 0, -1);
+ room.resolveTick();
+ expect(a.y).toBe(2);
+ expect(b.y).toBe(3);
+ });
+ });
+
+ describe('treasure and exits', () => {
+ test('player picking treasure marks them as carrier', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ room.treasure = { x: a.x + 1, y: a.y, carrierId: null };
+ room.walkable[a.y][a.x + 1] = true;
+ room.queueMove('a', 1, 0);
+ const result = room.resolveTick();
+ expect(result.events.some(e => e.type === 'treasure_pickup' && e.id === 'a')).toBe(true);
+ expect(room.playerStates.get('a').hasTreasure).toBe(true);
+ });
+
+ test('reaching exit finishes the match', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ const [ex, ey] = room.dungeon.exit;
+ a.moveTo(ex - 1, ey);
+ room.walkable[ey][ex] = true;
+ room.walkable[ey][ex - 1] = true;
+
+ room.queueMove('a', 1, 0);
+ const result = room.resolveTick();
+ expect(result.finished).toBe(true);
+ expect(room.status).toBe('finished');
+ expect(room.winnerId).toBe('a');
+ expect(result.events.some(e => e.type === 'player_exit' && e.id === 'a')).toBe(true);
+ });
+
+ test('treasure drops at corpse when carrier dies', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ room.treasure = { x: a.x + 1, y: a.y, carrierId: null };
+ room.walkable[a.y][a.x + 1] = true;
+ room.queueMove('a', 1, 0);
+ room.resolveTick();
+ expect(room.playerStates.get('a').hasTreasure).toBe(true);
+
+ // Kill the player by moving monster onto them.
+ room.monster.moveTo(a.x, a.y);
+ const result = room.resolveTick();
+ expect(room.playerStates.get('a').alive).toBe(false);
+ expect(room.treasure.carrierId).toBeNull();
+ expect(room.treasure.x).toBe(a.x);
+ expect(room.treasure.y).toBe(a.y);
+ });
+ });
+
+ describe('monster', () => {
+ test('monster kills player who moves onto it', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ // Put monster adjacent and player walks onto it.
+ const mx = a.x + 1;
+ const my = a.y;
+ room.monster.moveTo(mx, my);
+ room.walkable[my][mx] = true;
+
+ room.queueMove('a', 1, 0);
+ const result = room.resolveTick();
+ expect(room.playerStates.get('a').alive).toBe(false);
+ expect(result.events.some(e => e.type === 'player_death')).toBe(true);
+ });
+ });
+
+ describe('expiration / rankings', () => {
+ test('expired match ranks living players by proximity to exit', () => {
+ const room = makeRoom();
+ room.start();
+ const a = room.occupants.get('a');
+ const b = room.occupants.get('b');
+ const [ex, ey] = room.dungeon.exit;
+ a.moveTo(ex - 1, ey); // closer
+ b.moveTo(ex - 5, ey); // farther
+ room.walkable[ey][ex - 1] = true;
+ room.walkable[ey][ex - 5] = true;
+
+ room.expire('hard_ceiling');
+ room.finalize();
+ expect(room.winnerId).toBe('a');
+ expect(room.playerStates.get('a').placement).toBe(1);
+ expect(room.playerStates.get('b').placement).toBe(2);
+ });
+
+ test('all dead without escape ends match', () => {
+ const room = makeRoom();
+ room.start();
+ for (const id of room.playerStates.keys()) {
+ room._killPlayer(id, 'monster');
+ }
+ expect(room.status).toBe('finished');
+ expect(room.endReason).toBe('all_dead');
+ });
+ });
+
+ describe('serialization', () => {
+ test('toGameState includes players, monster, treasure, exit', () => {
+ const room = makeRoom();
+ room.start();
+ const state = room.toGameState('a');
+ expect(state.players.length).toBe(2);
+ expect(state.players.find(p => p.id === 'a').you).toBe(true);
+ expect(state.monster).toBeTruthy();
+ expect(state.exit).toEqual(room.dungeon.exit);
+ });
+
+ test('MatchState serializes entrants for persistence', () => {
+ const room = makeRoom();
+ room.start();
+ room.queueMove('a', 1, 0);
+ room.resolveTick();
+ room.expire('hard_ceiling');
+ room.finalize();
+ const rows = MatchState.toEntrantRows(room);
+ expect(rows.length).toBe(2);
+ expect(rows[0]).toHaveProperty('match_id', room.id);
+ expect(rows[0]).toHaveProperty('placement');
+ expect(rows[0]).toHaveProperty('score');
+ });
+ });
+
+ describe('determinism', () => {
+ test('same seed produces identical dungeon and initial state', () => {
+ const r1 = makeRoom();
+ const r2 = makeRoom();
+ expect(r1.dungeon.entrance).toEqual(r2.dungeon.entrance);
+ expect(r1.dungeon.exit).toEqual(r2.dungeon.exit);
+ expect(r1.seedHash).toEqual(r2.seedHash);
+ expect(r1.monster.x).toBe(r2.monster.x);
+ expect(r1.monster.y).toBe(r2.monster.y);
+ });
+ });
+});
+
+describe('MatchEngine', () => {
+ test('starts the room and ticks', () => {
+ const room = new MatchRoom({
+ entrants: { a: { userId: 1, name: 'A' } },
+ seed: '0000000000000000000000000000000000000000000000000000000000000000'
+ });
+ const engine = new MatchEngine({ room, tickMs: 1000 });
+ expect(engine.start()).toBe(true);
+ expect(room.status).toBe('active');
+ engine.stop();
+ });
+
+ test('onFinish fires when room finishes', () => {
+ const room = new MatchRoom({
+ entrants: { a: { userId: 1, name: 'A' } },
+ seed: '0000000000000000000000000000000000000000000000000000000000000000'
+ });
+ const finish = jest.fn();
+ const engine = new MatchEngine({ room, onFinish: finish });
+ engine.start();
+
+ // Walk to exit.
+ const a = room.occupants.get('a');
+ const [ex, ey] = room.dungeon.exit;
+ a.moveTo(ex - 1, ey);
+ room.walkable[ey][ex] = true;
+ room.walkable[ey][ex - 1] = true;
+ room.queueMove('a', 1, 0);
+ engine.tick();
+
+ expect(finish).toHaveBeenCalledWith(room);
+ engine.stop();
+ });
+});
diff --git a/test/matchScheduler.test.js b/test/matchScheduler.test.js
new file mode 100644
index 0000000..552a9d1
--- /dev/null
+++ b/test/matchScheduler.test.js
@@ -0,0 +1,92 @@
+const MatchScheduler = require('../src/network/matchScheduler');
+const MatchQueue = require('../src/network/matchQueue');
+
+describe('MatchScheduler', () => {
+ function makeQueue() {
+ const entries = [];
+ let nextId = 1;
+ return {
+ entries,
+ async join(e) {
+ e.queueEntryId = nextId++;
+ this.entries.push(e);
+ return { success: true };
+ },
+ async drain(economy, max) {
+ const filtered = this.entries.filter(e => e.economy === economy);
+ if (filtered.length < 2) return null;
+ const count = Math.min(max, filtered.length);
+ const drained = filtered.slice(0, count);
+ this.entries = this.entries.filter(e => !drained.includes(e));
+ return { entries: drained };
+ },
+ length(economy) { return this.entries.length; }
+ };
+ }
+
+ function makeManager() {
+ const attached = [];
+ return {
+ attached,
+ db: null,
+ attach(room, entrants, opts) {
+ this.attached.push({ room, entrants, opts });
+ },
+ setEngine(id, engine) {},
+ onTick() {},
+ onFinish() {}
+ };
+ }
+
+ test('does nothing when disabled', async () => {
+ process.env.MATCH_ENABLED = 'false';
+ const queue = makeQueue();
+ const manager = makeManager();
+ const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
+ await scheduler.onBlock(100);
+ expect(manager.attached.length).toBe(0);
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('does nothing when fewer than 2 players are queued', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const queue = makeQueue();
+ const manager = makeManager();
+ const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
+ await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
+ await scheduler.onBlock(100);
+ expect(manager.attached.length).toBe(0);
+ expect(queue.length('free')).toBe(1);
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('drains free queue into a match when 2+ players queued', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const queue = makeQueue();
+ const manager = makeManager();
+ const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager });
+ await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
+ await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
+ await scheduler.onBlock(100);
+ expect(manager.attached.length).toBe(1);
+ const { room, entrants } = manager.attached[0];
+ expect(room.economy).toBe('free');
+ expect(entrants.length).toBe(2);
+ expect(queue.length('free')).toBe(0);
+ delete process.env.MATCH_ENABLED;
+ });
+
+ test('starts the match engine after attach', async () => {
+ process.env.MATCH_ENABLED = 'true';
+ const queue = makeQueue();
+ const manager = makeManager();
+ const scheduler = new MatchScheduler({ matchQueue: queue, matchManager: manager, tickMs: 100 });
+ await queue.join({ userId: 1, socketId: 's1', sessionToken: 't1', economy: 'free' });
+ await queue.join({ userId: 2, socketId: 's2', sessionToken: 't2', economy: 'free' });
+ await scheduler.onBlock(100);
+ const { room } = manager.attached[0];
+ // Engine exists and was started via attach (the actual engine lives in scheduler internals).
+ expect(room.status).toBe('active');
+ delete process.env.MATCH_ENABLED;
+ });
+});
diff --git a/test/tavernMatchBridge.test.js b/test/tavernMatchBridge.test.js
new file mode 100644
index 0000000..6d57128
--- /dev/null
+++ b/test/tavernMatchBridge.test.js
@@ -0,0 +1,68 @@
+const TavernMatchBridge = require('../src/network/tavernMatchBridge');
+
+describe('TavernMatchBridge', () => {
+ function makeManager() {
+ const rooms = new Map();
+ return {
+ rooms,
+ onTick: jest.fn(),
+ onFinish: jest.fn()
+ };
+ }
+
+ function makeBridge({ rooms = [], io = null } = {}) {
+ const manager = makeManager();
+ for (const r of rooms) manager.rooms.set(r.id, r);
+ const emitted = [];
+ const bridge = new TavernMatchBridge({
+ matchManager: manager,
+ io: io || { to: () => ({ emit: (e, p) => emitted.push({ e, p }) }) },
+ debugManager: null
+ });
+ bridge.enabled = true;
+ return { bridge, manager, emitted };
+ }
+
+ function makeRoom(id = 'm1') {
+ return {
+ id,
+ economy: 'free',
+ tickCount: 5,
+ status: 'active',
+ occupants: new Map([['a', { id: 'a', x: 1, y: 2, name: 'A', avatar: 'default', facing: 'down' }]]),
+ playerStates: new Map([['a', { alive: true, finished: false, escaped: false, hasTreasure: false, placement: null }]]),
+ monster: { x: 3, y: 4 },
+ treasure: null,
+ dungeon: { exit: [10, 10] },
+ winnerId: null,
+ seedHash: 'deadbeef'
+ };
+ }
+
+ test('forwards public match tick to tavern room', () => {
+ const room = makeRoom('m1');
+ const { bridge, manager, emitted } = makeBridge({ rooms: [room] });
+ bridge.initialize();
+ manager.onTick('m1', { tick: 5, events: [] });
+ expect(emitted.some(e => e.e === 'tavern_match_tick' && e.p.matchId === 'm1')).toBe(true);
+ });
+
+ test('forwards match end to tavern room', async () => {
+ const room = makeRoom('m2');
+ room.status = 'finished';
+ room.winnerId = 'a';
+ const { bridge, manager, emitted } = makeBridge({ rooms: [room] });
+ bridge.initialize();
+ await manager.onFinish(room);
+ expect(emitted.some(e => e.e === 'tavern_match_end' && e.p.matchId === 'm2')).toBe(true);
+ });
+
+ test('getActiveMatches returns public snapshots', () => {
+ const room = makeRoom('m3');
+ const { bridge } = makeBridge({ rooms: [room] });
+ const list = bridge.getActiveMatches();
+ expect(list.length).toBe(1);
+ expect(list[0].matchId).toBe('m3');
+ expect(list[0].players.length).toBe(1);
+ });
+});