mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-28 10:38:32 +01:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user