mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 15:48:33 +02:00
fix: resolve security/correctness review + AAA graphics pass
Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater. Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests. Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles. Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
This commit is contained in:
@@ -59,18 +59,19 @@ class BroadcastManager {
|
||||
* @param {string} username - Username of the sender
|
||||
* @param {string} message - Chat message content
|
||||
* @param {number} timestamp - Message timestamp
|
||||
* @param {string} socketId - Socket ID of the sender
|
||||
* @param {string} publicId - Short, non-sensitive public id of the sender (NOT the raw
|
||||
* full socket.id — S1 defense in depth; the full socket.id must never leave the server)
|
||||
*/
|
||||
broadcastChatMessage(username, message, timestamp, socketId) {
|
||||
broadcastChatMessage(username, message, timestamp, publicId) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`💬 Broadcasting chat message from ${socketId}: "${message}"`);
|
||||
console.log(`💬 Broadcasting chat message from ${publicId}: "${message}"`);
|
||||
}
|
||||
|
||||
this.io.emit('chat_broadcast', {
|
||||
username: username,
|
||||
message: message,
|
||||
timestamp: timestamp,
|
||||
socketId: socketId
|
||||
publicId: publicId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ class SocketChatProvider extends ChatProvider {
|
||||
async publish(msg = {}) {
|
||||
const { scope = 'global', username, text, ts = Date.now(), socketId = null, userId = null } = msg;
|
||||
|
||||
// Defense in depth (S1): never broadcast the raw full socket.id to other clients — it is
|
||||
// a hijackable handle. Derive a short, non-sensitive public id for display/attribution.
|
||||
const publicId = userId != null ? String(userId) : (socketId ? String(socketId).substring(0, 6) : null);
|
||||
|
||||
if (scope === 'global') {
|
||||
if (this.history) {
|
||||
// Fire-and-forget persistence, exactly as before (must not block delivery).
|
||||
@@ -36,16 +40,16 @@ class SocketChatProvider extends ChatProvider {
|
||||
.catch(err => console.error('Failed to save chat message:', err.message));
|
||||
}
|
||||
if (this.broadcastManager) {
|
||||
this.broadcastManager.broadcastChatMessage(username, text, ts, socketId);
|
||||
this.broadcastManager.broadcastChatMessage(username, text, ts, publicId);
|
||||
} else {
|
||||
this.io.emit('chat_broadcast', { username, message: text, timestamp: ts, socketId });
|
||||
this.io.emit('chat_broadcast', { username, message: text, timestamp: ts, publicId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Room-scoped (e.g. tavern): delivered only to occupants of that room. The `scope`
|
||||
// field lets a room client filter these from any global broadcasts it also receives.
|
||||
this.io.to(scope).emit('chat_broadcast', { username, message: text, timestamp: ts, socketId, scope });
|
||||
this.io.to(scope).emit('chat_broadcast', { username, message: text, timestamp: ts, publicId, scope });
|
||||
}
|
||||
|
||||
async getHistory({ scope = 'global', limit = 50 } = {}) {
|
||||
|
||||
@@ -43,11 +43,13 @@ class MatchLeaderboard {
|
||||
|
||||
async _postFreeToPleb(room) {
|
||||
// Insert synthetic solo-style records so free races show up on the Pleb board.
|
||||
// Columns are the REAL games columns (treasure_found, moves_made); dungeon_seed is the
|
||||
// match UUID (36 chars, fits VARCHAR(50)) — never the 64-char seedHash which overflows.
|
||||
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)
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, score, dungeon_seed, created_at, completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
`, [
|
||||
state.userId,
|
||||
@@ -59,7 +61,7 @@ class MatchLeaderboard {
|
||||
state.moves,
|
||||
Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
|
||||
state.score,
|
||||
room.seedHash
|
||||
room.id
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
@@ -78,12 +80,13 @@ class MatchLeaderboard {
|
||||
}
|
||||
|
||||
async _postCryptoRace(room) {
|
||||
// Insert synthetic paid records for the Hall of Champions board.
|
||||
// Insert synthetic paid records for the Hall of Champions board. Real columns
|
||||
// (treasure_found, moves_made); dungeon_seed is the match UUID, not the 64-char seedHash.
|
||||
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)
|
||||
INSERT INTO games (user_id, socket_id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, score, dungeon_seed, created_at, completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
`, [
|
||||
state.userId,
|
||||
@@ -95,7 +98,7 @@ class MatchLeaderboard {
|
||||
state.moves,
|
||||
Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
|
||||
state.score,
|
||||
room.seedHash
|
||||
room.id
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
|
||||
+257
-136
@@ -1,44 +1,50 @@
|
||||
/**
|
||||
* 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).
|
||||
* Owns active MatchRooms, their MatchEngines, socket/user-to-match mappings, broadcasts, the
|
||||
* real pre-race countdown, and end-of-match finalization (payout, persistence, leaderboard,
|
||||
* broadcast). Also handles reconnect grace: a disconnected player can rejoin the same match
|
||||
* within a short window and is re-mapped across ALL room state.
|
||||
*
|
||||
* Server-authoritative throughout: every move is validated, occupancy is solid, and the room
|
||||
* stays in 'starting' (moves rejected) until the countdown elapses so an honest client and a
|
||||
* modified client always start together.
|
||||
*/
|
||||
|
||||
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
|
||||
const RECONNECT_GRACE_MS = 30000; // 30s to reconnect during an active match before AFK-kill
|
||||
const DEFAULT_COUNTDOWN_MS = 3000;
|
||||
const FINALIZE_CLEANUP_MS = 30000; // keep mappings this long after finish for late reconnects
|
||||
|
||||
class MatchManager {
|
||||
constructor({ io, db, debugManager, identityService = null, chatProvider = null } = {}) {
|
||||
constructor({ io, db, debugManager, identityService = null, gameModeManager = null, chatProvider = null } = {}) {
|
||||
this.io = io;
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this.identityService = identityService;
|
||||
this.chatProvider = chatProvider;
|
||||
this.gameModeManager = gameModeManager; // socketHandlers injects this so payouts/entry-fee reads work
|
||||
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.userToMatch = new Map(); // userId -> matchId
|
||||
this.disconnectTimeouts = new Map(); // `${matchId}:${socketId}` -> timeout id
|
||||
this._pendingCleanups = new Set(); // grace-cleanup timers awaiting fire
|
||||
|
||||
this.matchLeaderboard = new MatchLeaderboard({ db: this.db, io: this.io, debugManager: this.debugManager });
|
||||
this.matchPayoutService = new MatchPayoutService({
|
||||
db: this.db,
|
||||
walletService: null,
|
||||
gameModeManager: null,
|
||||
walletService: this.gameModeManager?.walletService || null,
|
||||
gameModeManager: this.gameModeManager,
|
||||
debugManager: this.debugManager
|
||||
});
|
||||
this.gameModeManager = null; // set via attach options
|
||||
|
||||
// Last-known movement per socket for flood control.
|
||||
this._lastMoveAt = new Map();
|
||||
@@ -50,16 +56,31 @@ class MatchManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a MatchRoom that was just created by MatchScheduler. The entrants array
|
||||
* contains { userId, socketId, sessionToken } objects from the queue.
|
||||
* Optional init hook (socketHandlers calls it after MatchQueue.initialize()). In-memory
|
||||
* state is empty on boot; abandoned in-flight matches are recovered and refunded by
|
||||
* MatchQueue.initialize() (the money authority), so there is nothing to rebuild here.
|
||||
*/
|
||||
attach(room, entrants, { tickMs = 250, minDurationMs = 20000, hardCeilingMs = 240000, db = null, gameModeManager = null } = {}) {
|
||||
if (!this.enabled) return;
|
||||
initialize() { /* no-op: boot recovery lives in MatchQueue.initialize() */ }
|
||||
|
||||
/**
|
||||
* Attach a MatchRoom that MatchScheduler just created and (for crypto) already collected
|
||||
* the pot for. Notifies entrants, runs the real countdown, and owns all lifecycle timers.
|
||||
* @param {MatchRoom} room
|
||||
* @param {Array<{userId:number, socketId:string, sessionToken:string}>} entrants
|
||||
*/
|
||||
attach(room, entrants, { tickMs = 250, minDurationMs = 20000, hardCeilingMs = 240000, countdownMs = DEFAULT_COUNTDOWN_MS, db = null, gameModeManager = null } = {}) {
|
||||
if (!this.enabled || !room) return;
|
||||
if (db) this.db = db;
|
||||
this.gameModeManager = gameModeManager;
|
||||
if (gameModeManager && this.matchPayoutService) this.matchPayoutService.gameModeManager = gameModeManager;
|
||||
if (gameModeManager) this.gameModeManager = gameModeManager;
|
||||
// Keep the payout service pointed at the live db + gameModeManager.
|
||||
if (this.matchPayoutService) {
|
||||
if (this.db) this.matchPayoutService.db = this.db;
|
||||
if (this.gameModeManager) this.matchPayoutService.gameModeManager = this.gameModeManager;
|
||||
}
|
||||
if (this.matchLeaderboard && this.db) this.matchLeaderboard.db = this.db;
|
||||
|
||||
this.rooms.set(room.id, room);
|
||||
for (const e of entrants) {
|
||||
for (const e of (entrants || [])) {
|
||||
this.socketToMatch.set(e.socketId, room.id);
|
||||
this.userToMatch.set(e.userId, room.id);
|
||||
}
|
||||
@@ -67,11 +88,11 @@ class MatchManager {
|
||||
// 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`);
|
||||
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);
|
||||
// Notify players (they will render a countdown; the server enforces it authoritatively).
|
||||
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', {
|
||||
@@ -79,31 +100,37 @@ class MatchManager {
|
||||
economy: room.economy,
|
||||
seedHash: room.seedHash,
|
||||
players: Array.from(room.occupants.values()).map(o => o.getState()),
|
||||
countdownMs: 3000
|
||||
countdownMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown before first tick.
|
||||
setTimeout(() => {
|
||||
room.start();
|
||||
this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: room.seedHash });
|
||||
}, 3000);
|
||||
// MP-H6: REAL countdown. The room stays in 'starting' (moves rejected) until this
|
||||
// fires; only then does the engine begin ticking, so honest and modified clients start
|
||||
// together — no head-start cheat is possible.
|
||||
const startTimer = setTimeout(() => {
|
||||
const r = this.rooms.get(room.id);
|
||||
if (!r || r.status !== 'starting') return; // expired/finalized during the countdown
|
||||
const engine = this.engines.get(room.id);
|
||||
if (engine && typeof engine.start === 'function') engine.start(); // activates room + ticks
|
||||
else r.start();
|
||||
this._broadcast(room.id, 'match_start', { matchId: room.id, tickMs, seedHash: r.seedHash });
|
||||
}, countdownMs);
|
||||
|
||||
// Minimum-duration watchdog: prevent next-block expiry before floor.
|
||||
const floorTimeout = setTimeout(() => {
|
||||
room._minDurationMet = true;
|
||||
}, minDurationMs);
|
||||
// Minimum-duration watchdog: prevents a next-block expiry before the floor.
|
||||
const floorTimeout = setTimeout(() => { const r = this.rooms.get(room.id); if (r) r._minDurationMet = true; }, minDurationMs);
|
||||
|
||||
// Hard ceiling watchdog.
|
||||
// Hard-ceiling watchdog: absolute max match length. Single owner (the scheduler no
|
||||
// longer sets its own ceiling timer), cleared by _finalize so it never fires on a
|
||||
// finished/removed room.
|
||||
const ceilingTimeout = setTimeout(() => this.expire(room.id, 'hard_ceiling'), hardCeilingMs);
|
||||
|
||||
room._watchdogs = [floorTimeout, ceilingTimeout];
|
||||
room._watchdogs = [startTimer, floorTimeout, ceilingTimeout];
|
||||
}
|
||||
|
||||
async _hydrateEntrants(room, entrants) {
|
||||
if (!this.identityService) return;
|
||||
for (const e of entrants) {
|
||||
for (const e of (entrants || [])) {
|
||||
try {
|
||||
const user = await this.identityService.userForId(e.userId);
|
||||
if (user) {
|
||||
@@ -153,21 +180,21 @@ class MatchManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a match ends naturally.
|
||||
* Called when a match ends naturally (engine detected finished).
|
||||
*/
|
||||
async onFinish(room) {
|
||||
await this._finalize(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-end a match (block deadline, hard ceiling, admin abort).
|
||||
* Force-end a match (block deadline, hard ceiling, admin abort). Idempotent.
|
||||
*/
|
||||
expire(matchId, reason) {
|
||||
const engine = this.engines.get(matchId);
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
if (!room || room._finalized) return;
|
||||
const engine = this.engines.get(matchId);
|
||||
if (engine) {
|
||||
engine.expire(reason);
|
||||
engine.expire(reason); // room.expire + finalize + onFinish -> _finalize
|
||||
} else {
|
||||
room.expire(reason);
|
||||
room.finalize();
|
||||
@@ -175,43 +202,73 @@ class MatchManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End a match immediately from a manager-side trigger (forfeit / AFK wipeout). Idempotent
|
||||
* via room._finalized.
|
||||
*/
|
||||
_endNow(room, reason) {
|
||||
if (!room || room._finalized) return;
|
||||
const engine = this.engines.get(room.id);
|
||||
if (room.status !== 'finished') room.expire(reason);
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
room.finalize();
|
||||
this._finalize(room).catch(err => this._log('[MatchManager] endNow finalize error', err.message));
|
||||
}
|
||||
|
||||
async _finalize(room) {
|
||||
if (room._finalized) return;
|
||||
if (!room || room._finalized) return;
|
||||
room._finalized = true;
|
||||
|
||||
// Clear watchdogs.
|
||||
if (room._watchdogs) room._watchdogs.forEach(id => clearTimeout(id));
|
||||
// Clear lifecycle watchdogs (countdown, floor, hard ceiling) so none fire post-finish.
|
||||
if (room._watchdogs) { room._watchdogs.forEach(id => clearTimeout(id)); room._watchdogs = null; }
|
||||
// Clear any pending AFK timers for this match.
|
||||
this._clearDisconnectTimers(room.id);
|
||||
// Stop the engine if it is still ticking.
|
||||
const engine = this.engines.get(room.id);
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
|
||||
// Persist results.
|
||||
if (this.db) {
|
||||
await this._persistFinish(room);
|
||||
}
|
||||
await this.matchLeaderboard.postMatch(room);
|
||||
// Each step is isolated in its OWN try/catch so a failure in one NEVER prevents the
|
||||
// winner payout or the match_end broadcast. The winner PAYOUT runs FIRST.
|
||||
// 1. Winner payout (crypto only).
|
||||
if (room.economy === 'crypto_race') {
|
||||
await this.matchPayoutService.payoutWinner(room);
|
||||
try { await this.matchPayoutService.payoutWinner(room); }
|
||||
catch (err) { this._log('[MatchManager] payout error', err.message); }
|
||||
}
|
||||
// 2. Persist match + entrant results.
|
||||
if (this.db) {
|
||||
try { await this._persistFinish(room); }
|
||||
catch (err) { this._log('[MatchManager] persist error', err.message); }
|
||||
}
|
||||
// 3. Leaderboard integration.
|
||||
try { await this.matchLeaderboard.postMatch(room); }
|
||||
catch (err) { this._log('[MatchManager] leaderboard error', err.message); }
|
||||
// 4. Broadcast the result (always attempted).
|
||||
try {
|
||||
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
|
||||
};
|
||||
})
|
||||
});
|
||||
} catch (err) { this._log('[MatchManager] broadcast error', err.message); }
|
||||
|
||||
// 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);
|
||||
// 5. Drop all mappings after a grace period so late reconnects still resolve the result.
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
this._pendingCleanups.delete(cleanupTimer);
|
||||
this._cleanup(room.id);
|
||||
}, FINALIZE_CLEANUP_MS);
|
||||
this._pendingCleanups.add(cleanupTimer);
|
||||
}
|
||||
|
||||
async _persistFinish(room) {
|
||||
@@ -257,28 +314,53 @@ class MatchManager {
|
||||
});
|
||||
}
|
||||
|
||||
_clearDisconnectTimers(matchId) {
|
||||
const prefix = `${matchId}:`;
|
||||
for (const [key, t] of this.disconnectTimeouts.entries()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
clearTimeout(t);
|
||||
this.disconnectTimeouts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every mapping owned by a finished match. Previously the userToMatch loop was a
|
||||
* no-op; it now actually deletes by userId, and socketToMatch / _lastMoveAt are cleared for
|
||||
* every occupant (including re-keyed ones).
|
||||
*/
|
||||
_cleanup(matchId) {
|
||||
const room = this.rooms.get(matchId);
|
||||
if (room) {
|
||||
for (const occ of room.occupants.values()) {
|
||||
this.socketToMatch.delete(occ.id);
|
||||
this._lastMoveAt.delete(occ.id);
|
||||
}
|
||||
for (const [uid] of room.playerStates.entries()) {
|
||||
// userToMatch is keyed by socketId today; cleanup is best-effort.
|
||||
for (const state of room.playerStates.values()) {
|
||||
if (state.userId != null && this.userToMatch.get(state.userId) === matchId) {
|
||||
this.userToMatch.delete(state.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Belt-and-suspenders: remove any lingering maps that still point at this match.
|
||||
for (const [sid, mid] of this.socketToMatch.entries()) {
|
||||
if (mid === matchId) this.socketToMatch.delete(sid);
|
||||
}
|
||||
for (const [uid, mid] of this.userToMatch.entries()) {
|
||||
if (mid === matchId) this.userToMatch.delete(uid);
|
||||
}
|
||||
this._clearDisconnectTimers(matchId);
|
||||
this.rooms.delete(matchId);
|
||||
this.engines.delete(matchId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.IO handler: move request.
|
||||
* Socket.IO handler: move request. Null-safe; a benign no-op when match mode is disabled,
|
||||
* the socket isn't in a match, or the room hasn't reached 'active' yet (MP-H6 countdown).
|
||||
*/
|
||||
move(socket, data) {
|
||||
if (!this.enabled) return;
|
||||
if (db) this.db = db;
|
||||
this.gameModeManager = gameModeManager;
|
||||
if (gameModeManager && this.matchPayoutService) this.matchPayoutService.gameModeManager = gameModeManager;
|
||||
if (!this.enabled || !socket) return;
|
||||
|
||||
const now = Date.now();
|
||||
const last = this._lastMoveAt.get(socket.id) || 0;
|
||||
if (now - last < this.moveCooldownMs) return;
|
||||
@@ -288,9 +370,12 @@ class MatchManager {
|
||||
if (!matchId) return;
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
// Reject moves until the countdown elapses and the room is active (no head-start cheat).
|
||||
if (room.status !== 'active') return;
|
||||
|
||||
const dx = Math.sign(Number(data.dx) || 0);
|
||||
const dy = Math.sign(Number(data.dy) || 0);
|
||||
const d = (data && typeof data === 'object') ? data : {};
|
||||
const dx = Math.sign(Number(d.dx) || 0);
|
||||
const dy = Math.sign(Number(d.dy) || 0);
|
||||
if (Math.abs(dx) + Math.abs(dy) !== 1) return;
|
||||
|
||||
room.queueMove(socket.id, dx, dy);
|
||||
@@ -300,47 +385,57 @@ class MatchManager {
|
||||
* Socket.IO handler: leave an active match (forfeit).
|
||||
*/
|
||||
leave(socket) {
|
||||
if (!this.enabled || !socket) return;
|
||||
const matchId = this.socketToMatch.get(socket.id);
|
||||
if (!matchId) return;
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
|
||||
const state = room.playerStates.get(socket.id);
|
||||
if (!state || !state.alive || state.finished) return; // nothing to forfeit
|
||||
|
||||
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));
|
||||
// Clear any pending AFK timer for this socket.
|
||||
const key = `${matchId}:${socket.id}`;
|
||||
const t = this.disconnectTimeouts.get(key);
|
||||
if (t) { clearTimeout(t); this.disconnectTimeouts.delete(key); }
|
||||
|
||||
// _killPlayer flips the room to 'finished' when it was the last active player.
|
||||
if (room.status === 'finished') {
|
||||
this._endNow(room, room.endReason || 'all_forfeit');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnect: start grace timer, then mark AFK-dead if not reconnected.
|
||||
* Handle disconnect: start a grace timer, then AFK-kill only if the player never reconnects.
|
||||
* Takes the live socket (identity is resolved from the connection, never the client).
|
||||
*/
|
||||
handleDisconnect(socketId, userId) {
|
||||
handleDisconnect(socket) {
|
||||
if (!this.enabled || !socket) return;
|
||||
const socketId = socket.id;
|
||||
const matchId = this.socketToMatch.get(socketId);
|
||||
if (!matchId) return;
|
||||
|
||||
const key = `${matchId}:${socketId}`;
|
||||
if (this.disconnectTimeouts.has(key)) return;
|
||||
|
||||
// Only arm the AFK timer for a player still in the race.
|
||||
const room0 = this.rooms.get(matchId);
|
||||
const st0 = room0?.playerStates.get(socketId);
|
||||
if (!st0 || !st0.alive || st0.finished) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.disconnectTimeouts.delete(key);
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
if (!room || room.status !== 'active') 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));
|
||||
if (room.status === 'finished') {
|
||||
this._endNow(room, room.endReason || 'all_dead');
|
||||
}
|
||||
}
|
||||
}, RECONNECT_GRACE_MS);
|
||||
@@ -349,53 +444,76 @@ class MatchManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reconnect: reattach socket to active match.
|
||||
* Handle reconnect: re-map a player from their OLD socket id to the new one across ALL room
|
||||
* state (occupants, playerStates, moveQueue, treasure carrier, winner) and manager maps,
|
||||
* clear the AFK timeout keyed by the OLD id, and never AFK-kill a reconnected player.
|
||||
* Ownership is verified: the reconnecting session must own the racer it attaches to.
|
||||
* @param {object} socket live socket (socket.id is the NEW id)
|
||||
* @param {{userId:number, sessionToken?:string, socketId?:string}} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
handleReconnect(socket, session) {
|
||||
if (!this.enabled || !socket || !session) return false;
|
||||
const userId = session.userId;
|
||||
if (userId == null) return false;
|
||||
const newId = socket.id;
|
||||
|
||||
// Find the match this user belongs to (user map first — socket id has changed).
|
||||
let matchId = this.userToMatch.get(userId);
|
||||
if (!matchId) matchId = this.socketToMatch.get(newId) || null;
|
||||
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);
|
||||
// Locate the OLD state key for this user and verify ownership. Matching by userId (the
|
||||
// stable DB id resolved from the connection) means a client can NEVER attach to another
|
||||
// player's racer.
|
||||
let oldId = null;
|
||||
for (const [id, state] of room.playerStates.entries()) {
|
||||
if (state.userId === userId) { oldId = id; break; }
|
||||
}
|
||||
if (oldId == null) return false;
|
||||
const ownerState = room.playerStates.get(oldId);
|
||||
if (!ownerState || ownerState.userId !== userId) return false;
|
||||
|
||||
// Clear the AFK disconnect timeout keyed by the OLD id (and defensively the new id).
|
||||
for (const k of [`${matchId}:${oldId}`, `${matchId}:${newId}`]) {
|
||||
const t = this.disconnectTimeouts.get(k);
|
||||
if (t) { clearTimeout(t); this.disconnectTimeouts.delete(k); }
|
||||
}
|
||||
|
||||
// Update mappings if the socket id changed.
|
||||
this.socketToMatch.set(socket.id, matchId);
|
||||
// Re-key ALL room + manager state from old -> new when the socket id changed.
|
||||
if (oldId !== newId) {
|
||||
const occ = room.occupants.get(oldId);
|
||||
if (occ) {
|
||||
room.occupants.delete(oldId);
|
||||
occ.id = newId;
|
||||
room.occupants.set(newId, occ);
|
||||
}
|
||||
const st = room.playerStates.get(oldId);
|
||||
if (st) {
|
||||
room.playerStates.delete(oldId);
|
||||
room.playerStates.set(newId, st);
|
||||
}
|
||||
if (room.moveQueue && room.moveQueue.has(oldId)) {
|
||||
const mv = room.moveQueue.get(oldId);
|
||||
room.moveQueue.delete(oldId);
|
||||
room.moveQueue.set(newId, mv);
|
||||
}
|
||||
if (room.treasure && room.treasure.carrierId === oldId) room.treasure.carrierId = newId;
|
||||
if (room.winnerId === oldId) room.winnerId = newId;
|
||||
this.socketToMatch.delete(oldId);
|
||||
this._lastMoveAt.delete(oldId);
|
||||
}
|
||||
|
||||
this.socketToMatch.set(newId, matchId);
|
||||
this.userToMatch.set(userId, 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)
|
||||
state: room.toGameState(newId)
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -421,17 +539,20 @@ class MatchManager {
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const [key, timeout] of this.disconnectTimeouts.entries()) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
for (const [, timeout] of this.disconnectTimeouts.entries()) clearTimeout(timeout);
|
||||
this.disconnectTimeouts.clear();
|
||||
for (const t of this._pendingCleanups) clearTimeout(t);
|
||||
this._pendingCleanups.clear();
|
||||
for (const [id, room] of this.rooms.entries()) {
|
||||
if (room._watchdogs) room._watchdogs.forEach(t => clearTimeout(t));
|
||||
if (room._watchdogs) { room._watchdogs.forEach(t => clearTimeout(t)); room._watchdogs = null; }
|
||||
const engine = this.engines.get(id);
|
||||
if (engine) engine.stop();
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
}
|
||||
this.rooms.clear();
|
||||
this.engines.clear();
|
||||
this.socketToMatch.clear();
|
||||
this.userToMatch.clear();
|
||||
this._lastMoveAt.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/**
|
||||
* 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.
|
||||
* On match start: commit each entrant's held ticket to the match and lock the pot (the ACTUAL
|
||||
* atomic amount collected), computing the house fee with exact integer math.
|
||||
* On match end: create ONE pending payout for the winner, capped at the true collected pot
|
||||
* minus the house fee, then hand it to the existing GameModeManager batch/retry machinery.
|
||||
*
|
||||
* This service does not send on-chain transactions itself; it reuses the existing
|
||||
* payout retry / batching machinery in GameModeManager.
|
||||
* This service does not send on-chain transactions itself; it reuses the existing payout retry
|
||||
* / batching path in GameModeManager. All pot/fee arithmetic is BigInt — no float precision
|
||||
* loss on large atomic amounts.
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
// Sentinel address for a winner who has not set a payout address yet. The payout row is stored
|
||||
// as 'needs_review' so the pot is a durable, queryable liability the winner can claim once they
|
||||
// provide an address — the house never silently keeps it, and the batcher never sends to this.
|
||||
const NO_ADDRESS_SENTINEL = 'PENDING_NO_ADDRESS';
|
||||
const DEFAULT_ENTRY_FEE_ATOMIC = 5000000000n; // 0.05 WOW at 11 decimals; only a last-resort default
|
||||
|
||||
class MatchPayoutService {
|
||||
constructor({ db, walletService, gameModeManager, debugManager } = {}) {
|
||||
this.db = db;
|
||||
@@ -24,21 +32,25 @@ class MatchPayoutService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Called by MatchScheduler BEFORE a crypto_race match is announced/started. Commits each
|
||||
* entrant's held queue-join ticket to this match, records the ACTUAL collected pot, and
|
||||
* computes the house fee via exact integer (BigInt) math. Throws on DB failure so the
|
||||
* caller can abort + refund (the transaction rolls back — no tickets are consumed).
|
||||
*/
|
||||
async collectEntryTickets(room, entrants) {
|
||||
if (!this.db || room.economy !== 'crypto_race') return;
|
||||
if (!this.db || !room || 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);
|
||||
const entryFee = this._entryFeeAtomic(); // BigInt atomic units per entry
|
||||
const houseFeeBp = this._houseFeeBasisPoints(); // integer basis points (0..10000)
|
||||
const count = BigInt((entrants && entrants.length) || 0);
|
||||
const pot = entryFee * count; // ACTUAL collected pot (BigInt)
|
||||
const houseFee = (pot * BigInt(houseFeeBp)) / 10000n; // floor via integer math — no float
|
||||
const housePercent = houseFeeBp / 100; // for the DECIMAL(5,2) audit column
|
||||
|
||||
await this.db.withTransaction(async (client) => {
|
||||
for (const entrant of entrants) {
|
||||
// Convert the held ticket to consumed.
|
||||
for (const entrant of (entrants || [])) {
|
||||
// Commit the held ticket to this match (delta 0 — it was already deducted at
|
||||
// queue-join; this row records the commitment for the audit trail).
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, match_id)
|
||||
VALUES ($1, 0, (
|
||||
@@ -46,7 +58,6 @@ class MatchPayoutService {
|
||||
), 'match_start', $2)
|
||||
`, [entrant.userId, room.id]);
|
||||
|
||||
// Update match_entrant to consumed.
|
||||
await client.query(`
|
||||
UPDATE match_entrants
|
||||
SET entry_consumed = TRUE
|
||||
@@ -54,7 +65,8 @@ class MatchPayoutService {
|
||||
`, [room.id, entrant.userId]);
|
||||
}
|
||||
|
||||
// Lock pot/house fee on the match row.
|
||||
// Lock the true pot / house fee on the match row (BigInt passed as strings so the
|
||||
// BIGINT columns are exact).
|
||||
await client.query(`
|
||||
UPDATE matches
|
||||
SET entry_fee_atomic = $1,
|
||||
@@ -62,44 +74,55 @@ class MatchPayoutService {
|
||||
house_fee_atomic = $3,
|
||||
house_fee_percent = $4
|
||||
WHERE id = $5
|
||||
`, [entryFee, pot, houseFee, houseFeePercent, room.id]);
|
||||
`, [entryFee.toString(), pot.toString(), houseFee.toString(), housePercent, room.id]);
|
||||
});
|
||||
|
||||
room.entryFeeAtomic = entryFee;
|
||||
room.potAtomic = pot;
|
||||
room.houseFeeAtomic = houseFee;
|
||||
room.houseFeePercent = houseFeePercent;
|
||||
// In-memory bookkeeping (informational). The authoritative amounts live on the match
|
||||
// row and are re-read (as BigInt) at payout time.
|
||||
room.entryFeeAtomic = Number(entryFee);
|
||||
room.potAtomic = Number(pot);
|
||||
room.houseFeeAtomic = Number(houseFee);
|
||||
room.houseFeePercent = housePercent;
|
||||
|
||||
this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}) for match ${room.id}`);
|
||||
this._log(`[MatchPayoutService] locked pot ${pot} (house fee ${houseFee}, ${housePercent}%) 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.
|
||||
* Called by MatchManager when a crypto_race match finishes. Creates ONE pending payout for
|
||||
* the winner capped at (pot − house fee). If the winner has no payout address the payout is
|
||||
* still recorded (status 'needs_review', a claimable liability) so the house never keeps it.
|
||||
*/
|
||||
async payoutWinner(room) {
|
||||
if (!this.db || room.economy !== 'crypto_race' || !room.winnerId) return;
|
||||
if (!this.db || !room || room.economy !== 'crypto_race' || !room.winnerId) return;
|
||||
|
||||
const winnerState = room.playerStates.get(room.winnerId);
|
||||
if (!winnerState || !winnerState.userId) return;
|
||||
if (!winnerState || winnerState.userId == null) return;
|
||||
|
||||
// Authoritative pot / fee from the match row (locked at collectEntryTickets).
|
||||
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;
|
||||
if (!matchRow.rows || 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;
|
||||
let pot, houseFee;
|
||||
try {
|
||||
pot = BigInt(matchRow.rows[0].pot_atomic ?? 0);
|
||||
houseFee = BigInt(matchRow.rows[0].house_fee_atomic ?? 0);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (houseFee < 0n) houseFee = 0n;
|
||||
if (houseFee > pot) houseFee = pot; // never pay a negative amount
|
||||
const winnerAmount = pot - houseFee; // capped at true collected pot − fee
|
||||
|
||||
if (winnerAmount <= 0) {
|
||||
if (winnerAmount <= 0n) {
|
||||
this._log(`[MatchPayoutService] no winner payout for match ${room.id} (pot=${pot} fee=${houseFee})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve payout address from user record.
|
||||
// Resolve payout address from the user record (may be absent).
|
||||
const userResult = await this.db.query(`
|
||||
SELECT id, payout_address
|
||||
FROM users
|
||||
@@ -107,17 +130,20 @@ class MatchPayoutService {
|
||||
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;
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
const hasAddress = typeof user.payout_address === 'string' && user.payout_address.trim().length > 0;
|
||||
const address = hasAddress ? user.payout_address.trim() : NO_ADDRESS_SENTINEL;
|
||||
const status = hasAddress ? 'pending' : 'needs_review';
|
||||
const reason = hasAddress ? 'match_winner' : 'match_winner_no_address';
|
||||
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
// Guard against double payout.
|
||||
// Guard against a double payout for this match (includes the deferred
|
||||
// no-address row so we never insert two).
|
||||
const existing = await client.query(`
|
||||
SELECT id FROM payouts
|
||||
WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed')
|
||||
WHERE match_id = $1 AND status IN ('pending', 'processing', 'completed', 'needs_review')
|
||||
FOR UPDATE
|
||||
`, [room.id]);
|
||||
if (existing.rows.length > 0) {
|
||||
@@ -127,30 +153,54 @@ class MatchPayoutService {
|
||||
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']);
|
||||
`, [user.id, room.id, address, winnerAmount.toString(), 0, reason, status]);
|
||||
});
|
||||
|
||||
// Trigger the existing batch payout processor.
|
||||
if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
||||
this.gameModeManager._scheduleBatchPayout();
|
||||
if (hasAddress) {
|
||||
// Trigger the existing batch payout processor (5s debounce).
|
||||
if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
||||
this.gameModeManager._scheduleBatchPayout();
|
||||
}
|
||||
this._log(`[MatchPayoutService] queued winner payout ${winnerAmount} for match ${room.id}`);
|
||||
} else {
|
||||
this._log(`[MatchPayoutService] winner ${user.id} has no payout address; recorded ${winnerAmount} as a claimable 'needs_review' payout for match ${room.id}`);
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* Entry fee (atomic units, BigInt). Does NOT assume entryFee == singleGamePrice: an explicit
|
||||
* MATCH_ENTRY_FEE_ATOMIC (or a gameModeManager.matchEntryFeeAtomic) wins; singleGamePrice is
|
||||
* only a fallback, then a hard default.
|
||||
*/
|
||||
_entryFeeAtomic() {
|
||||
const raw = (process.env.MATCH_ENTRY_FEE_ATOMIC != null && process.env.MATCH_ENTRY_FEE_ATOMIC !== '')
|
||||
? process.env.MATCH_ENTRY_FEE_ATOMIC
|
||||
: (this.gameModeManager?.matchEntryFeeAtomic
|
||||
?? this.gameModeManager?.singleGamePrice
|
||||
?? null);
|
||||
if (raw == null) return DEFAULT_ENTRY_FEE_ATOMIC;
|
||||
try {
|
||||
// Atomic units are integers; strip any fractional part defensively before BigInt.
|
||||
const s = String(raw).trim().split('.')[0];
|
||||
const b = BigInt(s);
|
||||
return b > 0n ? b : DEFAULT_ENTRY_FEE_ATOMIC;
|
||||
} catch (_) {
|
||||
return DEFAULT_ENTRY_FEE_ATOMIC;
|
||||
}
|
||||
}
|
||||
|
||||
_houseFeePercent() {
|
||||
const v = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT);
|
||||
return Number.isFinite(v) && v >= 0 && v <= 100 ? v : 5;
|
||||
/**
|
||||
* House fee as integer basis points (percent × 100), so a fractional percent like 5.5%
|
||||
* survives with no float precision loss in the pot math.
|
||||
*/
|
||||
_houseFeeBasisPoints() {
|
||||
const pct = parseFloat(process.env.MATCH_HOUSE_FEE_PERCENT);
|
||||
const p = Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : 5;
|
||||
return Math.round(p * 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+309
-104
@@ -2,21 +2,25 @@
|
||||
* 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.
|
||||
* • Enqueue/leave a queue for each economy (free, credits_prestige, crypto_race).
|
||||
* • Atomically deduct credits or tickets into escrow on join; REFUND on EVERY cancellation
|
||||
* path (leave, stale cleanup, drain abort, shutdown, boot recovery), always writing a
|
||||
* ledger row — never a status flip alone.
|
||||
* • Drain a full economy bucket into a match at block time (memory only mutated after the DB
|
||||
* commit succeeds, so a failed drain leaves the queue intact).
|
||||
*
|
||||
* The queue is backed by `match_queue_entries` so state survives server restarts and
|
||||
* disconnects. In-memory maps are rebuilt on initialization.
|
||||
* Backed by `match_queue_entries` so state survives restarts. In-memory maps are rebuilt on
|
||||
* initialize(), which also refunds abandoned queue rows and recovers in-flight `matches`.
|
||||
*
|
||||
* This module has no Socket.IO coupling; MatchManager owns transport.
|
||||
* 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);
|
||||
// race_entry_transactions.reason is CHECK-constrained; map any cancellation reason into it.
|
||||
const RACE_LEDGER_REASONS = new Set(['queue_leave', 'match_cancel', 'refund']);
|
||||
|
||||
class MatchQueue {
|
||||
constructor({ db, gameModeManager = null, debugManager = null } = {}) {
|
||||
@@ -37,23 +41,12 @@ class MatchQueue {
|
||||
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'
|
||||
`);
|
||||
// MP-H3: refund + cancel abandoned entries and recover in-flight matches BEFORE
|
||||
// rebuilding memory, so escrow is never silently lost across a restart/crash.
|
||||
await this._recoverStaleQueueEntries();
|
||||
await this._recoverAbandonedMatches();
|
||||
|
||||
// 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.
|
||||
// Rebuild in-memory state from the surviving 'queued' rows.
|
||||
const result = await this.db.query(`
|
||||
SELECT id, user_id, economy, socket_id, session_token, created_at
|
||||
FROM match_queue_entries
|
||||
@@ -75,6 +68,98 @@ class MatchQueue {
|
||||
this._log(`[MatchQueue] Initialized: ${this.length('free')} free, ${this.length('credits_prestige')} prestige, ${this.length('crypto_race')} crypto`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund + cancel queue rows abandoned by a crash: 'matched' rows that never became a
|
||||
* running match (older than 5 min), and 'queued' rows older than 24h. Each refund is
|
||||
* written to the ledger; the row is only cancelled in the same transaction as its refund.
|
||||
*/
|
||||
async _recoverStaleQueueEntries() {
|
||||
let stale = [];
|
||||
try {
|
||||
const res = await this.db.query(`
|
||||
SELECT user_id, economy FROM match_queue_entries
|
||||
WHERE (status = 'matched' AND created_at < NOW() - INTERVAL '5 minutes')
|
||||
OR (status = 'queued' AND created_at < NOW() - INTERVAL '24 hours')
|
||||
`);
|
||||
stale = res.rows || [];
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] stale scan error', err.message);
|
||||
return;
|
||||
}
|
||||
for (const row of stale) {
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const upd = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'cancelled', match_id = NULL
|
||||
WHERE user_id = $1 AND economy = $2 AND status IN ('queued', 'matched')
|
||||
RETURNING id
|
||||
`, [row.user_id, row.economy]);
|
||||
if (upd.rowCount > 0) {
|
||||
await this._applyRefund(client, row.user_id, row.economy, 'match_cancel');
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] stale recovery error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot recovery for in-flight `matches` rows left 'starting'/'active' by a crash. If a
|
||||
* payout was already recorded for the match, let it stand (finalize the status); otherwise
|
||||
* cancel the match and refund every entrant so escrow is never stranded.
|
||||
*/
|
||||
async _recoverAbandonedMatches() {
|
||||
let rows = [];
|
||||
try {
|
||||
const res = await this.db.query(`SELECT id, economy FROM matches WHERE status IN ('starting', 'active')`);
|
||||
rows = res.rows || [];
|
||||
} catch (err) {
|
||||
// matches table may not exist in a minimal test DB — non-fatal.
|
||||
this._log('[MatchQueue] abandoned match scan error', err.message);
|
||||
return;
|
||||
}
|
||||
for (const m of rows) {
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
// If a payout already exists, the race effectively completed — don't refund
|
||||
// (that would double-pay the winner); just finalize the match status so the
|
||||
// batcher/retry can settle the payout.
|
||||
const pay = await client.query(`SELECT id FROM payouts WHERE match_id = $1 LIMIT 1`, [m.id]);
|
||||
if (pay.rows.length > 0) {
|
||||
await client.query(`
|
||||
UPDATE matches SET status = 'finished', ended_at = COALESCE(ended_at, NOW())
|
||||
WHERE id = $1 AND status IN ('starting', 'active')
|
||||
`, [m.id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cancel = await client.query(`
|
||||
UPDATE matches SET status = 'cancelled', ended_at = NOW()
|
||||
WHERE id = $1 AND status IN ('starting', 'active')
|
||||
RETURNING id
|
||||
`, [m.id]);
|
||||
if (cancel.rowCount === 0) return; // already handled by another process
|
||||
|
||||
const ent = await client.query(`SELECT user_id FROM match_entrants WHERE match_id = $1`, [m.id]);
|
||||
for (const e of ent.rows || []) {
|
||||
if (m.economy === 'crypto_race' || m.economy === 'credits_prestige') {
|
||||
await this._applyRefund(client, e.user_id, m.economy, 'match_cancel');
|
||||
}
|
||||
}
|
||||
await client.query(`
|
||||
UPDATE match_queue_entries SET status = 'cancelled'
|
||||
WHERE match_id = $1 AND status IN ('queued', 'matched')
|
||||
`, [m.id]);
|
||||
});
|
||||
this._log(`[MatchQueue] recovered abandoned match ${m.id} (${m.economy})`);
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] abandoned match recovery error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_validateEconomy(economy) {
|
||||
return MATCH_ECONOMY_SET.has(economy) ? economy : null;
|
||||
}
|
||||
@@ -92,33 +177,53 @@ class MatchQueue {
|
||||
return this._queues[economy].slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* socketHandlers-facing API: enqueue a resolved session into a match queue. Identity comes
|
||||
* from the (server-resolved) session; the economy is explicit.
|
||||
* @param {{userId:number, socketId:string, sessionToken?:string, economy?:string}} session
|
||||
* @param {{economy?:string}} [opts]
|
||||
*/
|
||||
async enqueue(session, opts = {}) {
|
||||
const economy = (opts && opts.economy) || session?.economy;
|
||||
return this.join({
|
||||
userId: session?.userId,
|
||||
socketId: session?.socketId,
|
||||
sessionToken: session?.sessionToken,
|
||||
economy
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' };
|
||||
if (!entry || entry.userId == null) return { success: false, reason: 'invalid_user' };
|
||||
const economy = this._validateEconomy(entry.economy);
|
||||
if (!economy) return { success: false, reason: 'invalid_economy' };
|
||||
|
||||
// session_token is NOT NULL in the schema; coerce a missing token to a stable-ish
|
||||
// string (reconnect matches on userId anyway).
|
||||
const normalized = {
|
||||
...entry,
|
||||
economy,
|
||||
sessionToken: entry.sessionToken != null ? String(entry.sessionToken) : String(entry.userId)
|
||||
};
|
||||
|
||||
try {
|
||||
if (economy === 'free') {
|
||||
return await this._joinFree(entry);
|
||||
return await this._joinFree(normalized);
|
||||
}
|
||||
if (economy === 'credits_prestige') {
|
||||
return await this._joinCreditsPrestige(entry);
|
||||
return await this._joinCreditsPrestige(normalized);
|
||||
}
|
||||
return await this._joinCryptoRace(entry);
|
||||
return await this._joinCryptoRace(normalized);
|
||||
} 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' };
|
||||
const normErr = normalizeError(err, 'Failed to join match queue');
|
||||
this._log('[MatchQueue] join error:', normErr.message);
|
||||
return { success: false, reason: normErr.safeMessage || 'join_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +270,7 @@ class MatchQueue {
|
||||
|
||||
const balanceAfter = creditResult.rows[0].credits;
|
||||
|
||||
// 2. Log credit transaction.
|
||||
// 2. Log credit transaction (escrow).
|
||||
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)
|
||||
@@ -186,7 +291,7 @@ class MatchQueue {
|
||||
|
||||
async _joinCryptoRace(entry) {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
// 1. Consume one race entry ticket atomically.
|
||||
// 1. Consume one race entry ticket atomically (escrow).
|
||||
const ticketResult = await client.query(`
|
||||
UPDATE users
|
||||
SET race_entries = race_entries - 1
|
||||
@@ -200,7 +305,7 @@ class MatchQueue {
|
||||
|
||||
const balanceAfter = ticketResult.rows[0].race_entries;
|
||||
|
||||
// 2. Log ticket transaction (escrow).
|
||||
// 2. Log ticket transaction.
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
|
||||
VALUES ($1, -1, $2, 'queue_join', $3)
|
||||
@@ -232,20 +337,95 @@ class MatchQueue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a queue before a match starts. Refund credits/tickets.
|
||||
* @param {number} userId
|
||||
* @param {string} economy
|
||||
* @returns {Promise<{success:boolean, reason?:string}>}
|
||||
* Apply a refund for one entrant within an open transaction. Amounts are DB-derived
|
||||
* (MP-H4): the credit refund reads the exact deducted amount from the ledger, so it is
|
||||
* correct even after a restart cleared the in-memory entry. Always writes a ledger row.
|
||||
*/
|
||||
async leave(userId, economy) {
|
||||
async _applyRefund(client, userId, economy, ledgerReason = 'queue_leave') {
|
||||
if (economy === 'credits_prestige') {
|
||||
const amount = await this._creditsJoinAmount(client, userId);
|
||||
if (amount <= 0) return 0;
|
||||
const r = await client.query(`
|
||||
UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits
|
||||
`, [amount, userId]);
|
||||
const bal = r.rows[0]?.credits ?? 0;
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type, metadata)
|
||||
VALUES ($1, $2, 'match_queue_refund', $3, 'match', $4)
|
||||
`, [userId, amount, bal, JSON.stringify({ economy, refunded: true, reason: ledgerReason })]);
|
||||
return amount;
|
||||
}
|
||||
if (economy === 'crypto_race') {
|
||||
const reason = RACE_LEDGER_REASONS.has(ledgerReason) ? ledgerReason : 'queue_leave';
|
||||
const r = await client.query(`
|
||||
UPDATE users SET race_entries = race_entries + 1 WHERE id = $1 RETURNING race_entries
|
||||
`, [userId]);
|
||||
const bal = r.rows[0]?.race_entries ?? 0;
|
||||
await client.query(`
|
||||
INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, metadata)
|
||||
VALUES ($1, 1, $2, $3, $4)
|
||||
`, [userId, bal, reason, JSON.stringify({ economy, refunded: true, source: ledgerReason })]);
|
||||
return 1;
|
||||
}
|
||||
return 0; // free: nothing to refund
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact credit amount deducted at join, read from the ledger so refunds survive
|
||||
* restarts (MP-H4). Falls back to the configured cost if no ledger row is found.
|
||||
*/
|
||||
async _creditsJoinAmount(client, userId) {
|
||||
try {
|
||||
const r = await client.query(`
|
||||
SELECT amount FROM credit_transactions
|
||||
WHERE user_id = $1 AND reason = 'match_queue_join'
|
||||
AND (metadata->>'economy') = 'credits_prestige'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`, [userId]);
|
||||
if (r.rows.length > 0) {
|
||||
const amt = Math.abs(parseInt(r.rows[0].amount, 10));
|
||||
if (Number.isFinite(amt) && amt > 0) return amt;
|
||||
}
|
||||
} catch (_) { /* fall through to configured cost */ }
|
||||
return this._creditsCost();
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a queue before a match starts, refunding credits/tickets. Accepts either a resolved
|
||||
* session object ({ userId, economy }) — the socketHandlers form — or the legacy
|
||||
* (userId, economy) pair. When no economy is supplied, leaves every economy the user is in.
|
||||
*/
|
||||
async leave(sessionOrUserId, maybeEconomy) {
|
||||
if (!this.enabled) return { success: false, reason: 'match_disabled' };
|
||||
|
||||
let userId, economy;
|
||||
if (sessionOrUserId && typeof sessionOrUserId === 'object') {
|
||||
userId = sessionOrUserId.userId;
|
||||
economy = sessionOrUserId.economy || maybeEconomy;
|
||||
} else {
|
||||
userId = sessionOrUserId;
|
||||
economy = maybeEconomy;
|
||||
}
|
||||
if (userId == null) return { success: false, reason: 'invalid_user' };
|
||||
|
||||
// No economy given → leave every economy the user is queued in.
|
||||
if (!economy) {
|
||||
const results = [];
|
||||
for (const eco of ECONOMIES) {
|
||||
results.push(await this._leaveOne(userId, eco));
|
||||
}
|
||||
const ok = results.some(r => r && r.success);
|
||||
return { success: ok, reason: ok ? undefined : 'not_queued' };
|
||||
}
|
||||
|
||||
economy = this._validateEconomy(economy);
|
||||
if (!economy) return { success: false, reason: 'invalid_economy' };
|
||||
return this._leaveOne(userId, economy);
|
||||
}
|
||||
|
||||
async _leaveOne(userId, 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
|
||||
@@ -257,43 +437,64 @@ class MatchQueue {
|
||||
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 })]);
|
||||
}
|
||||
// MP-H4: refund is DB-derived inside _applyRefund, not read from volatile memory.
|
||||
await this._applyRefund(client, userId, economy, 'queue_leave');
|
||||
});
|
||||
|
||||
const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
|
||||
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' };
|
||||
const norm = normalizeError(err, 'Failed to leave match queue');
|
||||
if (norm.code === 'NOT_QUEUED') {
|
||||
// Keep memory consistent even if the DB row was already gone.
|
||||
const memIdx = this._queues[economy].findIndex(e => e.userId === userId);
|
||||
if (memIdx >= 0) this._queues[economy].splice(memIdx, 1);
|
||||
return { success: false, reason: 'not_queued' };
|
||||
}
|
||||
return { success: false, reason: norm.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
|
||||
* Refund a set of drained entrants (used when a race is aborted after drain marked them
|
||||
* 'matched', e.g. pot-collection failure or a <2-player defensive drain). Each refund is
|
||||
* written to the ledger and the queue row is cancelled in the same transaction, only when
|
||||
* the row was actually still queued/matched (so it can never double-refund).
|
||||
*/
|
||||
async refundEntries(entries, economy, reason = 'match_cancel') {
|
||||
if (!this.db || !Array.isArray(entries) || entries.length === 0) return;
|
||||
economy = this._validateEconomy(economy);
|
||||
if (!economy) return;
|
||||
|
||||
for (const e of entries) {
|
||||
const userId = e?.userId;
|
||||
if (userId == null) continue;
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const upd = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'cancelled', match_id = NULL
|
||||
WHERE user_id = $1 AND economy = $2 AND status IN ('queued', 'matched')
|
||||
RETURNING id
|
||||
`, [userId, economy]);
|
||||
if (upd.rowCount > 0) {
|
||||
await this._applyRefund(client, userId, economy, reason);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] refundEntries error', err.message);
|
||||
}
|
||||
const idx = this._queues[economy].findIndex(q => q.userId === userId);
|
||||
if (idx >= 0) this._queues[economy].splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain queued players for an economy into a match. Marks the DB rows 'matched' and returns
|
||||
* the entrants. The in-memory queue is only mutated AFTER the DB commit succeeds, so a
|
||||
* failed drain leaves the queue intact for the next block.
|
||||
* @returns {Promise<{entries:Array}|null>}
|
||||
*/
|
||||
async drain(economy, maxPlayers) {
|
||||
@@ -301,80 +502,84 @@ class MatchQueue {
|
||||
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);
|
||||
const candidates = this._queues[economy].slice(0, count);
|
||||
const userIds = candidates.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]);
|
||||
try {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const result = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'matched', matched_at = NOW()
|
||||
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' }
|
||||
);
|
||||
}
|
||||
});
|
||||
if (result.rowCount !== userIds.length) {
|
||||
// Memory/DB out of sync — abort without mutating memory; retry next block.
|
||||
throw Object.assign(
|
||||
new Error(`Queue drain mismatch: expected ${userIds.length}, got ${result.rowCount}`),
|
||||
{ code: 'DRAIN_MISMATCH' }
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this._log('[MatchQueue] drain error', err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
this._log(`[MatchQueue] drained ${drained.length} players for ${economy}`);
|
||||
return { entries: drained };
|
||||
// DB commit succeeded — now remove them from the in-memory queue.
|
||||
const idSet = new Set(userIds);
|
||||
this._queues[economy] = this._queues[economy].filter(e => !idSet.has(e.userId));
|
||||
this._log(`[MatchQueue] drained ${candidates.length} players for ${economy}`);
|
||||
return { entries: candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}>}
|
||||
* Reattach an in-memory entry after a socket reconnect while still IN THE QUEUE (not in a
|
||||
* running match). The DB row is keyed on user_id + economy.
|
||||
*/
|
||||
async reattach(userId, economy, newSocketId, sessionToken) {
|
||||
economy = this._validateEconomy(economy);
|
||||
if (!economy) return { inQueue: false };
|
||||
|
||||
const token = sessionToken != null ? String(sessionToken) : String(userId);
|
||||
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]);
|
||||
`, [newSocketId, token, 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;
|
||||
this._queues[economy][memIdx].sessionToken = token;
|
||||
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,
|
||||
sessionToken: token,
|
||||
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.
|
||||
* Cancel all queues (e.g. server shutdown). Refunds paid entries via _leaveOne (ledger row
|
||||
* written for each).
|
||||
*/
|
||||
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(() => {});
|
||||
await this._leaveOne(e.userId, economy).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,21 @@
|
||||
* 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.
|
||||
* MatchScheduler creates MatchRooms but does not own Socket.IO broadcasting; it delegates the
|
||||
* started room to MatchManager for transport, the real countdown, persistence, and finish
|
||||
* handling. It does NOT start the engine directly — the manager's countdown does — so honest
|
||||
* and modified clients always start together.
|
||||
*/
|
||||
|
||||
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;
|
||||
const DEFAULT_COUNTDOWN_MS = 3000;
|
||||
|
||||
class MatchScheduler {
|
||||
constructor({
|
||||
@@ -27,7 +29,8 @@ class MatchScheduler {
|
||||
maxPlayers = null,
|
||||
tickMs = null,
|
||||
minDurationMs = null,
|
||||
hardCeilingMs = null
|
||||
hardCeilingMs = null,
|
||||
countdownMs = null
|
||||
} = {}) {
|
||||
this.matchQueue = matchQueue;
|
||||
this.matchManager = matchManager;
|
||||
@@ -38,7 +41,10 @@ class MatchScheduler {
|
||||
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.countdownMs = countdownMs || parseInt(process.env.MATCH_COUNTDOWN_MS, 10) || DEFAULT_COUNTDOWN_MS;
|
||||
|
||||
// Retained for interface stability; the scheduler no longer owns any long-lived timers
|
||||
// (the hard-ceiling watchdog is owned solely by MatchManager, cleared on finalize).
|
||||
this._timeoutIds = [];
|
||||
}
|
||||
|
||||
@@ -54,7 +60,12 @@ class MatchScheduler {
|
||||
if (!this.enabled || !this.matchQueue || !this.matchManager) return;
|
||||
|
||||
for (const economy of ['free', 'credits_prestige', 'crypto_race']) {
|
||||
await this._drainEconomy(economy, blockHeight);
|
||||
try {
|
||||
await this._drainEconomy(economy, blockHeight);
|
||||
} catch (err) {
|
||||
// One economy failing must never block the others (or single-player blocks).
|
||||
this._log(`[MatchScheduler] drain ${economy} error`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +74,12 @@ class MatchScheduler {
|
||||
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(() => {});
|
||||
if (!entries || entries.length < 2) {
|
||||
// Defensive: drain() already marked these 'matched'; refund them so a race that did
|
||||
// not start never consumes credits/tickets.
|
||||
if (entries && entries.length) {
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,7 +90,7 @@ class MatchScheduler {
|
||||
for (const e of entries) {
|
||||
entrants[e.socketId] = {
|
||||
userId: e.userId,
|
||||
name: null, // name is resolved later by MatchManager
|
||||
name: null, // resolved later by MatchManager
|
||||
socketId: e.socketId,
|
||||
sessionToken: e.sessionToken
|
||||
};
|
||||
@@ -91,29 +105,38 @@ class MatchScheduler {
|
||||
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);
|
||||
// Persist match + entrants BEFORE any money movement or notification so a crash mid-race
|
||||
// is recoverable from the DB on next boot.
|
||||
try {
|
||||
await this._persistMatch(room, entries, economy);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] persist failed; refunding entrants', err.message);
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the engine.
|
||||
// MP-C4: collect the crypto pot / commit entry tickets BEFORE notifying players or
|
||||
// starting the engine. If collection fails, ABORT and REFUND every entrant — never
|
||||
// consume tickets for a race that did not start.
|
||||
if (economy === 'crypto_race') {
|
||||
const payoutService = this.matchManager?.matchPayoutService;
|
||||
try {
|
||||
if (!payoutService || typeof payoutService.collectEntryTickets !== 'function') {
|
||||
throw new Error('matchPayoutService unavailable');
|
||||
}
|
||||
await payoutService.collectEntryTickets(room, entries);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] pot collection failed; aborting + refunding', err.message);
|
||||
await this._abortMatch(room, entries, economy);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the engine and hand it to the manager, but DO NOT start it here — the manager's
|
||||
// countdown starts it once the pre-race timer elapses (server-authoritative start).
|
||||
const engine = new MatchEngine({
|
||||
room,
|
||||
tickMs: this.tickMs,
|
||||
@@ -121,11 +144,54 @@ class MatchScheduler {
|
||||
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);
|
||||
// Hand off to MatchManager for transport, the real countdown, reconnect mapping, all
|
||||
// watchdog timers, 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,
|
||||
countdownMs: this.countdownMs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a set of drained entrants (credits/tickets) and cancel their queue rows. Delegates
|
||||
* to MatchQueue which owns the money ledger; falls back to per-user leave if the batch
|
||||
* method is unavailable (older queue implementations).
|
||||
*/
|
||||
async _refund(entries, economy, reason) {
|
||||
if (!this.matchQueue) return;
|
||||
try {
|
||||
if (typeof this.matchQueue.refundEntries === 'function') {
|
||||
await this.matchQueue.refundEntries(entries, economy, reason);
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
await this.matchQueue.leave(e.userId, economy).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] refund error', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a match whose pot could not be collected: mark it cancelled and refund everyone.
|
||||
*/
|
||||
async _abortMatch(room, entries, economy) {
|
||||
if (this.matchManager?.db) {
|
||||
try {
|
||||
await this.matchManager.db.query(
|
||||
`UPDATE matches SET status = 'cancelled', ended_at = NOW() WHERE id = $1`,
|
||||
[room.id]
|
||||
);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] abort mark-cancelled error', err.message);
|
||||
}
|
||||
}
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
}
|
||||
|
||||
async _persistMatch(room, entries, economy) {
|
||||
@@ -172,10 +238,6 @@ class MatchScheduler {
|
||||
});
|
||||
}
|
||||
|
||||
_expireMatch(roomId, reason) {
|
||||
this.matchManager?.expire(roomId, reason);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const id of this._timeoutIds) clearTimeout(id);
|
||||
this._timeoutIds = [];
|
||||
|
||||
@@ -511,7 +511,9 @@ class PaymentHandlers {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
// Store & unref so tests / process can exit
|
||||
// Store & unref so tests / process can exit (the 30-min timer must never keep the
|
||||
// event loop alive on its own — in production the server keeps the process running).
|
||||
if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref();
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,23 @@
|
||||
* - stableId: prefers the session's stable user id (anon_token -> users.id); only when no
|
||||
* session is resolvable does it fall back to the ephemeral socket id. A client that
|
||||
* drops its token to mint a new identity is still caught by the IP limit.
|
||||
* - clientIp: the socket handshake address, or the first X-Forwarded-For hop when the
|
||||
* - clientIp: the socket handshake address, or the RIGHTMOST X-Forwarded-For hop when the
|
||||
* deployment is behind a trusted reverse proxy (TRUST_PROXY=true). XFF is ONLY trusted
|
||||
* when TRUST_PROXY is set, since otherwise a client could spoof it.
|
||||
*/
|
||||
|
||||
// S5: with a single trusted nginx proxy in front, nginx APPENDS the real client IP as the
|
||||
// last hop of X-Forwarded-For. The leftmost entries are attacker-controlled (a client can
|
||||
// pre-populate the header), so we must read the RIGHTMOST hop — the one the proxy added —
|
||||
// not the leftmost. Still gated on TRUST_PROXY so a direct-exposure deploy can't be spoofed.
|
||||
function clientIp(socket) {
|
||||
try {
|
||||
if (process.env.TRUST_PROXY === 'true') {
|
||||
const xff = socket.handshake?.headers?.['x-forwarded-for'];
|
||||
if (xff) return String(xff).split(',')[0].trim();
|
||||
if (xff) {
|
||||
const hops = String(xff).split(',').map(h => h.trim()).filter(Boolean);
|
||||
if (hops.length) return hops[hops.length - 1];
|
||||
}
|
||||
}
|
||||
return socket.handshake?.address || null;
|
||||
} catch (_) {
|
||||
|
||||
@@ -379,12 +379,18 @@ class SessionManager {
|
||||
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
// SECURE: Parameterized query for cleanup
|
||||
// Only delete truly disposable anonymous users: long-idle, zero credits, no payout
|
||||
// address, and — critically — NO history in any table that references users via a
|
||||
// RESTRICT/NO ACTION foreign key. Guarding with NOT EXISTS means the DELETE can never
|
||||
// abort on an FK violation, and we never remove a user who has payments, payouts, or
|
||||
// games on record (chat_messages / credit_transactions are optional extra guards).
|
||||
const result = await this.db.query(
|
||||
`DELETE FROM users
|
||||
WHERE last_seen < NOW() - INTERVAL '90 days'
|
||||
AND credits = 0
|
||||
RETURNING id`,
|
||||
`DELETE FROM users u
|
||||
WHERE u.last_seen < NOW() - INTERVAL '90 days' AND u.credits = 0 AND u.payout_address IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM payments p WHERE p.user_id = u.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM payouts po WHERE po.user_id = u.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM games g WHERE g.user_id = u.id)
|
||||
RETURNING u.id`,
|
||||
[] // No parameters needed
|
||||
);
|
||||
|
||||
@@ -397,6 +403,111 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C6/G6: Recover games left stuck at status='active' when the server was killed
|
||||
* mid-run. On a fresh boot no game can legitimately still be running, so every such row
|
||||
* is an orphan: idempotently refund the player what they paid (the linked confirmed
|
||||
* single_game payment, or the credits spent for a PAID_CREDITS game) and then finalize
|
||||
* the game's status. Safe to run repeatedly — each game is locked FOR UPDATE and skipped
|
||||
* unless still 'active', and each refund is deduped by a per-game credit_transactions
|
||||
* reason key, so re-runs neither double-refund nor double-finalize. index.js calls this
|
||||
* at startup right after recoverPendingPayments.
|
||||
*/
|
||||
async recoverOrphanedGames() {
|
||||
const summary = { finalized: 0, refunded: 0, creditsRefunded: 0 };
|
||||
try {
|
||||
const creditsPerGame = (this.gameModeManager && this.gameModeManager.creditsPerGameCost) || 1;
|
||||
|
||||
const orphans = await this.db.query(
|
||||
`SELECT id FROM games WHERE status = 'active' ORDER BY id ASC`,
|
||||
[]
|
||||
);
|
||||
|
||||
for (const orphan of orphans.rows) {
|
||||
try {
|
||||
const outcome = await this.db.withTransaction(async (client) => {
|
||||
// Lock the game row and re-check under the lock: another instance (or a prior
|
||||
// run) may already have finalized it.
|
||||
const lock = await client.query(
|
||||
`SELECT id, user_id, game_mode, payment_id, status
|
||||
FROM games WHERE id = $1 FOR UPDATE`,
|
||||
[orphan.id]
|
||||
);
|
||||
const game = lock.rows[0];
|
||||
if (!game || game.status !== 'active') return null; // already handled
|
||||
|
||||
const reason = `orphan_game_refunded:${game.id}`;
|
||||
let refunded = false;
|
||||
|
||||
if (game.user_id) {
|
||||
// Dedup: never refund the same orphaned game twice.
|
||||
const dup = await client.query(
|
||||
`SELECT 1 FROM credit_transactions WHERE user_id = $1 AND reason = $2 LIMIT 1`,
|
||||
[game.user_id, reason]
|
||||
);
|
||||
|
||||
if (dup.rows.length === 0) {
|
||||
// A refund is warranted when the player actually paid: a PAID_CREDITS game
|
||||
// consumed credits, or a PAID_SINGLE game had a confirmed payment.
|
||||
let doRefund = false;
|
||||
if (game.game_mode === 'PAID_CREDITS') {
|
||||
doRefund = true;
|
||||
} else if (game.payment_id != null) {
|
||||
const pay = await client.query(
|
||||
`SELECT status FROM payments WHERE id = $1`,
|
||||
[game.payment_id]
|
||||
);
|
||||
doRefund = !!pay.rows[0] && pay.rows[0].status === 'confirmed';
|
||||
}
|
||||
|
||||
if (doRefund) {
|
||||
const upd = await client.query(
|
||||
`UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`,
|
||||
[creditsPerGame, game.user_id]
|
||||
);
|
||||
const newBalance = upd.rows[0]?.credits ?? 0;
|
||||
await client.query(
|
||||
`INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, $3, $4, 'refund')`,
|
||||
[game.user_id, creditsPerGame, reason, newBalance]
|
||||
);
|
||||
refunded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the game: refunded games are marked 'refunded', the rest 'expired'.
|
||||
const newStatus = refunded ? 'refunded' : 'expired';
|
||||
await client.query(
|
||||
`UPDATE games SET status = $1, completed_at = COALESCE(completed_at, NOW()) WHERE id = $2`,
|
||||
[newStatus, game.id]
|
||||
);
|
||||
|
||||
return { refunded };
|
||||
});
|
||||
|
||||
if (outcome) {
|
||||
summary.finalized++;
|
||||
if (outcome.refunded) {
|
||||
summary.refunded++;
|
||||
summary.creditsRefunded += creditsPerGame;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[OrphanRecovery] Failed to recover game ${orphan.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.finalized > 0) {
|
||||
console.log(`[OrphanRecovery] Finalized ${summary.finalized} orphaned game(s); refunded ${summary.refunded} (${summary.creditsRefunded} credits).`);
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to recover orphaned games');
|
||||
console.error('[OrphanRecovery] Error recovering orphaned games:', normalized.message);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
generateSecureToken() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
+158
-12
@@ -344,7 +344,18 @@ class SocketHandlers {
|
||||
if (restored) {
|
||||
restoredGame = restored.game;
|
||||
const suspendedState = restored.suspendedState;
|
||||
|
||||
|
||||
// G1/C3: resume the block-timeout clock from the ORIGINAL entry block,
|
||||
// not the reconnect height. suspendedGameManager persists the entry block
|
||||
// and hands it back here; restore it onto the in-memory user so
|
||||
// checkGamesTimeout continues counting from where it left off. If it's
|
||||
// missing, leave blockRec unset — checkGamesTimeout records the first
|
||||
// observed height rather than treating the game as instant-death.
|
||||
const entryBlock = (restored.blockRec ?? suspendedState?.blockRec ?? null);
|
||||
if (memUser && entryBlock != null) {
|
||||
memUser.blockRec = entryBlock;
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🔄 [handleConnection] Restored game ${restoredGame.id} for user ${dbUserId}`);
|
||||
}
|
||||
@@ -450,8 +461,8 @@ class SocketHandlers {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message);
|
||||
});
|
||||
});
|
||||
socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button
|
||||
socket.on('play_free', () => this.handleAutoStart(socket, { free: true })); // Explicit free-play choice
|
||||
socket.on('auto_start', (data) => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {})); // New handler for start button
|
||||
socket.on('play_free', (data) => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true })); // Explicit free-play choice
|
||||
socket.on('join_queue', () => this.handleJoinQueue(socket)); // Queue instead of auto-start
|
||||
socket.on('early_entry', () => this.handleEarlyEntry(socket)); // Early entry without waiting for block
|
||||
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
|
||||
@@ -479,10 +490,28 @@ 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
|
||||
// Match mode handlers. Every match listener is wrapped so a match-layer fault can
|
||||
// never crash the connection or leak a stack to the client — it logs and emits a
|
||||
// benign 'match_error' instead (C5). Identity is always resolved from the CONNECTION,
|
||||
// never from the client payload.
|
||||
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));
|
||||
socket.on('match_move', (data) => {
|
||||
try {
|
||||
this.matchManager.move(socket, data);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_move error:', err.message);
|
||||
socket.emit('match_error', { message: 'Move could not be processed.' });
|
||||
}
|
||||
});
|
||||
socket.on('match_leave', () => {
|
||||
try {
|
||||
this.matchManager.leave(socket);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_leave error:', err.message);
|
||||
socket.emit('match_error', { message: 'Could not leave the match.' });
|
||||
}
|
||||
});
|
||||
socket.on('match_reconnect', (data) => this._handleMatchReconnect(socket, data));
|
||||
// Tavern match spectator bridge
|
||||
socket.on('tavern_match_list', () => {
|
||||
const list = this.tavernMatchBridge ? this.tavernMatchBridge.getActiveMatches() : [];
|
||||
@@ -503,6 +532,13 @@ class SocketHandlers {
|
||||
// honoured when the instance allows free play; otherwise fall through to paid.
|
||||
const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled;
|
||||
|
||||
// C7: optional client-supplied entropy for provably-fair seed derivation. Must be
|
||||
// hex (≤64 chars); anything malformed is silently ignored (falls back to ''). This
|
||||
// is purely additive — existing clients that send no seed are unaffected.
|
||||
const clientSeed = (typeof opts.clientSeed === 'string' && /^[a-fA-F0-9]{0,64}$/.test(opts.clientSeed))
|
||||
? opts.clientSeed
|
||||
: undefined;
|
||||
|
||||
// Rate limiting for game starts — keyed on stable identity + IP so reconnecting
|
||||
// (new socket.id) can't reset the limit.
|
||||
const rlId = stableId(socket, this.sessionManager);
|
||||
@@ -582,7 +618,7 @@ class SocketHandlers {
|
||||
|
||||
// Process start (free / credits deduction / payment link)
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree });
|
||||
const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree, clientSeed });
|
||||
if (!startRes.success) {
|
||||
// Abort game
|
||||
this.activeGames.delete(socket.id);
|
||||
@@ -712,6 +748,89 @@ class SocketHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable match session/identity for a connection. Identity is derived ONLY
|
||||
* from the CONNECTION (the session cache, then a DB lookup by socket) — never from any
|
||||
* client-supplied payload — so a client can't act on another user's behalf (C5). Mirrors
|
||||
* how other handlers resolve identity via sessionManager.
|
||||
* @returns {Promise<{userId:number, socketId:string, sessionToken:(string|null), user:Object}|null>}
|
||||
*/
|
||||
async _resolveMatchSession(socket) {
|
||||
if (!this.sessionManager || !socket) return null;
|
||||
let sessionUser = null;
|
||||
try {
|
||||
sessionUser = this.sessionManager.sessions?.get(socket.id) || null;
|
||||
} catch (_) { sessionUser = null; }
|
||||
if (!sessionUser) {
|
||||
try { sessionUser = await this.sessionManager.getBySocket(socket.id); } catch (_) { sessionUser = null; }
|
||||
}
|
||||
if (!sessionUser || sessionUser.id == null) return null;
|
||||
return {
|
||||
userId: sessionUser.id,
|
||||
socketId: socket.id,
|
||||
sessionToken: sessionUser.anon_token || null,
|
||||
user: sessionUser
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a match-mode queue join/leave (C5/S4). The economy comes from the payload but
|
||||
* the IDENTITY is resolved from the connection, never from data. Never throws: any fault
|
||||
* is logged and reported to the client via 'match_error'. If match mode is disabled /
|
||||
* the queue is absent, respond benignly and return.
|
||||
* @param {Object} socket
|
||||
* @param {{action?:('join'|'leave'), economy?:string}} data
|
||||
*/
|
||||
async _handleMatchQueue(socket, data = {}) {
|
||||
try {
|
||||
// Match disabled or queue not wired → benign no-op.
|
||||
if (!this.matchQueue || (typeof this.matchQueue.isEnabled === 'function' && !this.matchQueue.isEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await this._resolveMatchSession(socket);
|
||||
if (!session) {
|
||||
socket.emit('match_error', { message: 'Please reconnect before joining a match.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const economy = (data && typeof data.economy === 'string') ? data.economy : undefined;
|
||||
const action = (data && data.action === 'leave') ? 'leave' : 'join';
|
||||
|
||||
// Carry economy on the session too, so a leave(session) that keys on economy and
|
||||
// an enqueue(session,{economy}) that keys on the explicit arg both stay correct.
|
||||
const matchSession = { ...session, economy };
|
||||
|
||||
if (action === 'leave') {
|
||||
await this.matchQueue.leave(matchSession);
|
||||
} else {
|
||||
await this.matchQueue.enqueue(matchSession, { economy });
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] _handleMatchQueue error:', err.message);
|
||||
socket.emit('match_error', { message: 'Could not update the match queue. Please try again.' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a match-mode reconnect (C5). Resolves identity from the connection and hands the
|
||||
* live socket + session to the match manager. Same never-throw discipline as the other
|
||||
* match listeners; benign no-op when match mode is disabled.
|
||||
*/
|
||||
async _handleMatchReconnect(socket, data = {}) {
|
||||
try {
|
||||
if (!this.matchManager || this.matchManager.enabled === false) {
|
||||
return;
|
||||
}
|
||||
const session = await this._resolveMatchSession(socket);
|
||||
if (!session) return;
|
||||
await this.matchManager.handleReconnect(socket, session);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match_reconnect error:', err.message);
|
||||
socket.emit('match_error', { message: 'Could not rejoin the match.' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the active socket instance by id (io.sockets.sockets Map) */
|
||||
_getLiveSocket(socketId) {
|
||||
try {
|
||||
@@ -855,7 +974,16 @@ class SocketHandlers {
|
||||
// Clean up tavern presence
|
||||
if (this.tavernManager) {
|
||||
this.tavernManager.handleDisconnect(socketId);
|
||||
this.matchManager.handleDisconnect(socketId, null);
|
||||
}
|
||||
|
||||
// Clean up match presence (starts the AFK grace timer). New signature takes the
|
||||
// live socket (C5). Guarded so a match-layer fault can't abort disconnect cleanup.
|
||||
if (this.matchManager) {
|
||||
try {
|
||||
this.matchManager.handleDisconnect(socket);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[SocketHandlers] match handleDisconnect error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Evict the cached session row LAST (after the suspend logic above read it),
|
||||
@@ -1084,14 +1212,16 @@ class SocketHandlers {
|
||||
* Start games for waiting players when a new block is found
|
||||
*/
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
return await this.queueHandler.startGamesForWaiting(blockHeight);
|
||||
// Also drain match-mode queues on the new block.
|
||||
// Start queued single-player games first and capture the result to return.
|
||||
const result = await this.queueHandler.startGamesForWaiting(blockHeight);
|
||||
// Then drain match-mode queues on the same block, fire-and-forget so a match-layer
|
||||
// error can never block or fail single-player block processing (MP-C3).
|
||||
if (this.matchScheduler) {
|
||||
this.matchScheduler.onBlock(blockHeight).catch(err => {
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.error('[SocketHandlers] Match scheduler block error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1110,9 +1240,25 @@ class SocketHandlers {
|
||||
const now = Date.now();
|
||||
const snapshot = Array.from(this.activeGames.entries());
|
||||
for (const [socketId, game] of snapshot) {
|
||||
// G3: the snapshot can go stale while we await a previous game-over. If this game
|
||||
// was concurrently won or otherwise ended (removed from activeGames on another
|
||||
// async path), never re-end it here as a loss/timeout.
|
||||
if (!this.activeGames.has(socketId)) continue;
|
||||
|
||||
const user = this.connectionHandler.getUserBySocket(socketId);
|
||||
if (!user) continue;
|
||||
|
||||
// G1/C3: only end when the entry block is DEFINED and a later block has arrived.
|
||||
// If we've never recorded an entry block for this player, record the current
|
||||
// height as the first observation instead of treating a missing value as either
|
||||
// immortal (never times out) or instant-death (times out on the first tick).
|
||||
if (user.blockRec == null) {
|
||||
user.blockRec = currentHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
const elapsedMs = now - (game.startedAt || now);
|
||||
if (user && user.blockRec && currentHeight > user.blockRec && elapsedMs >= graceMs) {
|
||||
if (currentHeight > user.blockRec && elapsedMs >= graceMs) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`💀 GAME TIMEOUT for player ${socketId}: entered on block ${user.blockRec}, died on block ${currentHeight}`);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ class SuspendedGameManager {
|
||||
hasTreasure: game.player?.hasTreasure || false,
|
||||
moveCount: game.moveCount || 0,
|
||||
blockHeight: game.blockHeight || null,
|
||||
// Entry block height (blockRec) for reconnect timeout continuity (C3).
|
||||
// Prefer an explicit value from the caller, then the value captured on the
|
||||
// game object at creation. Never invent one — a missing value stays null so
|
||||
// the timeout logic treats it as "not yet observed" rather than instant-death.
|
||||
blockRec: (additionalState.blockRec !== undefined && additionalState.blockRec !== null)
|
||||
? additionalState.blockRec
|
||||
: (game.blockRec ?? game.blockHeight ?? null),
|
||||
...additionalState
|
||||
};
|
||||
|
||||
@@ -114,7 +121,33 @@ class SuspendedGameManager {
|
||||
|
||||
// Update game with new socket ID
|
||||
game.socketId = newSocketId;
|
||||
|
||||
|
||||
// Keep a stable DB user id on the game object so completeGame can key on it
|
||||
// without any socket_id lookup. It is normally stamped at creation; backfill
|
||||
// from the alias just in case an older game object only carries one of them.
|
||||
if (game.userId == null && game.dbUserId != null) game.userId = game.dbUserId;
|
||||
if (game.dbUserId == null && game.userId != null) game.dbUserId = game.userId;
|
||||
|
||||
// Align the persisted game row's socket_id to the reconnecting socket so
|
||||
// downstream row-matching (game-over UPDATE, completion) stays consistent.
|
||||
// Fire-and-forget so restoreGame remains synchronous for its callers.
|
||||
try {
|
||||
const db = game.db || suspended.db || null;
|
||||
if (db && game.id) {
|
||||
Promise.resolve(
|
||||
db.query('UPDATE games SET socket_id = $1 WHERE dungeon_seed = $2', [newSocketId, game.id])
|
||||
).catch((err) => {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn(`[SuspendedGameManager] socket_id realign failed for game ${game.id}: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn(`[SuspendedGameManager] socket_id realign threw for game ${game.id}: ${normalizeError(err).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the user reference if provided
|
||||
if (newUser) {
|
||||
game.user = newUser;
|
||||
@@ -136,7 +169,10 @@ class SuspendedGameManager {
|
||||
|
||||
return {
|
||||
game,
|
||||
suspendedState: suspended
|
||||
suspendedState: suspended,
|
||||
// Expose the entry block height so the reconnect path can restore
|
||||
// memUser.blockRec and keep the block-timeout logic continuous (C3).
|
||||
blockRec: suspended.blockRec ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user