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

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

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

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

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

  Known follow-ups for real-world validation:
  - Full integration test with Postgres + Socket.IO.
  - Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
  - Browser render-kit verification in /match.html and tavern spectator.
  - Crypto-race payout end-to-end with real wallet-rpc.
This commit is contained in:
jwinterm
2026-07-09 13:26:52 -04:00
parent 8fa8b06b20
commit 0f68eec26b
31 changed files with 3963 additions and 8 deletions
+31
View File
@@ -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
+23
View File
@@ -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
+34 -2
View File
@@ -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
}
}
};
}
+16
View File
@@ -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,
+186
View File
@@ -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:<id>', '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;
+79
View File
@@ -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;
+671
View File
@@ -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;
+108
View File
@@ -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;
+19
View File
@@ -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;
+116
View File
@@ -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;
+438
View File
@@ -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;
+157
View File
@@ -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;
+383
View File
@@ -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;
+185
View File
@@ -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;
+62
View File
@@ -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();
+112
View File
@@ -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;
+8
View File
@@ -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
};