mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* AddressManager
|
||||
* Handles detection, pending confirmation, validation, and persistence of payout addresses.
|
||||
* Keeps socketHandlers slimmer by encapsulating this concern.
|
||||
*/
|
||||
const { AppError, normalizeError } = require('../utils/errors');
|
||||
|
||||
// Candidate extraction accepts every Monero nettype; isValidAddress() then restricts XMR to the
|
||||
// configured network (mainnet 4/8, stagenet 5/7, testnet 9/B). Wallet RPC performs the final
|
||||
// checksum + nettype validation before persistence.
|
||||
const ADDRESS_REGEX = /((?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/;
|
||||
const XMR_ADDRESS_REGEX = /^(?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}$/;
|
||||
const WOW_ADDRESS_REGEX = /^(?:(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})$/;
|
||||
|
||||
class AddressManager {
|
||||
constructor({ gameModeManager, broadcastManager, io, debugManager, onConfirmed = null }) {
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.pending = new Map(); // socketId -> address awaiting confirmation
|
||||
this.onConfirmed = onConfirmed; // optional callback(socketId, accepted, address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an unconfirmed address prompt for a departed socket. Required because confirm() is the
|
||||
* only other path that clears `pending`, and its keys are volatile socket ids: a player who
|
||||
* triggers detection then leaves without answering would leak an entry.
|
||||
*/
|
||||
forgetSocket(socketId) {
|
||||
this.pending.delete(socketId);
|
||||
}
|
||||
|
||||
detectInText(text) {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
const match = text.match(ADDRESS_REGEX);
|
||||
if (!match) return null;
|
||||
const candidate = match[1];
|
||||
return this.isValidAddress(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
isValidAddress(address) {
|
||||
const value = typeof address === 'string' ? address.trim() : '';
|
||||
if (!value) return false;
|
||||
// Upper bound accommodates integrated addresses.
|
||||
if (value.length < 80 || value.length > 120) return false;
|
||||
|
||||
const currency = String(this.gameModeManager?.cryptoType || '').toUpperCase();
|
||||
if (currency === 'XMR') {
|
||||
if (!XMR_ADDRESS_REGEX.test(value)) return false;
|
||||
const network = String(this.gameModeManager?.network || 'mainnet').toLowerCase();
|
||||
const prefixes = network === 'stagenet' ? new Set(['5', '7'])
|
||||
: (network === 'testnet' ? new Set(['9', 'B']) : new Set(['4', '8']));
|
||||
return prefixes.has(value[0]);
|
||||
}
|
||||
if (currency === 'WOW') return WOW_ADDRESS_REGEX.test(value);
|
||||
return ADDRESS_REGEX.test(value);
|
||||
}
|
||||
|
||||
async _validateWithWallet(address) {
|
||||
if (!this.gameModeManager || typeof this.gameModeManager.validatePayoutAddress !== 'function') {
|
||||
return true; // injected managers without wallet access are syntax-checked only
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await this.gameModeManager.validatePayoutAddress(address);
|
||||
} catch (error) {
|
||||
throw new AppError('Payout address validation failed', {
|
||||
safeMessage: 'Could not verify that address with the configured wallet network. Try again.'
|
||||
});
|
||||
}
|
||||
if (!result || result.valid !== true) {
|
||||
throw new AppError('Payout address is invalid for the configured wallet network', {
|
||||
safeMessage: 'That address is not valid for this server\'s configured network.'
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleDetection(socketId, address) {
|
||||
try {
|
||||
if (this.gameModeManager) {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socketId);
|
||||
if (userRow && userRow.payout_address === address) {
|
||||
const shortAddr = address.slice(0, 10) + '…' + address.slice(-6);
|
||||
this.io.to(socketId).emit('address_confirmed', { address, message: `Payout address already set to ${shortAddr}. You can update it via the address manager.` });
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address already on file. Use the address button/command to replace it.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Address detection user lookup failed:', e.message);
|
||||
}
|
||||
this.pending.set(socketId, address);
|
||||
const shortAddr = address.slice(0, 10) + '…' + address.slice(-6);
|
||||
this.io.to(socketId).emit('address_detected', { address: shortAddr, fullAddress: address, message: `Detected payout address: ${shortAddr}\nType 'confirm' to save or 'cancel' to discard.` });
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address detected. Type confirm or cancel.');
|
||||
}
|
||||
|
||||
async saveAddress(socketId, address, { autoConfirm = true } = {}) {
|
||||
const trimmed = typeof address === 'string' ? address.trim() : '';
|
||||
if (!trimmed) {
|
||||
throw new AppError('No payout address supplied', {
|
||||
safeMessage: 'Please enter a payout address.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.isValidAddress(trimmed)) {
|
||||
throw new AppError('Invalid payout address format', {
|
||||
safeMessage: 'That does not look like a valid XMR/WOW address.'
|
||||
});
|
||||
}
|
||||
|
||||
this.pending.set(socketId, trimmed);
|
||||
|
||||
if (autoConfirm) {
|
||||
const saved = await this.confirm(socketId, true);
|
||||
if (!saved) return false;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async confirm(socketId, accept) {
|
||||
const pending = this.pending.get(socketId);
|
||||
if (!pending) {
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'warning', 'No address pending. Paste an address first.');
|
||||
return false;
|
||||
}
|
||||
if (!accept) {
|
||||
this.pending.delete(socketId);
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address entry cancelled.');
|
||||
this.io.to(socketId).emit('address_confirmed', { cancelled: true, message: 'Address entry cancelled.' });
|
||||
if (this.onConfirmed) {
|
||||
try { this.onConfirmed(socketId, false, pending); } catch(_) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (this.gameModeManager) {
|
||||
try {
|
||||
await this._validateWithWallet(pending);
|
||||
const saved = await this.gameModeManager.setUserPayoutAddress(socketId, pending);
|
||||
if (saved !== true) throw new Error('Payout address was not persisted');
|
||||
this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address saved.' });
|
||||
} catch (e) {
|
||||
const normalized = normalizeError?.(e, 'Failed to save address') || e;
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'error', normalized.safeMessage || 'Failed to save address. Try again.');
|
||||
this.io.to(socketId).emit('address_update_error', {
|
||||
message: normalized.safeMessage || 'Failed to save address. Try again.'
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.pending.delete(socketId);
|
||||
}
|
||||
} else {
|
||||
this.pending.delete(socketId);
|
||||
this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address accepted (session only).' });
|
||||
}
|
||||
if (this.onConfirmed) {
|
||||
try { this.onConfirmed(socketId, true, pending); } catch(_) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
requiresPayoutAddress() {
|
||||
if (!this.gameModeManager) return false; // free mode has no payout address requirement
|
||||
const mode = this.gameModeManager.gameMode;
|
||||
if (typeof this.gameModeManager.requiresPayoutAddressForMode === 'function') {
|
||||
return this.gameModeManager.requiresPayoutAddressForMode(mode);
|
||||
}
|
||||
return (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AddressManager;
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Broadcast Manager Module
|
||||
* Handles all socket.io event broadcasting for the Wownerogue game server:
|
||||
* global emits to every client and targeted emits to a single player socket.
|
||||
*/
|
||||
|
||||
class BroadcastManager {
|
||||
constructor(io, debugManager = null) {
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this._lastBroadcastBlock = null;
|
||||
this._lastUserCount = -1;
|
||||
}
|
||||
|
||||
setDebugManager(debugManager) {
|
||||
this.debugManager = debugManager;
|
||||
}
|
||||
|
||||
// ====== GLOBAL BROADCASTS (All Clients) ======
|
||||
|
||||
/**
|
||||
* Broadcast connected user count to all clients
|
||||
*/
|
||||
broadcastUserCount() {
|
||||
// io.sockets.sockets is the authoritative set; io.engine.clientsCount is the fallback for
|
||||
// adapters that do not expose it.
|
||||
let count = 0;
|
||||
if (this.io.sockets && this.io.sockets.sockets) {
|
||||
count = this.io.sockets.sockets.size || 0;
|
||||
} else if (this.io.engine) {
|
||||
count = this.io.engine.clientsCount || 0;
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👥 Broadcasting user count: ${count} (last was ${this._lastUserCount})`);
|
||||
}
|
||||
|
||||
// Emitted unconditionally, including on an unchanged count, so clients that just connected
|
||||
// receive a value.
|
||||
this._lastUserCount = count;
|
||||
this.io.emit('user_count', { count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast block height to all connected clients
|
||||
* @param {number} blockHeight - Current block height
|
||||
* @param {{hash?: string, difficulty?: number, txPoolSize?: number}|null} [tip] - Cosmetic chain
|
||||
* tip for ambient UI. Public chain data only; omitted entirely when unavailable so clients
|
||||
* keep whatever they last had rather than flickering to a blank readout.
|
||||
*/
|
||||
broadcastBlockHeight(blockHeight, tip) {
|
||||
// Only log when the height actually changes to avoid per-client spam
|
||||
if (blockHeight !== this._lastBroadcastBlock) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📡 Broadcasting block height ${blockHeight} to all clients`);
|
||||
}
|
||||
this._lastBroadcastBlock = blockHeight;
|
||||
}
|
||||
this.io.emit('blockheight', this.blockHeightPayload(blockHeight, tip));
|
||||
}
|
||||
|
||||
/** Shared shape for the `blockheight` event so the per-socket emit on connect matches. */
|
||||
blockHeightPayload(blockHeight, tip) {
|
||||
const payload = { blockHeight };
|
||||
if (tip && tip.hash) payload.hash = tip.hash;
|
||||
if (tip && Number.isFinite(tip.difficulty)) payload.difficulty = tip.difficulty;
|
||||
if (tip && Number.isFinite(tip.txPoolSize)) payload.txPoolSize = tip.txPoolSize;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast chat message to all connected clients
|
||||
* @param {string} username - Username of the sender
|
||||
* @param {string} message - Chat message content
|
||||
* @param {number} timestamp - Message timestamp
|
||||
* @param {string} publicId - Short, non-sensitive public id of the sender. Never the full
|
||||
* socket.id, which must not leave the server.
|
||||
*/
|
||||
broadcastChatMessage(username, message, timestamp, publicId) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`💬 Broadcasting chat message from ${publicId}: "${message}"`);
|
||||
}
|
||||
|
||||
this.io.emit('chat_broadcast', {
|
||||
username: username,
|
||||
message: message,
|
||||
timestamp: timestamp,
|
||||
publicId: publicId
|
||||
});
|
||||
}
|
||||
|
||||
// ====== PLAYER-SPECIFIC BROADCASTS ======
|
||||
|
||||
/**
|
||||
* Send status update to a specific player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {string} type - Type of status (info, error, warning, connection, help, etc.)
|
||||
* @param {string} message - The status message
|
||||
*/
|
||||
sendStatusUpdate(socketId, type, message) {
|
||||
this.io.to(socketId).emit('status_update', {
|
||||
type: type,
|
||||
message: message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome message to a newly connected client
|
||||
* @param {string} clientId - The client ID
|
||||
*/
|
||||
sendWelcome(clientId) {
|
||||
this.io.to(clientId).emit('welcome', clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send game state update to the player's socket
|
||||
* @param {string} playerSocketId - The socket ID of the player
|
||||
* @param {object} gameState - The game state to broadcast
|
||||
*/
|
||||
sendGameUpdate(playerSocketId, gameState) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📡 sendGameUpdate to ${playerSocketId}:`);
|
||||
console.log(` - Lighting data: ${!!gameState.lighting} (${gameState.lighting ? Object.keys(gameState.lighting).length : 0} rows)`);
|
||||
console.log(` - Torch data: ${!!gameState.torches} (${gameState.torches ? gameState.torches.length : 0} torches)`);
|
||||
}
|
||||
|
||||
this.io.to(playerSocketId).emit('game_update', gameState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send game start event to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {object} gameState - Initial game state
|
||||
*/
|
||||
sendGameStart(socketId, gameState) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🎮 SENDING GAME_START to ${socketId}`);
|
||||
}
|
||||
this.io.to(socketId).emit('game_start', gameState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send game over event to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {object} gameOverData - Game over information
|
||||
*/
|
||||
sendGameOver(socketId, gameOverData) {
|
||||
this.io.to(socketId).emit('game_over', gameOverData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send waiting status to player (for queue system)
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {object} waitingData - Waiting status information
|
||||
*/
|
||||
sendWaitingStatus(socketId, waitingData) {
|
||||
this.io.to(socketId).emit('waiting_status', waitingData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send queue cancelled event to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
*/
|
||||
sendQueueCancelled(socketId) {
|
||||
this.io.to(socketId).emit('queue_cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send simple message to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {string} message - The message content
|
||||
*/
|
||||
sendMessage(socketId, message) {
|
||||
this.io.to(socketId).emit('message', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send socket registration confirmation
|
||||
* @param {string} socketId - The socket ID
|
||||
* @param {object} registrationData - Registration confirmation data
|
||||
*/
|
||||
sendSocketRegistered(socketId, registrationData) {
|
||||
this.io.to(socketId).emit('socket_registered', registrationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send debug pong response
|
||||
* @param {string} socketId - The socket ID
|
||||
* @param {object} pongData - Debug pong data
|
||||
*/
|
||||
sendDebugPong(socketId, pongData) {
|
||||
this.io.to(socketId).emit('debug_pong', pongData);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = BroadcastManager;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* ChatProvider: abstraction over chat message delivery and history.
|
||||
*
|
||||
* The seam that lets the chat backend be swapped (e.g. for a Nostr channel) without changing
|
||||
* callers. Application concerns (commands, moderation, rate limiting, XSS escaping) stay in
|
||||
* the caller (ChatHandler / TavernManager); a provider only delivers and (optionally) stores.
|
||||
*
|
||||
* Scopes:
|
||||
* 'global' → the whole server (the main lobby chat).
|
||||
* 'tavern:<id>' → a single Socket.IO room (tavern chat), ephemeral by default.
|
||||
*/
|
||||
class ChatProvider {
|
||||
// eslint-disable-next-line no-empty-function
|
||||
async initialize() {}
|
||||
|
||||
/**
|
||||
* Deliver (and optionally persist) a chat message.
|
||||
* @param {object} msg
|
||||
* @param {string} msg.scope 'global' or a room channel.
|
||||
* @param {string} msg.username Display name / short id.
|
||||
* @param {string} msg.text Message text (caller has already sanitized/escaped it).
|
||||
* @param {number} [msg.ts] Timestamp (ms).
|
||||
* @param {string} [msg.socketId]
|
||||
* @param {number|null} [msg.userId]
|
||||
*/
|
||||
async publish(msg) { // eslint-disable-line no-unused-vars
|
||||
throw new Error('ChatProvider.publish not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent messages for a scope, oldest → newest.
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.scope]
|
||||
* @param {number} [opts.limit]
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getHistory({ scope = 'global', limit = 50 } = {}) { // eslint-disable-line no-unused-vars
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message whose nostr event was signed by the CLIENT (per-player identity, Phase 2).
|
||||
* The base/local provider has no relay, so it just delivers the message in-game: the signed
|
||||
* `event` is ignored. NostrChatProvider overrides this to ALSO publish the pre-signed event.
|
||||
* @param {object} req { event, scope, username, text, ts, socketId, userId }
|
||||
*/
|
||||
async relaySignedEvent({ event, ...msg } = {}) { // eslint-disable-line no-unused-vars
|
||||
return this.publish({ scope: 'global', ...msg });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-function
|
||||
async shutdown() {}
|
||||
}
|
||||
|
||||
module.exports = ChatProvider;
|
||||
@@ -0,0 +1,155 @@
|
||||
const ChatProvider = require('./ChatProvider');
|
||||
const { escapeChatText } = require('../../utils/escapeChat');
|
||||
|
||||
/**
|
||||
* NostrChatProvider: global cross-server chat over nostr, layered on the existing ChatProvider seam.
|
||||
*
|
||||
* It DECORATES a local provider (SocketChatProvider): every message is still delivered in-game +
|
||||
* persisted exactly as before, and on top of that a global message is fanned out to nostr relays
|
||||
* (relay.smirk.cash) so other game servers/modes see it. Messages arriving from the relay are
|
||||
* delivered locally-only (never re-published), so there are no echo loops.
|
||||
*
|
||||
* publish(local msg) → local.publish (in-game + history) + sign & relay.publish (global fan-out)
|
||||
* relay event (remote)→ local.publish only (in-game + history), text/username escaped
|
||||
*
|
||||
* Topology: client → server → relay. The server stays the moderation authority (bans, rate-limit,
|
||||
* history in Postgres via the local provider); the relay is transport + fan-out. Signing with a
|
||||
* registered Smirk npub (the bridge key, or later each player's own) is what the relay's
|
||||
* inbox-outbox policy requires to publish, so global chat is a concrete reason to sign in with Smirk.
|
||||
*
|
||||
* Behavior-preserving: with no transport/signer (nostr disabled) this is exactly the local provider.
|
||||
*/
|
||||
class NostrChatProvider extends ChatProvider {
|
||||
constructor({
|
||||
local,
|
||||
transport = null,
|
||||
signer = null,
|
||||
scope = 'global',
|
||||
channelTag = 'wowngeon-global',
|
||||
kind = 1,
|
||||
serverOrigin = null,
|
||||
debugManager = null,
|
||||
maxRemotePerMin = 120,
|
||||
now = () => Date.now()
|
||||
} = {}) {
|
||||
super();
|
||||
if (!local) throw new Error('NostrChatProvider requires a local provider');
|
||||
this.local = local;
|
||||
this.transport = transport;
|
||||
this.signer = signer;
|
||||
this.scope = scope;
|
||||
this.channelTag = channelTag;
|
||||
this.kind = kind;
|
||||
this.serverOrigin = serverOrigin;
|
||||
this.debugManager = debugManager;
|
||||
this.maxRemotePerMin = maxRemotePerMin;
|
||||
this.now = now;
|
||||
|
||||
this._seen = new Set(); // event ids we've delivered/published, for echo dedupe
|
||||
this._seenOrder = []; // bounded FIFO of the same ids
|
||||
this._remoteTimes = []; // recent remote-delivery timestamps (rate limit)
|
||||
}
|
||||
|
||||
_globalEnabled() {
|
||||
return this.scope === 'global' && !!this.transport;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.local.initialize();
|
||||
if (!this._globalEnabled()) return;
|
||||
const since = Math.floor(this.now() / 1000);
|
||||
// Only future messages: no history flood on connect (local history covers the past).
|
||||
this.transport.subscribe([{ kinds: [this.kind], '#t': [this.channelTag], since }], (ev) => this._onRemote(ev));
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[nostr] chat subscribed: tag=${this.channelTag} kind=${this.kind} publish=${this.signer ? 'on' : 'read-only'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(msg = {}) {
|
||||
// 1) Always deliver + persist locally, exactly as before.
|
||||
await this.local.publish(msg);
|
||||
|
||||
// 2) Fan out global messages to nostr (skip room-scoped and skip messages we received
|
||||
// from the relay, which arrive here only via local delivery, never through publish).
|
||||
if (msg.remote || msg.scope !== 'global' || !this._globalEnabled() || !this.signer) return;
|
||||
try {
|
||||
const template = {
|
||||
kind: this.kind,
|
||||
created_at: Math.floor((msg.ts || this.now()) / 1000),
|
||||
tags: [['t', this.channelTag], ['n', String(msg.username || '').slice(0, 32)]],
|
||||
content: String(msg.text == null ? '' : msg.text)
|
||||
};
|
||||
if (this.serverOrigin) template.tags.push(['origin', String(this.serverOrigin)]);
|
||||
const signed = this.signer.sign(template);
|
||||
if (signed && signed.id) this._markSeen(signed.id); // our own echo will be ignored
|
||||
await this.transport.publish(signed);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] fan-out failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// A message arriving from the relay: deliver locally only (no re-publish → no loop).
|
||||
_onRemote(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
if (typeof event.content !== 'string' || !Array.isArray(event.tags)) return;
|
||||
// Ours (echo) or already seen → drop.
|
||||
if (this.signer && event.pubkey === this.signer.pubkey) return;
|
||||
if (event.id && !this._markSeen(event.id)) return;
|
||||
// Rate-limit remote delivery so a hostile relay can't flood in-game clients.
|
||||
if (!this._allowRemote()) return;
|
||||
|
||||
const nTag = event.tags.find(t => Array.isArray(t) && t[0] === 'n');
|
||||
const rawName = (nTag && nTag[1]) || (typeof event.pubkey === 'string' ? event.pubkey.slice(0, 8) : 'nostr');
|
||||
const username = escapeChatText(rawName, 32) || 'nostr';
|
||||
const text = escapeChatText(event.content, 200);
|
||||
const ts = Number.isFinite(event.created_at) ? event.created_at * 1000 : this.now();
|
||||
|
||||
// remote:true prevents re-fan-out; local.publish delivers to in-game clients + persists.
|
||||
this.local.publish({ scope: 'global', username, text, ts, userId: null, remote: true })
|
||||
.catch(err => console.error('[nostr] local deliver of remote failed:', err.message));
|
||||
}
|
||||
|
||||
_allowRemote() {
|
||||
const cutoff = this.now() - 60000;
|
||||
this._remoteTimes = this._remoteTimes.filter(t => t >= cutoff);
|
||||
if (this._remoteTimes.length >= this.maxRemotePerMin) return false;
|
||||
this._remoteTimes.push(this.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the id is new (and records it); false if already seen. Bounded FIFO.
|
||||
_markSeen(id) {
|
||||
if (this._seen.has(id)) return false;
|
||||
this._seen.add(id);
|
||||
this._seenOrder.push(id);
|
||||
if (this._seenOrder.length > 1000) {
|
||||
const old = this._seenOrder.shift();
|
||||
this._seen.delete(old);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Relay a CLIENT-signed event (per-player identity, Phase 2). Delivers the message in-game
|
||||
// (escaped text, exactly like a normal global message) and publishes the pre-signed event to
|
||||
// the relays WITHOUT re-signing, so it carries the player's OWN npub, not the bridge. The
|
||||
// signed id is marked seen so the relay round-trip doesn't re-deliver it.
|
||||
async relaySignedEvent({ event, username, text, ts, socketId, userId } = {}) {
|
||||
await this.local.publish({ scope: 'global', username, text, ts, socketId, userId });
|
||||
if (this._globalEnabled() && event && event.id) {
|
||||
this._markSeen(event.id);
|
||||
try { await this.transport.publish(event); }
|
||||
catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] signed relay failed:', err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
async getHistory(opts) {
|
||||
return this.local.getHistory(opts);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try { if (this.transport) this.transport.close(); } catch (_) {}
|
||||
await this.local.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NostrChatProvider;
|
||||
@@ -0,0 +1,65 @@
|
||||
const ChatProvider = require('./ChatProvider');
|
||||
|
||||
/**
|
||||
* SocketChatProvider: the default ChatProvider, backed by Socket.IO for delivery and (for
|
||||
* global chat) the Postgres-backed ChatHistoryManager for persistence.
|
||||
*
|
||||
* Behaviour matches the pre-seam chat exactly:
|
||||
* • global scope → persist via the history manager + broadcast to everyone
|
||||
* (broadcastManager.broadcastChatMessage, i.e. io.emit 'chat_broadcast').
|
||||
* • room scope → deliver only to that Socket.IO room, ephemeral (no history).
|
||||
*
|
||||
* A history manager may be injected (so ChatHandler shares its existing instance) or omitted
|
||||
* (tavern chat, which is ephemeral). When omitted, this provider persists nothing.
|
||||
*/
|
||||
class SocketChatProvider extends ChatProvider {
|
||||
constructor({ io, broadcastManager = null, debugManager = null, historyManager = null } = {}) {
|
||||
super();
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.history = historyManager; // null → ephemeral (no persistence)
|
||||
this._ownsHistory = false; // true only if this provider created the manager
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this._ownsHistory && this.history) await this.history.initialize();
|
||||
}
|
||||
|
||||
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).
|
||||
this.history.saveMessage({ socketId, username, message: text, type: 'chat', userId })
|
||||
.catch(err => console.error('Failed to save chat message:', err.message));
|
||||
}
|
||||
if (this.broadcastManager) {
|
||||
this.broadcastManager.broadcastChatMessage(username, text, ts, publicId);
|
||||
} else {
|
||||
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, publicId, scope });
|
||||
}
|
||||
|
||||
async getHistory({ scope = 'global', limit = 50 } = {}) {
|
||||
if (scope === 'global' && this.history) return this.history.getRecentMessages(limit);
|
||||
return [];
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this._ownsHistory && this.history) this.history.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SocketChatProvider;
|
||||
@@ -0,0 +1,46 @@
|
||||
const NostrChatProvider = require('./NostrChatProvider');
|
||||
const NostrTransport = require('./nostr/NostrTransport');
|
||||
const { createBridgeSigner } = require('./nostr/bridgeSigner');
|
||||
|
||||
function parseBool(v) {
|
||||
if (v == null) return false;
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(v).trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat provider for the given local (in-game) provider. Single opt-in seam for Pillar 5.
|
||||
*
|
||||
* Behavior-preserving: with NOSTR_CHAT_ENABLED unset (today) the plain local provider is returned
|
||||
* unchanged. Enable to layer global cross-server chat over nostr:
|
||||
* NOSTR_CHAT_ENABLED=true
|
||||
* NOSTR_CHAT_SCOPE=global|local (local == plain local chat; global fans out over relays)
|
||||
* NOSTR_RELAYS=wss://relay.smirk.cash[,...]
|
||||
* NOSTR_CHAT_TAG=wowngeon-global (the shared channel topic)
|
||||
* NOSTR_CHAT_KIND=1
|
||||
* NOSTR_BRIDGE_SK=<nsec|hex> (server bridge key; must be a registered Smirk npub to publish.
|
||||
* Omit → receive-only global chat.)
|
||||
*/
|
||||
function buildChatProvider({ local, env = process.env, debugManager = null } = {}) {
|
||||
if (!local) throw new Error('buildChatProvider requires a local provider');
|
||||
if (!parseBool(env.NOSTR_CHAT_ENABLED)) return local;
|
||||
|
||||
const scope = (env.NOSTR_CHAT_SCOPE || 'global').toLowerCase();
|
||||
if (scope !== 'global') return local; // local-only is exactly the plain provider
|
||||
|
||||
const relays = (env.NOSTR_RELAYS || 'wss://relay.smirk.cash').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const transport = new NostrTransport({ relays, debugManager });
|
||||
const signer = createBridgeSigner(env.NOSTR_BRIDGE_SK);
|
||||
|
||||
return new NostrChatProvider({
|
||||
local,
|
||||
transport,
|
||||
signer,
|
||||
scope: 'global',
|
||||
channelTag: env.NOSTR_CHAT_TAG || 'wowngeon-global',
|
||||
kind: parseInt(env.NOSTR_CHAT_KIND || '1', 10) || 1,
|
||||
serverOrigin: env.NOSTR_SERVER_ORIGIN || env.SERVER_ID || null,
|
||||
debugManager
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { buildChatProvider, NostrChatProvider, NostrTransport };
|
||||
@@ -0,0 +1,65 @@
|
||||
const { loadNostrTools } = require('../../../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* Thin transport over nostr-tools SimplePool: connect to relays, publish signed events, subscribe
|
||||
* to filters. Isolated behind this class so NostrChatProvider can be unit-tested with a fake
|
||||
* transport (no network). Degrades to a warn-and-noop if nostr-tools/ws can't load, so enabling
|
||||
* chat can never crash the server.
|
||||
*
|
||||
* Reading (REQ/subscribe) is open on relay.smirk.cash; only publishing is policy-gated.
|
||||
*/
|
||||
class NostrTransport {
|
||||
constructor({ relays = ['wss://relay.smirk.cash'], debugManager = null } = {}) {
|
||||
this.relays = Array.isArray(relays) ? relays.filter(Boolean) : [];
|
||||
this.debugManager = debugManager;
|
||||
this.pool = null;
|
||||
this._sub = null;
|
||||
this._ok = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.pool || !this.relays.length) return this._ok;
|
||||
try {
|
||||
const tools = loadNostrTools();
|
||||
if (typeof tools.useWebSocketImplementation === 'function') {
|
||||
try { tools.useWebSocketImplementation(require('ws')); } catch (_) { /* browser/global WS */ }
|
||||
}
|
||||
this.pool = new tools.SimplePool();
|
||||
this._ok = true;
|
||||
} catch (err) {
|
||||
console.error('[nostr] transport disabled (nostr-tools/ws unavailable):', err.message);
|
||||
this._ok = false;
|
||||
}
|
||||
return this._ok;
|
||||
}
|
||||
|
||||
async publish(signedEvent) {
|
||||
if (!this.connect()) return;
|
||||
try {
|
||||
await Promise.any(this.pool.publish(this.relays, signedEvent));
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] publish failed:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(filters, onEvent) {
|
||||
if (!this.connect()) return;
|
||||
try {
|
||||
this._sub = this.pool.subscribeMany(this.relays, filters, {
|
||||
onevent: (ev) => { try { onEvent(ev); } catch (e) { console.error('[nostr] onEvent error:', e.message); } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[nostr] subscribe failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
try { if (this._sub && typeof this._sub.close === 'function') this._sub.close(); } catch (_) {}
|
||||
try { if (this.pool && typeof this.pool.close === 'function') this.pool.close(this.relays); } catch (_) {}
|
||||
this._sub = null;
|
||||
this.pool = null;
|
||||
this._ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NostrTransport;
|
||||
@@ -0,0 +1,61 @@
|
||||
const { loadNostrTools } = require('../../../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* A signer produces signed nostr events for outbound chat. Contract:
|
||||
* { pubkey: <64-hex x-only>, sign(template) -> signedEvent } (template: {kind, created_at, tags, content})
|
||||
*
|
||||
* createBridgeSigner builds a SERVER bridge signer from a secret key (hex or nsec). This is the
|
||||
* drop-in model: the game server signs cross-server chat with one key. For global chat to be
|
||||
* ACCEPTED by relay.smirk.cash (default `inbox-outbox` policy → only registered npubs may publish),
|
||||
* this bridge pubkey must be registered as a Smirk npub (an ops step, like the BTCPay API key).
|
||||
* A future upgrade signs per-message with each player's OWN Smirk npub (window.smirk.signNostrEvent)
|
||||
* so chat identity == wallet identity cryptographically; the provider stays signer-agnostic, so
|
||||
* that path only swaps the signer.
|
||||
*
|
||||
* Returns null when no key is configured → outbound nostr disabled (the provider still receives).
|
||||
*/
|
||||
function createBridgeSigner(secret) {
|
||||
if (!secret) return null;
|
||||
let tools, sk, pubkey;
|
||||
try {
|
||||
tools = loadNostrTools();
|
||||
sk = normalizeSecret(secret, tools);
|
||||
pubkey = tools.getPublicKey(sk);
|
||||
} catch (err) {
|
||||
console.error('[nostr] bridge signer disabled, bad NOSTR_BRIDGE_SK:', err.message);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pubkey,
|
||||
sign(template) {
|
||||
// finalizeEvent fills pubkey/id/sig and stamps created_at if absent.
|
||||
return tools.finalizeEvent(
|
||||
{
|
||||
kind: template.kind,
|
||||
created_at: template.created_at || Math.floor(Date.now() / 1000),
|
||||
tags: template.tags || [],
|
||||
content: template.content || ''
|
||||
},
|
||||
sk
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Accept an nsec bech32 or a 64-hex string; return the Uint8Array secret nostr-tools expects.
|
||||
function normalizeSecret(secret, tools) {
|
||||
const s = String(secret).trim();
|
||||
if (s.startsWith('nsec1')) {
|
||||
const dec = tools.nip19.decode(s);
|
||||
if (dec.type !== 'nsec') throw new Error('not an nsec');
|
||||
return dec.data; // Uint8Array
|
||||
}
|
||||
if (/^[0-9a-fA-F]{64}$/.test(s)) {
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = parseInt(s.substr(i * 2, 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('expected nsec or 64-hex secret key');
|
||||
}
|
||||
|
||||
module.exports = { createBridgeSigner };
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Chat Handler Module
|
||||
* Handles chat messages, commands, and chat-related rate limiting
|
||||
*/
|
||||
|
||||
const ChatHistoryManager = require('./chatHistoryManager');
|
||||
const SocketChatProvider = require('./chat/SocketChatProvider');
|
||||
const { buildChatProvider } = require('./chat');
|
||||
const { clientIp, stableId } = require('./rateLimitContext');
|
||||
const { verifyChatEvent } = require('../utils/verifyChatEvent');
|
||||
const { escapeChatText } = require('../utils/escapeChat');
|
||||
|
||||
class ChatHandler {
|
||||
constructor({ io, broadcastManager, debugManager, addressManager, paymentHandlers, queueManager, gameModeManager, rateLimiter, db }) {
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.addressManager = addressManager;
|
||||
this.paymentHandlers = paymentHandlers;
|
||||
this.queueManager = queueManager;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.rateLimiter = rateLimiter;
|
||||
|
||||
// Initialize chat history manager for persistent message storage
|
||||
this.chatHistory = new ChatHistoryManager({
|
||||
db: db || (gameModeManager?.db),
|
||||
debugManager: debugManager,
|
||||
maxHistoryMessages: 50
|
||||
});
|
||||
|
||||
// Chat delivery + history go through a ChatProvider seam so the backend is swappable.
|
||||
// The local provider shares the ChatHistoryManager instance above, so ban checks and stats
|
||||
// that read this.chatHistory keep working and lifecycle stays owned here. buildChatProvider
|
||||
// wraps it with a NostrChatProvider for global cross-server chat when NOSTR_CHAT_ENABLED is
|
||||
// set; otherwise it returns this local provider unchanged.
|
||||
const localChatProvider = new SocketChatProvider({
|
||||
io: this.io,
|
||||
broadcastManager: this.broadcastManager,
|
||||
debugManager: this.debugManager,
|
||||
historyManager: this.chatHistory
|
||||
});
|
||||
this.chatProvider = buildChatProvider({ local: localChatProvider, debugManager: this.debugManager });
|
||||
|
||||
// Memory leak prevention - cleanup old timestamps
|
||||
this._chatLastSent = new Map();
|
||||
this.chatCleanupInterval = setInterval(() => this.cleanupChatTimestamps(), 300000); // 5 minutes
|
||||
|
||||
this._awaitingAddressFor = new Set(); // Track users waiting for address confirmation
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the chat handler (must be called after construction)
|
||||
*/
|
||||
async initialize() {
|
||||
await this.chatHistory.initialize();
|
||||
// Lets a NostrChatProvider connect + subscribe to the relay. No-op for the plain local
|
||||
// provider (history is already initialized above and injected, not owned by the provider).
|
||||
if (this.chatProvider && typeof this.chatProvider.initialize === 'function') {
|
||||
await this.chatProvider.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send chat history to a newly connected user
|
||||
* @param {string} socketId - Socket ID of the new user
|
||||
* @param {number} messageCount - Number of messages to send (default 50)
|
||||
*/
|
||||
async sendChatHistoryToUser(socketId, messageCount = 50) {
|
||||
try {
|
||||
const messages = await this.chatProvider.getHistory({ scope: 'global', limit: messageCount });
|
||||
if (messages.length > 0) {
|
||||
this.io.to(socketId).emit('chat_history', { messages });
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📜 Sent ${messages.length} chat history messages to ${socketId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send chat history:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming chat messages with rate limiting
|
||||
*/
|
||||
async handleChatMessage(socket, msg, additionalHandlers = {}) {
|
||||
try {
|
||||
// Rate limiting keys on stable id + IP so reconnecting can't reset the counter.
|
||||
const rlId = stableId(socket, this.gameModeManager?.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Please slow down! You can send ${rateLimitResult.remaining} more messages after ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log('Message received:', msg);
|
||||
}
|
||||
|
||||
const command = msg.toLowerCase();
|
||||
|
||||
// A pasted XMR/WOW payout address takes priority over command and chat handling.
|
||||
const detected = this.addressManager.detectInText(msg);
|
||||
if (detected) {
|
||||
await this._handleAddressDetection(socket, detected);
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = await this._handleGameCommand(socket, command, additionalHandlers);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a command, broadcast as chat message
|
||||
await this._handleChatBroadcast(socket, msg);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat handler error:', error);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Message processing failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a client-signed global chat event. The client signs with the player's own Smirk npub
|
||||
* (window.smirk.signNostrEvent) and sends the finished event; the server verifies it is
|
||||
* authentic and signed by this session's authenticated npub (users.smirk_public_key from NIP-98
|
||||
* login), moderates, then relays it: the provider publishes the pre-signed event to nostr under
|
||||
* the player's npub, not the bridge. Serves lobby and tavern, both of which render global scope.
|
||||
*/
|
||||
async handleSignedChatMessage(socket, payload) {
|
||||
try {
|
||||
const rlId = stableId(socket, this.gameModeManager?.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rl = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
|
||||
if (!rl.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Please slow down before your next message.');
|
||||
return;
|
||||
}
|
||||
await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
|
||||
|
||||
const event = payload && payload.event;
|
||||
if (!event || typeof event !== 'object') return;
|
||||
|
||||
// The session's authenticated npub is the ONLY key this player may post under.
|
||||
const db = this.gameModeManager?.db;
|
||||
const session = this.gameModeManager?.sessionManager?.sessions?.get(socket.id);
|
||||
const userId = session ? session.id : null;
|
||||
if (!db || !userId) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.');
|
||||
return;
|
||||
}
|
||||
const row = (await db.query('SELECT smirk_public_key, display_name, username FROM users WHERE id = $1', [userId])).rows[0];
|
||||
const npub = row && row.smirk_public_key;
|
||||
if (!npub) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdict = verifyChatEvent(event, {
|
||||
expectedPubkey: npub,
|
||||
channelTag: process.env.NOSTR_CHAT_TAG || 'wowngeon-global',
|
||||
maxLen: 280
|
||||
});
|
||||
if (!verdict.ok) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.warn('[chat] signed event rejected:', verdict.reason);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Your signed message could not be verified.');
|
||||
return;
|
||||
}
|
||||
|
||||
const username = row.display_name || row.username || String(npub).slice(0, 8);
|
||||
const text = escapeChatText(verdict.content, 200);
|
||||
|
||||
// Delivers in-game (escaped) + relays the client-signed event to nostr under the player's npub.
|
||||
await this.chatProvider.relaySignedEvent({
|
||||
event, scope: 'global', username, text, ts: Date.now(), socketId: socket.id, userId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Signed chat handler error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat statistics
|
||||
*/
|
||||
async getStats() {
|
||||
const historyStats = await this.chatHistory.getStats();
|
||||
return {
|
||||
chatTimestamps: this._chatLastSent.size,
|
||||
awaitingAddress: this._awaitingAddressFor.size,
|
||||
chatHistory: historyStats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown handler - cleanup resources
|
||||
*/
|
||||
shutdown() {
|
||||
if (this.chatCleanupInterval) {
|
||||
clearInterval(this.chatCleanupInterval);
|
||||
this.chatCleanupInterval = null;
|
||||
}
|
||||
if (this.chatHistory) {
|
||||
this.chatHistory.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear address confirmation state for a user
|
||||
*/
|
||||
clearAddressConfirmation(socketId) {
|
||||
this._awaitingAddressFor.delete(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is awaiting address confirmation
|
||||
*/
|
||||
isAwaitingAddressConfirmation(socketId) {
|
||||
return this._awaitingAddressFor.has(socketId);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
async promptAddress(socket) {
|
||||
await this._promptAddress(socket);
|
||||
}
|
||||
|
||||
async _handleAddressDetection(socket, detected) {
|
||||
// Rate limiting keys on stable id + IP.
|
||||
const rlId = stableId(socket, this.gameModeManager?.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'address:set', rlIp);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Address changes are rate limited. Try again in ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(rlId, 'address:set', rlIp);
|
||||
this.addressManager.handleDetection(socket.id, detected);
|
||||
}
|
||||
|
||||
async _handleGameCommand(socket, command, additionalHandlers) {
|
||||
switch (command) {
|
||||
case 'hello':
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'help',
|
||||
'Welcome! Type "enter" to join the queue for the next block, or use the START button for immediate entry. ' +
|
||||
'Paste your XMR/WOW address in chat to set your payout address.');
|
||||
return true;
|
||||
|
||||
case 'enter':
|
||||
if (additionalHandlers.handleGameQueue) {
|
||||
await additionalHandlers.handleGameQueue(socket);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'cancel':
|
||||
if (this.addressManager.pending.has(socket.id)) {
|
||||
this.addressManager.confirm(socket.id, false);
|
||||
return true;
|
||||
}
|
||||
if (additionalHandlers.handleCancelEntry) {
|
||||
await additionalHandlers.handleCancelEntry(socket);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'confirm':
|
||||
if (this.addressManager.pending.has(socket.id)) {
|
||||
this.addressManager.confirm(socket.id, true);
|
||||
return true;
|
||||
}
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Nothing pending confirmation.');
|
||||
return true;
|
||||
|
||||
case 'address':
|
||||
case 'payout':
|
||||
await this._promptAddress(socket);
|
||||
return true;
|
||||
|
||||
case 'payment':
|
||||
case 'pay':
|
||||
if (this.gameModeManager) {
|
||||
await this.paymentHandlers.createAndShowPaymentRequest(socket);
|
||||
} else {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Server is in FREE mode - no payment required.');
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'stats':
|
||||
if (additionalHandlers.handleStatsRequest) {
|
||||
await additionalHandlers.handleStatsRequest(socket);
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
// command is already lowercased by the caller, so the prefix match is case-insensitive.
|
||||
if (command.startsWith('/nick ') || command === '/nick') {
|
||||
await this._handleNickCommand(socket, command);
|
||||
return true;
|
||||
}
|
||||
return false; // Command not handled
|
||||
}
|
||||
}
|
||||
|
||||
async _handleNickCommand(socket, command) {
|
||||
const name = command.slice(6).trim();
|
||||
if (!name) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
|
||||
'Usage: /nick YourName (max 20 chars, letters/numbers/underscores)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: 1-20 chars, alphanumeric + underscores + spaces
|
||||
if (!/^[a-zA-Z0-9_ ]{1,20}$/.test(name)) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
'Invalid name. Use letters, numbers, underscores, or spaces (max 20 chars).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.gameModeManager?.db) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Display names not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.gameModeManager.db.query(
|
||||
'UPDATE users SET display_name = $1 WHERE socket_id = $2',
|
||||
[name.trim(), socket.id]
|
||||
);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
|
||||
'Display name set to: ' + name.trim());
|
||||
} catch (err) {
|
||||
console.error('Failed to set display name:', err.message);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to set display name.');
|
||||
}
|
||||
}
|
||||
|
||||
async _handleChatBroadcast(socket, msg) {
|
||||
if (typeof msg !== 'string') return;
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Message length limit (reject, don't truncate)
|
||||
const MAX_MESSAGE_LENGTH = 200;
|
||||
if (trimmed.length > MAX_MESSAGE_LENGTH) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Message too long (${trimmed.length}/${MAX_MESSAGE_LENGTH} characters). Please shorten it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user info for ban check and user_id
|
||||
let userId = null;
|
||||
if (this.gameModeManager) {
|
||||
try {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
|
||||
userId = userRow?.id || null;
|
||||
|
||||
if (userId && await this.chatHistory.isUserChatBanned(userId)) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'You have been banned from chat.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue without user lookup - allow message
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('User lookup for chat failed:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional rate limit for broadcast messages (more restrictive)
|
||||
const now = Date.now();
|
||||
const last = this._chatLastSent.get(socket.id) || 0;
|
||||
const BROADCAST_COOLDOWN = 2000; // 2 seconds between broadcasts
|
||||
|
||||
if (now - last < BROADCAST_COOLDOWN) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Please wait ${Math.ceil((BROADCAST_COOLDOWN - (now - last)) / 1000)} seconds before sending another message.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._chatLastSent.set(socket.id, now);
|
||||
|
||||
// Complete HTML entity escaping to prevent XSS
|
||||
// Note: length already validated above, slice is just a safety net
|
||||
const safe = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.slice(0, MAX_MESSAGE_LENGTH);
|
||||
const username = socket.id.substring(0, 6);
|
||||
|
||||
// The provider broadcasts and saves to history (fire-and-forget).
|
||||
await this.chatProvider.publish({
|
||||
scope: 'global',
|
||||
username: username,
|
||||
text: safe,
|
||||
ts: now,
|
||||
socketId: socket.id,
|
||||
userId: userId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old chat timestamps to prevent memory leaks
|
||||
*/
|
||||
cleanupChatTimestamps() {
|
||||
const now = Date.now();
|
||||
const TIMESTAMP_EXPIRY = 600000; // 10 minutes
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [socketId, timestamp] of this._chatLastSent.entries()) {
|
||||
if (now - timestamp > TIMESTAMP_EXPIRY) {
|
||||
this._chatLastSent.delete(socketId);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING && cleanedCount > 0) {
|
||||
console.log(`🧹 ChatHandler cleanup: removed ${cleanedCount} old timestamps`);
|
||||
}
|
||||
}
|
||||
|
||||
async _promptAddress(socket) {
|
||||
let existing = null;
|
||||
try {
|
||||
if (this.gameModeManager) {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
|
||||
existing = userRow?.payout_address || null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('Address prompt lookup failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit('address_prompt', {
|
||||
existingAddress: existing,
|
||||
canUpdate: true
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Update your payout address using the address manager.');
|
||||
} else {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Add your payout address to receive rewards.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatHandler;
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Chat History Manager
|
||||
* Handles persistent storage and retrieval of chat messages
|
||||
*/
|
||||
|
||||
class ChatHistoryManager {
|
||||
constructor({ db, debugManager, maxHistoryMessages = 50, inMemoryFallbackSize = 100 }) {
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this.maxHistoryMessages = maxHistoryMessages;
|
||||
|
||||
// In-memory fallback for when DB is not available
|
||||
this._inMemoryHistory = [];
|
||||
this._inMemoryFallbackSize = inMemoryFallbackSize;
|
||||
|
||||
// Cache recent messages in memory for fast access
|
||||
this._recentMessagesCache = [];
|
||||
this._cacheSize = maxHistoryMessages;
|
||||
this._cacheInitialized = false;
|
||||
|
||||
// Cleanup old messages periodically (once per day)
|
||||
this._cleanupInterval = null;
|
||||
this._maxMessageAge = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager - load recent messages into cache
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
if (this.db) {
|
||||
// Ensure table exists
|
||||
await this._ensureTable();
|
||||
|
||||
// Load recent messages into cache
|
||||
await this._refreshCache();
|
||||
|
||||
// Start cleanup interval (run once per day)
|
||||
this._cleanupInterval = setInterval(() => {
|
||||
this.cleanupOldMessages().catch(err => {
|
||||
console.error('Chat history cleanup error:', err.message);
|
||||
});
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`💬 ChatHistoryManager initialized with ${this._recentMessagesCache.length} cached messages`);
|
||||
}
|
||||
} else {
|
||||
console.warn('ChatHistoryManager: No database available, using in-memory fallback');
|
||||
}
|
||||
this._cacheInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('ChatHistoryManager initialization error:', error.message);
|
||||
// Fall back to in-memory mode
|
||||
this._cacheInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chat_messages table exists
|
||||
*/
|
||||
async _ensureTable() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
await this.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
socket_id VARCHAR(255),
|
||||
username VARCHAR(50),
|
||||
message TEXT NOT NULL,
|
||||
message_type VARCHAR(20) DEFAULT 'chat',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index if it doesn't exist
|
||||
await this.db.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at
|
||||
ON chat_messages(created_at DESC)
|
||||
`);
|
||||
} catch (error) {
|
||||
// Table might already exist, that's fine
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log('Chat table check:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the in-memory cache from database
|
||||
*/
|
||||
async _refreshCache() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT id, socket_id, username, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, [this._cacheSize]);
|
||||
|
||||
// Reverse to get chronological order (oldest first)
|
||||
this._recentMessagesCache = result.rows.reverse().map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh chat cache:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a chat message
|
||||
* @param {Object} messageData - Message data
|
||||
* @param {string} messageData.socketId - Socket ID of sender
|
||||
* @param {string} messageData.username - Display username (playerId)
|
||||
* @param {string} messageData.message - Message content
|
||||
* @param {string} messageData.type - Message type (chat, system, event)
|
||||
* @param {number} [messageData.userId] - Optional user ID from database
|
||||
* @returns {Object} Saved message with id and timestamp
|
||||
*/
|
||||
async saveMessage({ socketId, username, message, type = 'chat', userId = null }) {
|
||||
const timestamp = Date.now();
|
||||
const playerId = username; // username IS the playerId (6-char display ID)
|
||||
const messageObj = {
|
||||
socketId,
|
||||
username,
|
||||
playerId,
|
||||
message,
|
||||
type,
|
||||
timestamp,
|
||||
userId
|
||||
};
|
||||
|
||||
// Add to in-memory cache immediately
|
||||
this._recentMessagesCache.push(messageObj);
|
||||
if (this._recentMessagesCache.length > this._cacheSize) {
|
||||
this._recentMessagesCache.shift();
|
||||
}
|
||||
|
||||
// Also add to fallback array
|
||||
this._inMemoryHistory.push(messageObj);
|
||||
if (this._inMemoryHistory.length > this._inMemoryFallbackSize) {
|
||||
this._inMemoryHistory.shift();
|
||||
}
|
||||
|
||||
// Persist to database asynchronously (best effort)
|
||||
if (this.db) {
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
INSERT INTO chat_messages (socket_id, username, player_id, user_id, message, message_type, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
RETURNING id
|
||||
`, [socketId, username, playerId, userId, message, type]);
|
||||
|
||||
messageObj.id = result.rows[0]?.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to persist chat message:', error.message);
|
||||
// Message is still in memory, so users will see it
|
||||
}
|
||||
}
|
||||
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent messages for a new user
|
||||
* Uses hybrid strategy: last hour of messages, OR last 10 messages if < 10 in last hour
|
||||
* @param {number} maxCount - Maximum number of messages to retrieve (default: 50)
|
||||
* @param {number} minCount - Minimum messages to show if recent activity is low (default: 10)
|
||||
* @returns {Array} Array of recent messages
|
||||
*/
|
||||
async getRecentMessages(maxCount = 50, minCount = 10) {
|
||||
// Try database first for hybrid strategy
|
||||
if (this.db) {
|
||||
try {
|
||||
// Hybrid query: Get messages from last hour, OR last N messages if fewer than minCount
|
||||
const result = await this.db.query(`
|
||||
WITH recent_hour AS (
|
||||
SELECT id, socket_id, username, player_id, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
WHERE deleted_at IS NULL
|
||||
AND created_at > NOW() - INTERVAL '1 hour'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
),
|
||||
fallback AS (
|
||||
SELECT id, socket_id, username, player_id, message, message_type, created_at
|
||||
FROM chat_messages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
)
|
||||
SELECT * FROM (
|
||||
SELECT * FROM recent_hour
|
||||
UNION
|
||||
SELECT * FROM fallback
|
||||
) combined
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $1
|
||||
`, [maxCount, minCount]);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
playerId: row.player_id || row.username,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get recent messages from DB:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to cache (filter out deleted messages)
|
||||
const limit = Math.min(maxCount, this._cacheSize);
|
||||
|
||||
if (this._cacheInitialized && this._recentMessagesCache.length > 0) {
|
||||
const activeMessages = this._recentMessagesCache.filter(m => !m.deletedAt);
|
||||
return activeMessages.slice(-limit);
|
||||
}
|
||||
|
||||
// Final fallback to in-memory
|
||||
if (this._inMemoryHistory.length > 0) {
|
||||
const activeMessages = this._inMemoryHistory.filter(m => !m.deletedAt);
|
||||
return activeMessages.slice(-limit);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old messages (older than 30 days by default)
|
||||
* @param {number} maxAgeMs - Maximum message age in milliseconds
|
||||
* @returns {number} Number of messages deleted
|
||||
*/
|
||||
async cleanupOldMessages(maxAgeMs = this._maxMessageAge) {
|
||||
if (!this.db) return 0;
|
||||
|
||||
try {
|
||||
const cutoffDate = new Date(Date.now() - maxAgeMs);
|
||||
const result = await this.db.query(`
|
||||
DELETE FROM chat_messages
|
||||
WHERE created_at < $1
|
||||
`, [cutoffDate]);
|
||||
|
||||
const deletedCount = result.rowCount || 0;
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING && deletedCount > 0) {
|
||||
console.log(`🧹 Cleaned up ${deletedCount} old chat messages`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
console.error('Chat cleanup error:', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is banned from chat
|
||||
* @param {number} userId - User ID to check
|
||||
* @returns {boolean} True if user is banned
|
||||
*/
|
||||
async isUserChatBanned(userId) {
|
||||
if (!userId || !this.db) return false;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(
|
||||
'SELECT chat_banned FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0]?.chat_banned === true;
|
||||
} catch (error) {
|
||||
console.error('Failed to check chat ban status:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a message (admin moderation)
|
||||
* @param {number} messageId - Message ID to delete
|
||||
* @param {string} deletedBy - Admin identifier
|
||||
* @param {string} reason - Reason for deletion
|
||||
* @returns {boolean} True if deleted successfully
|
||||
*/
|
||||
async deleteMessage(messageId, deletedBy = 'admin', reason = null) {
|
||||
if (!this.db) return false;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
UPDATE chat_messages
|
||||
SET deleted_at = NOW(), deleted_by = $2, delete_reason = $3
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`, [messageId, deletedBy, reason]);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
// Also remove from in-memory caches
|
||||
this._recentMessagesCache = this._recentMessagesCache.filter(m => m.id !== messageId);
|
||||
this._inMemoryHistory = this._inMemoryHistory.filter(m => m.id !== messageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chat message:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for admin view (includes deleted)
|
||||
* @param {number} limit - Number of messages
|
||||
* @param {boolean} includeDeleted - Whether to include deleted messages
|
||||
* @returns {Array} Messages for admin
|
||||
*/
|
||||
async getMessagesForAdmin(limit = 100, includeDeleted = false) {
|
||||
if (!this.db) return [];
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT
|
||||
c.id, c.socket_id, c.username, c.player_id, c.user_id,
|
||||
c.message, c.message_type, c.created_at,
|
||||
c.deleted_at, c.deleted_by, c.delete_reason,
|
||||
u.payout_address, u.chat_banned
|
||||
FROM chat_messages c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
${includeDeleted ? '' : 'WHERE c.deleted_at IS NULL'}
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $1
|
||||
`, [limit]);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
socketId: row.socket_id,
|
||||
username: row.username,
|
||||
playerId: row.player_id,
|
||||
userId: row.user_id,
|
||||
message: row.message,
|
||||
type: row.message_type,
|
||||
timestamp: new Date(row.created_at).getTime(),
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
deletedBy: row.deleted_by,
|
||||
deleteReason: row.delete_reason,
|
||||
payoutAddress: row.payout_address,
|
||||
chatBanned: row.chat_banned
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get admin messages:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message count for statistics
|
||||
* @returns {Object} Statistics about chat messages
|
||||
*/
|
||||
async getStats() {
|
||||
const stats = {
|
||||
cachedMessages: this._recentMessagesCache.length,
|
||||
inMemoryFallback: this._inMemoryHistory.length,
|
||||
totalInDb: null
|
||||
};
|
||||
|
||||
if (this.db) {
|
||||
try {
|
||||
const result = await this.db.query('SELECT COUNT(*) as count FROM chat_messages');
|
||||
stats.totalInDb = parseInt(result.rows[0]?.count || 0);
|
||||
} catch (error) {
|
||||
// Ignore errors for stats
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown - cleanup intervals
|
||||
*/
|
||||
shutdown() {
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
this._cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatHistoryManager;
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Connection Handler Module
|
||||
* Handles socket connection, registration, and session management
|
||||
*/
|
||||
|
||||
const user = require('../db/user');
|
||||
const { clientIp } = require('./rateLimitContext');
|
||||
const { gameNameFor } = require('../game/helpers/gameModeUtils');
|
||||
|
||||
class ConnectionHandler {
|
||||
constructor({ io, broadcastManager, debugManager, sessionManager, rateLimiter }) {
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.sessionManager = sessionManager;
|
||||
this.rateLimiter = rateLimiter;
|
||||
|
||||
this.clientSocketMap = new Map();
|
||||
|
||||
// Per-IP concurrent-socket cap. The rate limiter caps the RATE of new connections and
|
||||
// game starts, but not how many sockets one IP HOLDS OPEN; a client can trickle in
|
||||
// under the rate limit and keep hundreds alive to hoard resources and spectator slots.
|
||||
// The default of 10 is generous for shared NATs and multi-tab use.
|
||||
this.maxSocketsPerIp = parseInt(process.env.MAX_SOCKETS_PER_IP, 10) || 10;
|
||||
this.socketsByIp = new Map(); // ip -> Set<socketId>
|
||||
this.socketIpMap = new Map(); // socketId -> ip, for O(1) disconnect cleanup
|
||||
|
||||
this.mapCleanupInterval = setInterval(() => this.cleanupMappings(), 300000); // 5 minutes
|
||||
}
|
||||
|
||||
/** Register a live socket for its IP. @returns {boolean} false if the IP is at the cap. */
|
||||
_trackIpSocket(ip, socketId) {
|
||||
if (!ip) return true; // unattributable connections are not blocked
|
||||
let set = this.socketsByIp.get(ip);
|
||||
if (set && set.size >= this.maxSocketsPerIp && !set.has(socketId)) {
|
||||
return false;
|
||||
}
|
||||
if (!set) { set = new Set(); this.socketsByIp.set(ip, set); }
|
||||
set.add(socketId);
|
||||
this.socketIpMap.set(socketId, ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drop a socket from its IP's live set. */
|
||||
_untrackIpSocket(socketId) {
|
||||
const ip = this.socketIpMap.get(socketId);
|
||||
if (!ip) return;
|
||||
const set = this.socketsByIp.get(ip);
|
||||
if (set) {
|
||||
set.delete(socketId);
|
||||
if (set.size === 0) this.socketsByIp.delete(ip);
|
||||
}
|
||||
this.socketIpMap.delete(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new socket connection with rate limiting and session management
|
||||
*/
|
||||
async handleConnection(socket) {
|
||||
try {
|
||||
// The real client IP (honouring a trusted reverse proxy via TRUST_PROXY) so per-IP
|
||||
// connection limits stay correct behind a proxy.
|
||||
const ip = clientIp(socket) || socket.handshake.address;
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(ip, 'connection:new', ip);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🚫 Connection rate limited for IP ${ip}. Retry after: ${rateLimitResult.retryAfter}ms`);
|
||||
}
|
||||
socket.emit('rate_limited', {
|
||||
action: 'connection',
|
||||
retryAfter: rateLimitResult.retryAfter,
|
||||
message: 'Too many connection attempts. Please wait before trying again.'
|
||||
});
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(ip, 'connection:new', ip);
|
||||
|
||||
if (!this._trackIpSocket(ip, socket.id)) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🚫 Concurrent socket cap (${this.maxSocketsPerIp}) reached for IP ${ip}`);
|
||||
}
|
||||
socket.emit('rate_limited', {
|
||||
action: 'connection',
|
||||
message: 'Too many simultaneous connections from your network.'
|
||||
});
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log('A user connected');
|
||||
console.log(socket.client.id);
|
||||
console.log(socket.handshake.address);
|
||||
}
|
||||
|
||||
// The DB is authoritative for the session; the in-memory user is ephemeral state
|
||||
// for game logic that keys off socket.id.
|
||||
const resumeToken = this._getResumeToken(socket);
|
||||
const sessionInfo = await this._initializeSession(socket, resumeToken);
|
||||
|
||||
new user.User(socket.id, socket.handshake.address);
|
||||
const memUser = this._setupMemoryUser(socket);
|
||||
|
||||
this._emitSessionEvents(socket, sessionInfo);
|
||||
|
||||
this._sendConnectionStatus(socket);
|
||||
|
||||
// Delayed so the socket is registered with the IO server before it is counted.
|
||||
setTimeout(() => this.broadcastManager.broadcastUserCount(), 100);
|
||||
|
||||
return { sessionInfo, memUser };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection handler error:', error);
|
||||
socket.emit('connection_error', { message: 'Failed to establish connection' });
|
||||
socket.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client registration with cleanup
|
||||
*/
|
||||
handleRegisterClient(socket, data) {
|
||||
// The client-supplied id becomes both a key and a value in clientSocketMap, and
|
||||
// getUserBySocket follows that mapping, so an oversized or colliding id would poison
|
||||
// the map. Validate before storing.
|
||||
const clientId = data && data.clientId;
|
||||
if (typeof clientId !== 'string' || clientId.length === 0 || clientId.length > 64 || !/^[A-Za-z0-9_-]+$/.test(clientId)) {
|
||||
socket.emit('socket_registered', { serverId: socket.id, success: false, error: 'Invalid clientId' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`Client registered: ${socket.id} (server) <-> ${clientId} (client)`);
|
||||
}
|
||||
|
||||
this._cleanupExistingMappings(socket.id, clientId);
|
||||
|
||||
this.clientSocketMap.set(clientId, socket.id);
|
||||
this.clientSocketMap.set(socket.id, clientId);
|
||||
|
||||
socket.emit('socket_registered', {
|
||||
clientId: clientId,
|
||||
serverId: socket.id,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnection with proper cleanup
|
||||
*/
|
||||
handleDisconnect(socket, additionalCleanup = null) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log('User disconnected', socket.client.id);
|
||||
}
|
||||
|
||||
this._untrackIpSocket(socket.id);
|
||||
|
||||
const clientId = this.clientSocketMap.get(socket.id);
|
||||
if (clientId) {
|
||||
this.clientSocketMap.delete(clientId);
|
||||
}
|
||||
this.clientSocketMap.delete(socket.id);
|
||||
|
||||
// The user registry is keyed by socket.id (see `new user.User(socket.id, ...)` in
|
||||
// handleConnection). socket.client.id is a DIFFERENT value under the Engine.IO 4
|
||||
// protocol, so deleting by it alone removes nothing and leaks one User per connection,
|
||||
// each able to pin a whole Game instance through `currentGame`. Remove both keys:
|
||||
// socket.id is the real one, client.id covers any entry stored under the older key.
|
||||
user.removeUser(socket.id);
|
||||
if (socket.client?.id && socket.client.id !== socket.id) user.removeUser(socket.client.id);
|
||||
|
||||
this.broadcastManager.broadcastUserCount();
|
||||
|
||||
if (additionalCleanup && typeof additionalCleanup === 'function') {
|
||||
additionalCleanup(socket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by socket ID with fallback mapping
|
||||
*/
|
||||
getUserBySocket(socketId) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`Looking up user with socketId: ${socketId}`);
|
||||
}
|
||||
|
||||
let foundUser = user.getUserBySocketId(socketId);
|
||||
|
||||
if (!foundUser && this.clientSocketMap.has(socketId)) {
|
||||
const mappedId = this.clientSocketMap.get(socketId);
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`Socket ID ${socketId} not found directly, trying mapped ID: ${mappedId}`);
|
||||
}
|
||||
foundUser = user.getUserBySocketId(mappedId);
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`User lookup result for ${socketId}: ${foundUser ? "FOUND" : "NOT FOUND"}`);
|
||||
}
|
||||
return foundUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
clientSocketMappings: this.clientSocketMap.size / 2, // Halved: mappings are stored in both directions
|
||||
rateLimiterStats: this.rateLimiter.getStats()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Release resources held by this handler.
|
||||
*/
|
||||
shutdown() {
|
||||
if (this.mapCleanupInterval) {
|
||||
clearInterval(this.mapCleanupInterval);
|
||||
this.mapCleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
_getResumeToken(socket) {
|
||||
try {
|
||||
// Session credentials belong only in Socket.IO's handshake auth payload. Query
|
||||
// strings are routinely retained by reverse-proxy/access logs and must never resume
|
||||
// an account, even when an older client still sends one there.
|
||||
return socket.handshake.auth?.resumeToken || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _initializeSession(socket, resumeToken) {
|
||||
if (!this.sessionManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.sessionManager.resumeOrCreate({
|
||||
socketId: socket.id,
|
||||
ipAddress: socket.handshake.address,
|
||||
resumeToken
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Session initialization failed:', e.message);
|
||||
// A compare-and-swap loser presented a credential another concurrent connection
|
||||
// already consumed. Do not downgrade it into an anonymous live socket: the outer
|
||||
// connection handler will emit a generic error and disconnect it.
|
||||
if (e.code === 'SESSION_TOKEN_REPLAY') throw e;
|
||||
// A database outage still permits a connection, just without session features.
|
||||
if (e.message.includes('Database not connected')) {
|
||||
console.log('⚠️ Database not ready, allowing connection without session features');
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_setupMemoryUser(socket) {
|
||||
const memUser = user.getUserBySocketId(socket.id);
|
||||
if (memUser) {
|
||||
memUser.clientId = socket.client.id;
|
||||
}
|
||||
return memUser;
|
||||
}
|
||||
|
||||
_emitSessionEvents(socket, sessionInfo) {
|
||||
if (sessionInfo) {
|
||||
if (sessionInfo.resumed) {
|
||||
this.io.to(socket.id).emit('session_resumed', {
|
||||
token: sessionInfo.token,
|
||||
payoutAddress: sessionInfo.user.payout_address || null,
|
||||
credits: sessionInfo.user.credits || 0,
|
||||
totalCreditsPurchased: sessionInfo.user.total_credits_purchased || 0
|
||||
});
|
||||
} else {
|
||||
this.io.to(socket.id).emit('session_token', { token: sessionInfo.token });
|
||||
|
||||
// Only new sessions need address_confirmed; resumed ones carry payoutAddress
|
||||
// in session_resumed.
|
||||
if (sessionInfo.user.payout_address) {
|
||||
this.io.to(socket.id).emit('address_confirmed', {
|
||||
address: sessionInfo.user.payout_address,
|
||||
message: 'Payout address restored.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// creditsPerGame lets the client compute games remaining without a round trip.
|
||||
this.io.to(socket.id).emit('credits_update', {
|
||||
balance: sessionInfo.user.credits || 0,
|
||||
totalCreditsPurchased: sessionInfo.user.total_credits_purchased || 0,
|
||||
creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1
|
||||
});
|
||||
|
||||
if (sessionInfo.recovered && sessionInfo.recovered.creditsRecovered > 0) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'success',
|
||||
`💰 Payment recovered! ${sessionInfo.recovered.creditsRecovered} credits have been added to your balance.`);
|
||||
this.io.to(socket.id).emit('credits_recovered', {
|
||||
creditsRecovered: sessionInfo.recovered.creditsRecovered,
|
||||
paymentsProcessed: sessionInfo.recovered.paymentsProcessed,
|
||||
newBalance: sessionInfo.user.credits || 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Without a session, clients still expect a welcome keyed by client.id.
|
||||
this.io.to(socket.client.id).emit('welcome', socket.client.id);
|
||||
}
|
||||
}
|
||||
|
||||
_sendConnectionStatus(socket) {
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight();
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`📈 Sending current block height ${currentBlock} to new connection ${socket.id}`);
|
||||
}
|
||||
// Include the cosmetic chain tip so a client that connects mid-block still gets the ambient
|
||||
// chain readout instead of waiting up to a full block interval for the next broadcast.
|
||||
const tip = this.debugManager.getChainTip ? this.debugManager.getChainTip() : null;
|
||||
this.io.to(socket.id).emit(
|
||||
'blockheight',
|
||||
this.broadcastManager.blockHeightPayload
|
||||
? this.broadcastManager.blockHeightPayload(currentBlock, tip)
|
||||
: { blockHeight: currentBlock }
|
||||
);
|
||||
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'connection', `Connected to ${gameNameFor(process.env.CRYPTO_TYPE)} server`);
|
||||
}
|
||||
|
||||
_cleanupExistingMappings(socketId, clientId) {
|
||||
const existingSocketForClient = this.clientSocketMap.get(clientId);
|
||||
const existingClientForSocket = this.clientSocketMap.get(socketId);
|
||||
|
||||
if (existingSocketForClient && existingSocketForClient !== socketId) {
|
||||
this.clientSocketMap.delete(existingSocketForClient);
|
||||
}
|
||||
if (existingClientForSocket && existingClientForSocket !== clientId) {
|
||||
this.clientSocketMap.delete(existingClientForSocket);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic cleanup of stale mappings to prevent memory leaks
|
||||
*/
|
||||
cleanupMappings() {
|
||||
const activeSocketIds = new Set();
|
||||
|
||||
if (this.io && this.io.sockets && this.io.sockets.sockets) {
|
||||
for (const socketId of this.io.sockets.sockets.keys()) {
|
||||
activeSocketIds.add(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
let cleanedCount = 0;
|
||||
const toDelete = [];
|
||||
|
||||
// Length distinguishes a socket ID from a client-supplied id closely enough to pick
|
||||
// the socket-keyed direction; the paired reverse mapping goes with it.
|
||||
for (const [key, value] of this.clientSocketMap.entries()) {
|
||||
if (key.length > 10 && !activeSocketIds.has(key)) {
|
||||
toDelete.push(key);
|
||||
toDelete.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of toDelete) {
|
||||
if (this.clientSocketMap.delete(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile per-IP tracking against live sockets: a missed disconnect would leave a
|
||||
// leaked entry that wrongly counts toward the cap for that IP.
|
||||
for (const [socketId, ip] of this.socketIpMap.entries()) {
|
||||
if (!activeSocketIds.has(socketId)) {
|
||||
const set = this.socketsByIp.get(ip);
|
||||
if (set) { set.delete(socketId); if (set.size === 0) this.socketsByIp.delete(ip); }
|
||||
this.socketIpMap.delete(socketId);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING && cleanedCount > 0) {
|
||||
console.log(`🧹 ConnectionHandler cleanup: removed ${cleanedCount} stale mappings`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConnectionHandler;
|
||||
@@ -0,0 +1,160 @@
|
||||
const Appearance = require('../multiplayer/appearance');
|
||||
const Entitlements = require('../multiplayer/entitlements');
|
||||
const CatalogService = require('../services/catalogService');
|
||||
const SubscriptionService = require('../services/subscriptionService');
|
||||
|
||||
function parseAppearance(value) {
|
||||
if (!value) return Appearance.normalizeAppearance('default');
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return Appearance.normalizeAppearance(JSON.parse(value));
|
||||
} catch (_) {
|
||||
return Appearance.normalizeAppearance('default');
|
||||
}
|
||||
}
|
||||
return Appearance.normalizeAppearance(value);
|
||||
}
|
||||
|
||||
class IdentityService {
|
||||
constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null, catalogService = null, subscriptionService = null } = {}) {
|
||||
this.db = db || gameModeManager?.db || null;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.sessionManager = sessionManager;
|
||||
this.debugManager = debugManager;
|
||||
// Operator-owned cosmetic catalog (DB-backed, cached). Falls back to the built-in default
|
||||
// when the table is absent, so this never breaks a fresh/partly-migrated DB.
|
||||
this.catalogService = catalogService || (this.db ? new CatalogService({ db: this.db }) : null);
|
||||
// Resolves a premium-subscription tier by npub so a sub unlocks cosmetics. No source
|
||||
// configured (PREMIUM_NPUBS / SMIRK_PREMIUM_STATUS_URL) => inert (returns null).
|
||||
this.subscriptionService = subscriptionService || new SubscriptionService();
|
||||
}
|
||||
|
||||
async _catalog() {
|
||||
if (!this.catalogService) return undefined; // snapshotForUser falls back to DEFAULT_CATALOG
|
||||
try {
|
||||
return await this.catalogService.getCatalog();
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('[IdentityService] catalog load failed; using default:', err.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async userForSocket(socket) {
|
||||
if (!socket) return null;
|
||||
if (this.gameModeManager && typeof this.gameModeManager.getOrCreateUser === 'function') {
|
||||
return this.gameModeManager.getOrCreateUser(socket.id);
|
||||
}
|
||||
if (this.sessionManager && typeof this.sessionManager.getBySocket === 'function') {
|
||||
return this.sessionManager.getBySocket(socket.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async packGrantsForUser(userId) {
|
||||
if (!this.db || userId == null) return [];
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT pack_id, source, granted_at, expires_at
|
||||
FROM user_pack_entitlements
|
||||
WHERE user_id = $1
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
`, [userId]);
|
||||
return result.rows || [];
|
||||
} catch (err) {
|
||||
// Fresh deployments run migrations before sockets are accepted. This fallback keeps
|
||||
// tests and partially-migrated dev DBs from losing the whole connection path.
|
||||
if (err && (err.code === '42P01' || /user_pack_entitlements/i.test(err.message || ''))) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('[IdentityService] user_pack_entitlements unavailable; using credit policy only.');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async entitlementsForUser(user) {
|
||||
const catalog = await this._catalog();
|
||||
if (!user) return Entitlements.snapshotForUser({}, [], catalog);
|
||||
const grants = await this.packGrantsForUser(user.id);
|
||||
// An active premium subscription (resolved by npub) sets subscription_tier, which unlocks
|
||||
// the cosmetic packs at/below that tier. Best-effort: any resolver failure leaves it unset.
|
||||
if (this.subscriptionService && user.smirk_public_key && user.subscription_tier == null) {
|
||||
try {
|
||||
const tier = await this.subscriptionService.tierForNpub(user.smirk_public_key);
|
||||
if (tier) user = { ...user, subscription_tier: tier };
|
||||
} catch (_) { /* leave unset */ }
|
||||
}
|
||||
return Entitlements.snapshotForUser(user, grants, catalog);
|
||||
}
|
||||
|
||||
async entitlementsForSocket(socket) {
|
||||
const user = await this.userForSocket(socket);
|
||||
return this.entitlementsForUser(user);
|
||||
}
|
||||
|
||||
async identityForSocket(socket) {
|
||||
const user = await this.userForSocket(socket);
|
||||
const entitlements = await this.entitlementsForUser(user);
|
||||
const appearance = Entitlements.normalizeAppearance(parseAppearance(user?.appearance), entitlements);
|
||||
return {
|
||||
appearance,
|
||||
entitlements
|
||||
};
|
||||
}
|
||||
|
||||
async saveAppearanceForSocket(socket, input) {
|
||||
const user = await this.userForSocket(socket);
|
||||
const entitlements = await this.entitlementsForUser(user);
|
||||
const appearance = Entitlements.normalizeAppearance(input, entitlements);
|
||||
|
||||
if (!this.db || !user?.id) {
|
||||
return { appearance, entitlements };
|
||||
}
|
||||
|
||||
const result = await this.db.query(`
|
||||
UPDATE users
|
||||
SET appearance = $1::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *
|
||||
`, [JSON.stringify(appearance), user.id]);
|
||||
|
||||
const updated = result.rows && result.rows[0] ? result.rows[0] : { ...user, appearance };
|
||||
this._refreshSessionCache(socket.id, updated);
|
||||
return {
|
||||
appearance: Entitlements.normalizeAppearance(parseAppearance(updated.appearance), entitlements),
|
||||
entitlements
|
||||
};
|
||||
}
|
||||
|
||||
_refreshSessionCache(socketId, user) {
|
||||
if (!socketId || !user || !this.sessionManager?.sessions) return;
|
||||
if (this.sessionManager.sessions.has(socketId)) {
|
||||
this.sessionManager.sessions.set(socketId, user);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers for match mode (user lookup by stable id, not socket)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async userForId(userId) {
|
||||
if (!this.db || userId == null) return null;
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT * FROM users WHERE id = $1 LIMIT 1
|
||||
`, [userId]);
|
||||
return result.rows && result.rows[0] ? result.rows[0] : null;
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('[IdentityService] userForId failed:', err.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IdentityService;
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Match economy admission policy.
|
||||
*
|
||||
* Crypto matches are materially different from free/prestige matches: consuming a ticket
|
||||
* promises one on-chain winner payout. Keep every admission caller on the same fail-closed
|
||||
* contract so the queue, scheduler, and payout snapshot cannot disagree.
|
||||
*/
|
||||
|
||||
const { resolveMatchRuleset } = require('../game/rulesets');
|
||||
|
||||
const CRYPTO_WIN_CONDITIONS = new Set(['first-to-exit', 'last-alive', 'high-score']);
|
||||
const PG_BIGINT_MAX = 9223372036854775807n;
|
||||
|
||||
function isExplicitTrue(value) {
|
||||
return String(value ?? '').trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function parsePositiveAtomic(value) {
|
||||
const raw = String(value ?? '').trim().replace(/_/g, '');
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
try {
|
||||
const parsed = BigInt(raw);
|
||||
return parsed > 0n && parsed <= PG_BIGINT_MAX ? parsed : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHouseFeeBasisPoints(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!/^\d+(?:\.\d{1,2})?$/.test(raw)) return null;
|
||||
const percent = Number(raw);
|
||||
if (!Number.isFinite(percent) || percent < 0 || percent >= 100) return null;
|
||||
return Math.round(percent * 100);
|
||||
}
|
||||
|
||||
function playerContract(ruleset, requestedMaxPlayers) {
|
||||
const ruleMin = Number.isInteger(ruleset?.players?.min) ? ruleset.players.min : 2;
|
||||
const ruleMax = Number.isInteger(ruleset?.players?.max) ? ruleset.players.max : 4;
|
||||
const hasConfiguredMax = requestedMaxPlayers !== null
|
||||
&& requestedMaxPlayers !== undefined
|
||||
&& String(requestedMaxPlayers).trim() !== '';
|
||||
const rawConfiguredMax = String(requestedMaxPlayers ?? '').trim();
|
||||
const configured = /^\d+$/.test(rawConfiguredMax) ? Number(rawConfiguredMax) : NaN;
|
||||
const configuredIsValidInteger = Number.isSafeInteger(configured);
|
||||
const requestedMax = configuredIsValidInteger ? configured : ruleMax;
|
||||
// Match persistence and transport require at least two entrants even when a ruleset (such
|
||||
// as score attack) is also valid as a standalone one-player challenge.
|
||||
const minPlayers = Math.max(2, ruleMin);
|
||||
const maxPlayers = Math.max(2, Math.min(32, ruleMax, requestedMax));
|
||||
return {
|
||||
minPlayers,
|
||||
maxPlayers,
|
||||
valid: (!hasConfiguredMax || configuredIsValidInteger)
|
||||
&& minPlayers <= maxPlayers
|
||||
&& requestedMax >= minPlayers
|
||||
&& requestedMax <= 32
|
||||
};
|
||||
}
|
||||
|
||||
function cryptoRulesetSupported(ruleset) {
|
||||
return !!ruleset
|
||||
&& ruleset.mode !== 'solo'
|
||||
&& ruleset.mode !== 'coop'
|
||||
&& CRYPTO_WIN_CONDITIONS.has(ruleset.winCondition?.type)
|
||||
&& Number(ruleset.players?.max) >= 2;
|
||||
}
|
||||
|
||||
function matchPayoutAdmissionPolicy({
|
||||
env = process.env,
|
||||
gameModeManager = null,
|
||||
ruleset = null,
|
||||
requestedMaxPlayers = null
|
||||
} = {}) {
|
||||
const resolvedRuleset = ruleset || resolveMatchRuleset(env.MATCH_RULESET_ID || 'race');
|
||||
const players = playerContract(
|
||||
resolvedRuleset,
|
||||
requestedMaxPlayers ?? env.MATCH_MAX_PLAYERS ?? resolvedRuleset.players.max
|
||||
);
|
||||
const payoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX);
|
||||
const entryFee = parsePositiveAtomic(env.MATCH_ENTRY_FEE_ATOMIC);
|
||||
const houseFeeBasisPoints = parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT);
|
||||
const masterPayoutsEnabled = gameModeManager
|
||||
? gameModeManager.payoutsEnabled === true
|
||||
: isExplicitTrue(env.PAYOUTS_ENABLED);
|
||||
const paidProductAvailable = gameModeManager
|
||||
? !!(gameModeManager.directModeEnabled || gameModeManager.creditsModeEnabled)
|
||||
: isExplicitTrue(env.PAYMENTS_ENABLED);
|
||||
|
||||
const checks = [
|
||||
[isExplicitTrue(env.MATCH_ENABLED), 'match_disabled'],
|
||||
[isExplicitTrue(env.MATCH_CRYPTO_RACE_ENABLED), 'crypto_match_disabled'],
|
||||
[isExplicitTrue(env.MATCH_PAYOUTS_ENABLED), 'match_payouts_disabled'],
|
||||
[masterPayoutsEnabled, 'payout_master_disabled'],
|
||||
[paidProductAvailable, 'paid_products_disabled'],
|
||||
[cryptoRulesetSupported(resolvedRuleset), 'unsupported_crypto_ruleset'],
|
||||
[players.valid, 'invalid_player_contract'],
|
||||
[payoutCap !== null, 'invalid_payout_cap'],
|
||||
[entryFee !== null, 'invalid_entry_fee'],
|
||||
[houseFeeBasisPoints !== null, 'invalid_house_fee']
|
||||
];
|
||||
const failed = checks.find(([ok]) => !ok);
|
||||
|
||||
return Object.freeze({
|
||||
enabled: !failed,
|
||||
reason: failed ? failed[1] : null,
|
||||
ruleset: resolvedRuleset,
|
||||
minPlayers: players.minPlayers,
|
||||
maxPlayers: players.maxPlayers,
|
||||
payoutCap,
|
||||
entryFee,
|
||||
houseFeeBasisPoints
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PG_BIGINT_MAX,
|
||||
cryptoRulesetSupported,
|
||||
isExplicitTrue,
|
||||
matchPayoutAdmissionPolicy,
|
||||
parseHouseFeeBasisPoints,
|
||||
parsePositiveAtomic,
|
||||
playerContract
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const LEGACY_PAID_FREEZE_VERSION = 'future-block-freeze-v1';
|
||||
const PAID_FREEZE_VERSION = 'future-block-freeze-v2';
|
||||
const PAID_SEED_VERSION = 'future-chain-block-v2';
|
||||
const PAID_ECONOMIES = new Set(['credits_prestige', 'crypto_race']);
|
||||
|
||||
function invariant(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function canonicalQueueEntryIds(queueEntryIds) {
|
||||
const ids = (queueEntryIds || []).map(id => String(id));
|
||||
invariant(ids.length >= 2 && ids.every(id => /^\d+$/.test(id)),
|
||||
'At least two durable numeric queue entry ids are required');
|
||||
invariant(new Set(ids).size === ids.length, 'Queue entry ids must be unique');
|
||||
return ids.sort((left, right) => {
|
||||
if (left.length !== right.length) return left.length - right.length;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
}
|
||||
|
||||
function blockHeight(value, label) {
|
||||
const height = Number(value);
|
||||
invariant(Number.isSafeInteger(height) && height >= 0, `${label} must be a safe block height`);
|
||||
return height;
|
||||
}
|
||||
|
||||
function canonicalBlockHash(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
invariant(/^[0-9a-f]{64}$/.test(normalized), 'A canonical 64-hex block hash is required');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildPaidEntrantFreeze({
|
||||
version = PAID_FREEZE_VERSION,
|
||||
freezeBlockHeight,
|
||||
targetBlockHeight,
|
||||
entropyDelayBlocks = null,
|
||||
economy,
|
||||
rulesetId,
|
||||
queueEntryIds
|
||||
}) {
|
||||
invariant(version === PAID_FREEZE_VERSION, 'Paid entrant freeze version is unsupported');
|
||||
const frozenAt = blockHeight(freezeBlockHeight, 'Freeze block height');
|
||||
const target = blockHeight(targetBlockHeight, 'Target block height');
|
||||
const delay = target - frozenAt;
|
||||
invariant(Number.isSafeInteger(delay) && delay >= 1,
|
||||
'Paid seed block must be after the entrant freeze');
|
||||
if (entropyDelayBlocks != null) {
|
||||
invariant(blockHeight(entropyDelayBlocks, 'Paid entropy delay') === delay,
|
||||
'Paid entropy delay does not match the committed block heights');
|
||||
}
|
||||
invariant(PAID_ECONOMIES.has(String(economy)), 'Paid entrant freeze requires a paid economy');
|
||||
invariant(/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(rulesetId || '')),
|
||||
'Paid entrant freeze requires a canonical ruleset id');
|
||||
const ids = canonicalQueueEntryIds(queueEntryIds);
|
||||
const canonical = [
|
||||
'wowngeon-paid-entrant-freeze', PAID_FREEZE_VERSION, String(frozenAt), String(target),
|
||||
String(delay),
|
||||
String(economy), String(rulesetId), ids.join(',')
|
||||
].join('|');
|
||||
const freezeCommitment = crypto.createHash('sha256').update(canonical).digest('hex');
|
||||
return Object.freeze({
|
||||
version: PAID_FREEZE_VERSION,
|
||||
freezeBlockHeight: frozenAt,
|
||||
targetBlockHeight: target,
|
||||
entropyDelayBlocks: delay,
|
||||
economy: String(economy),
|
||||
rulesetId: String(rulesetId),
|
||||
queueEntryIds: Object.freeze(ids),
|
||||
freezeCommitment
|
||||
});
|
||||
}
|
||||
|
||||
function deriveFutureBlockMatchSeed({ blockHash, blockHeight: height, freeze }) {
|
||||
const verifiedFreeze = buildPaidEntrantFreeze(freeze || {});
|
||||
const entropyHeight = blockHeight(height, 'Entropy block height');
|
||||
invariant(entropyHeight === verifiedFreeze.targetBlockHeight,
|
||||
'Paid seed must use the exact future block committed by the entrant freeze');
|
||||
if (freeze?.freezeCommitment != null) {
|
||||
invariant(String(freeze.freezeCommitment) === verifiedFreeze.freezeCommitment,
|
||||
'Paid entrant freeze commitment does not match its durable inputs');
|
||||
}
|
||||
const hash = canonicalBlockHash(blockHash);
|
||||
const canonical = [
|
||||
'wowngeon-match', PAID_SEED_VERSION, hash, String(entropyHeight),
|
||||
verifiedFreeze.freezeCommitment, verifiedFreeze.economy, verifiedFreeze.rulesetId,
|
||||
verifiedFreeze.queueEntryIds.join(',')
|
||||
].join('|');
|
||||
return Object.freeze({
|
||||
seed: crypto.createHash('sha256').update(canonical).digest('hex'),
|
||||
derivation: Object.freeze({
|
||||
version: PAID_SEED_VERSION,
|
||||
blockHash: hash,
|
||||
blockHeight: entropyHeight,
|
||||
freezeBlockHeight: verifiedFreeze.freezeBlockHeight,
|
||||
targetBlockHeight: verifiedFreeze.targetBlockHeight,
|
||||
entropyDelayBlocks: verifiedFreeze.entropyDelayBlocks,
|
||||
freezeCommitment: verifiedFreeze.freezeCommitment,
|
||||
economy: verifiedFreeze.economy,
|
||||
rulesetId: verifiedFreeze.rulesetId,
|
||||
queueEntryIds: verifiedFreeze.queueEntryIds
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LEGACY_PAID_FREEZE_VERSION,
|
||||
PAID_FREEZE_VERSION,
|
||||
PAID_SEED_VERSION,
|
||||
buildPaidEntrantFreeze,
|
||||
canonicalBlockHash,
|
||||
canonicalQueueEntryIds,
|
||||
deriveFutureBlockMatchSeed
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* MatchLeaderboard: post-race leaderboard integration.
|
||||
*
|
||||
* At the end of every match, this module decides which leaderboard each entrant's score
|
||||
* belongs to:
|
||||
* • free races → Pleb board (game_mode = 'FREE')
|
||||
* • credits_prestige races → Prestige board (authoritative match tables)
|
||||
* • crypto_race races → Hall of Champions (games-like paid board)
|
||||
*
|
||||
* For free and crypto_race we insert a synthetic `games` row if the instance wants those
|
||||
* scores surfaced on the existing boards. The prestige route queries `matches` and
|
||||
* `match_entrants` directly.
|
||||
*/
|
||||
|
||||
class MatchLeaderboard {
|
||||
constructor({ db, io = null, debugManager = null } = {}) {
|
||||
this.db = db;
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
}
|
||||
|
||||
_log(...args) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist scores and emit leaderboard_update where appropriate.
|
||||
* @param {MatchRoom} room
|
||||
*/
|
||||
async postMatch(room) {
|
||||
if (!this.db || room.status !== 'finished') return;
|
||||
|
||||
// The existing Pleb/Prestige/Champions boards rank individuals. Co-op has a collective
|
||||
// success condition and therefore must not manufacture an individual winner from its
|
||||
// placement order. Its authoritative result remains in matches/match_entrants until a
|
||||
// dedicated team leaderboard exists.
|
||||
if (this._isCoop(room)) return;
|
||||
|
||||
const economy = room.economy;
|
||||
|
||||
if (economy === 'free') {
|
||||
await this._postFreeToPleb(room);
|
||||
} else if (economy === 'credits_prestige') {
|
||||
await this._postPrestige(room);
|
||||
} else if (economy === 'crypto_race') {
|
||||
await this._postCryptoRace(room);
|
||||
}
|
||||
}
|
||||
|
||||
_isCoop(room) {
|
||||
return room?.ruleset?.mode === 'coop'
|
||||
|| room?.ruleset?.winCondition?.type === 'all-escape';
|
||||
}
|
||||
|
||||
_entrantResult(room, socketId, state) {
|
||||
// MatchRoom.finalize() makes these agree, but require both facts defensively: a stale or
|
||||
// partially-built in-memory room must never award an individual leaderboard win.
|
||||
const won = socketId === room.winnerId && Number(state.placement) === 1;
|
||||
return {
|
||||
status: won ? 'won' : 'lost',
|
||||
outcome: won ? 'match_winner' : 'match_loss'
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const result = this._entrantResult(room, socketId, state);
|
||||
await client.query(`
|
||||
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,
|
||||
socketId,
|
||||
'FREE',
|
||||
result.status,
|
||||
result.outcome,
|
||||
state.hasTreasure,
|
||||
state.moves,
|
||||
Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
|
||||
state.score,
|
||||
room.id
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1) WHERE id = $2
|
||||
`, [state.score, state.userId]);
|
||||
}
|
||||
});
|
||||
|
||||
this._broadcastUpdate('pleb');
|
||||
}
|
||||
|
||||
async _postPrestige(room) {
|
||||
// Scores are already persisted to match_entrants. The runtime prestige query reads them
|
||||
// directly, so we just broadcast an update so open leaderboards refresh.
|
||||
this._broadcastUpdate('prestige');
|
||||
}
|
||||
|
||||
async _postCryptoRace(room) {
|
||||
// Insert synthetic paid records for the Hall of Champions board. 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;
|
||||
const result = this._entrantResult(room, socketId, state);
|
||||
await client.query(`
|
||||
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,
|
||||
socketId,
|
||||
'PAID_CREDITS',
|
||||
result.status,
|
||||
result.outcome,
|
||||
state.hasTreasure,
|
||||
state.moves,
|
||||
Math.max(1, Math.round(((room.endedAt || Date.now()) - (room.startedAt || room.createdAt)) / 1000)),
|
||||
state.score,
|
||||
room.id
|
||||
]);
|
||||
|
||||
await client.query(`
|
||||
UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1) WHERE id = $2
|
||||
`, [state.score, state.userId]);
|
||||
}
|
||||
});
|
||||
|
||||
this._broadcastUpdate('champions');
|
||||
}
|
||||
|
||||
_broadcastUpdate(board) {
|
||||
if (!this.io) return;
|
||||
this.io.emit('leaderboard_update', { board, at: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MatchLeaderboard;
|
||||
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* MatchManager: Socket.IO transport and lifecycle for match mode.
|
||||
*
|
||||
* 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 MatchState = require('../multiplayer/MatchState');
|
||||
const MatchPayoutService = require('./matchPayoutService');
|
||||
const MatchLeaderboard = require('./matchLeaderboard');
|
||||
const { createFinancialRecoveryError } = require('../utils/financialRecoveryError');
|
||||
|
||||
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 before AFK-kill
|
||||
const DEFAULT_COUNTDOWN_MS = 3000;
|
||||
const FINALIZE_CLEANUP_MS = 30000; // keep mappings this long after finish for late reconnects
|
||||
const FINALIZE_RETRY_MS = 5000; // retry a nondurable finish without publishing a winner
|
||||
|
||||
class MatchManager {
|
||||
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._pendingCleanups = new Set(); // grace-cleanup timers awaiting fire
|
||||
this.financialRecoveryReady = false;
|
||||
this._liabilityReconcileTimer = null;
|
||||
this._liabilityReconcileRunning = false;
|
||||
|
||||
this.matchLeaderboard = new MatchLeaderboard({ db: this.db, io: this.io, debugManager: this.debugManager });
|
||||
this.matchPayoutService = new MatchPayoutService({
|
||||
db: this.db,
|
||||
walletService: this.gameModeManager?.walletService || null,
|
||||
gameModeManager: this.gameModeManager,
|
||||
debugManager: this.debugManager
|
||||
});
|
||||
|
||||
// Last-known movement per socket for flood control.
|
||||
this._lastMoveAt = new Map();
|
||||
this.moveCooldownMs = 60;
|
||||
}
|
||||
|
||||
_log(...args) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING || CONSOLE_LOGGING) console.log(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async initialize() {
|
||||
// Queue recovery resolves abandoned in-flight escrow first. Finished matches already
|
||||
// have a durable winner + accepted liability snapshot, so reconstruct any payout row
|
||||
// that a crash/transient DB error prevented us from inserting after completion.
|
||||
// MATCH_ENABLED controls new rooms only. Reconciliation must run even when operators
|
||||
// disable the entire match surface, because an already-finished accepted liability is
|
||||
// still owed and may have crashed before its payout row was inserted.
|
||||
if (!this.db || !this.matchPayoutService) {
|
||||
throw createFinancialRecoveryError('finished_match_liabilities', {
|
||||
scanFailed: true,
|
||||
scanned: 0,
|
||||
resolved: 0,
|
||||
unresolved: []
|
||||
});
|
||||
}
|
||||
await this._reconcileFinishedLiabilities({ failClosed: true });
|
||||
|
||||
// A payout insert can fail after a match finish has already committed. Re-scan in
|
||||
// bounded batches so a transient runtime failure is retried without duplicating payout
|
||||
// rows (the database's match_id uniqueness remains the exactly-once anchor).
|
||||
const configuredMs = Number(process.env.MATCH_LIABILITY_RECONCILE_MS);
|
||||
const intervalMs = Math.max(5000, Math.min(
|
||||
300000,
|
||||
Number.isFinite(configuredMs) && configuredMs > 0 ? configuredMs : 30000
|
||||
));
|
||||
this._liabilityReconcileTimer = setInterval(() => {
|
||||
this._reconcileFinishedLiabilities({ failClosed: false }).catch(error => {
|
||||
this._log('[MatchManager] liability reconciliation timer error', error.message);
|
||||
});
|
||||
}, intervalMs);
|
||||
this._liabilityReconcileTimer.unref?.();
|
||||
}
|
||||
|
||||
async _reconcileFinishedLiabilities({ failClosed = false, batchSize = 100, maxBatches = 100 } = {}) {
|
||||
if (this._liabilityReconcileRunning) {
|
||||
if (failClosed) {
|
||||
throw createFinancialRecoveryError('finished_match_liabilities', {
|
||||
scanned: 0,
|
||||
resolved: 0,
|
||||
unresolved: [{ type: 'reconciliation', id: 'already_running' }]
|
||||
});
|
||||
}
|
||||
return { ok: false, skipped: true, scanned: 0, created: 0, failed: 0, unresolved: [] };
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(1000, parseInt(batchSize, 10) || 100));
|
||||
const batchLimit = Math.max(1, Math.min(1000, parseInt(maxBatches, 10) || 100));
|
||||
const total = { ok: true, scanned: 0, created: 0, failed: 0, unresolved: [] };
|
||||
this._liabilityReconcileRunning = true;
|
||||
try {
|
||||
for (let batch = 0; batch < batchLimit; batch += 1) {
|
||||
let result;
|
||||
try {
|
||||
result = await this.matchPayoutService.reconcileFinishedLiabilities({ limit });
|
||||
} catch (error) {
|
||||
throw createFinancialRecoveryError('finished_match_liabilities', {
|
||||
scanFailed: true,
|
||||
scanned: total.scanned,
|
||||
resolved: total.created,
|
||||
unresolved: total.unresolved
|
||||
}, error);
|
||||
}
|
||||
total.scanned += Number(result.scanned) || 0;
|
||||
total.created += Number(result.created) || 0;
|
||||
total.failed += Number(result.failed) || 0;
|
||||
if (Array.isArray(result.unresolved)) total.unresolved.push(...result.unresolved);
|
||||
|
||||
if (total.failed > 0 || result.ok === false) {
|
||||
if (total.unresolved.length === 0) {
|
||||
total.unresolved.push({ type: 'match_liability', id: 'unknown' });
|
||||
}
|
||||
throw createFinancialRecoveryError('finished_match_liabilities', {
|
||||
scanned: total.scanned,
|
||||
resolved: total.created,
|
||||
unresolved: total.unresolved
|
||||
});
|
||||
}
|
||||
if ((Number(result.scanned) || 0) < limit) {
|
||||
this.financialRecoveryReady = true;
|
||||
if (total.created > 0) {
|
||||
this._log(`[MatchManager] liability reconciliation: ${total.created} created, 0 failed`);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
throw createFinancialRecoveryError('finished_match_liabilities', {
|
||||
scanned: total.scanned,
|
||||
resolved: total.created,
|
||||
unresolved: [{ type: 'reconciliation_backlog', id: `>${limit * batchLimit}` }]
|
||||
});
|
||||
} catch (error) {
|
||||
this.financialRecoveryReady = false;
|
||||
total.ok = false;
|
||||
if (failClosed) throw error;
|
||||
this._log('[MatchManager] liability reconciliation incomplete', error.message);
|
||||
return total;
|
||||
} finally {
|
||||
this._liabilityReconcileRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
|
||||
room.minDurationMs = Math.max(0, Number(minDurationMs) || 0);
|
||||
room.hardCeilingMs = Math.max(1, Number(hardCeilingMs) || 1);
|
||||
room._minDurationMet = room.minDurationMs === 0;
|
||||
|
||||
this.rooms.set(room.id, room);
|
||||
for (const e of (entrants || [])) {
|
||||
this.socketToMatch.set(e.socketId, room.id);
|
||||
this.userToMatch.set(e.userId, room.id);
|
||||
}
|
||||
|
||||
// Resolve display names and appearances from identity service (best-effort).
|
||||
this._hydrateEntrants(room, entrants).catch(err => this._log('[MatchManager] hydrate error', err.message));
|
||||
|
||||
this._log(`[MatchManager] attached match ${room.id} with ${(entrants || []).length} entrants`);
|
||||
|
||||
// Notify players (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', {
|
||||
matchId: room.id,
|
||||
economy: room.economy,
|
||||
ruleset: room._rulesetSummary?.() || null,
|
||||
seedHash: room.seedHash,
|
||||
fairness: room.fairnessProof?.(false) || null,
|
||||
players: Array.from(room.occupants.values()).map(o => o.getState()),
|
||||
countdownMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
// Reflect the now-live race in the DB (was left 'starting' during the countdown). This
|
||||
// lets boot recovery distinguish a genuinely-running race from an abandoned one, and it
|
||||
// stamps started_at so the age-guarded (multi-instance) recovery path won't reclaim a
|
||||
// race that is legitimately in flight.
|
||||
if (this.db) {
|
||||
this.db.query(
|
||||
`UPDATE matches SET status = 'active', started_at = COALESCE(started_at, NOW()) WHERE id = $1 AND status = 'starting'`,
|
||||
[room.id]
|
||||
).catch(err => this._log('[MatchManager] mark-active error', err.message));
|
||||
}
|
||||
this._broadcast(room.id, 'match_start', {
|
||||
matchId: room.id,
|
||||
tickMs,
|
||||
seedHash: r.seedHash,
|
||||
fairness: r.fairnessProof?.(false) || null,
|
||||
ruleset: r._rulesetSummary?.() || null
|
||||
});
|
||||
|
||||
// The floor is active-play time, not queue/countdown time. The block handler also
|
||||
// checks startedAt directly, avoiding a timer-order race when a block arrives exactly
|
||||
// as the floor elapses.
|
||||
if (!r._minDurationMet) {
|
||||
const floorTimeout = setTimeout(() => {
|
||||
const live = this.rooms.get(room.id);
|
||||
if (live) live._minDurationMet = true;
|
||||
}, r.minDurationMs);
|
||||
if (Array.isArray(r._watchdogs)) r._watchdogs.push(floorTimeout);
|
||||
}
|
||||
}, countdownMs);
|
||||
|
||||
// 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'), room.hardCeilingMs);
|
||||
|
||||
room._watchdogs = [startTimer, ceilingTimeout];
|
||||
}
|
||||
|
||||
async _hydrateEntrants(room, entrants) {
|
||||
if (!this.identityService) return;
|
||||
for (const e of (entrants || [])) {
|
||||
try {
|
||||
const user = await this.identityService.userForId(e.userId);
|
||||
if (user) {
|
||||
const entitlements = await this.identityService.entitlementsForUser(user);
|
||||
const appearance = require('../multiplayer/entitlements').normalizeAppearance(
|
||||
require('../multiplayer/appearance').parseAppearance(user.appearance),
|
||||
entitlements
|
||||
);
|
||||
const occ = room.occupants.get(e.socketId);
|
||||
if (occ) {
|
||||
occ.name = user.display_name || user.username || null;
|
||||
occ.avatar = appearance.avatar;
|
||||
occ.appearance = appearance;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this._log('[MatchManager] hydrate entrant error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEngine(matchId, engine) {
|
||||
this.engines.set(matchId, engine);
|
||||
}
|
||||
|
||||
_channel(matchId) {
|
||||
return `match:${matchId}`;
|
||||
}
|
||||
|
||||
_broadcast(matchId, event, payload) {
|
||||
if (!this.io) return;
|
||||
this.io.to(this._channel(matchId)).emit(event, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by MatchEngine every tick.
|
||||
*/
|
||||
onTick(matchId, result) {
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
this._broadcast(matchId, 'match_tick', {
|
||||
matchId,
|
||||
tick: result.tick,
|
||||
events: result.events,
|
||||
state: room.toGameState()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End active block-bounded rooms on the first advancing header after their active-play floor.
|
||||
* Rooms created by the same header are protected by the strict height comparison.
|
||||
* @returns {number} number of rooms expired
|
||||
*/
|
||||
expireBlockDeadlines(blockHeight) {
|
||||
const height = Number(blockHeight);
|
||||
if (!Number.isSafeInteger(height) || height < 0) return 0;
|
||||
|
||||
let expired = 0;
|
||||
for (const room of Array.from(this.rooms.values())) {
|
||||
if (!room || room.status !== 'active' || room._finalized) continue;
|
||||
if (room.ruleset?.timing?.blockDeadline !== true) continue;
|
||||
const startHeight = Number(room.startBlockHeight);
|
||||
if (!Number.isSafeInteger(startHeight) || height <= startHeight) continue;
|
||||
|
||||
const floorMs = Math.max(0, Number(room.minDurationMs) || 0);
|
||||
const activeForMs = Number.isFinite(room.startedAt) ? Date.now() - room.startedAt : -1;
|
||||
const floorMet = room._minDurationMet === true || activeForMs >= floorMs;
|
||||
if (!floorMet) continue;
|
||||
|
||||
room._minDurationMet = true;
|
||||
room.endBlockHeight = height;
|
||||
this.expire(room.id, 'block_deadline');
|
||||
expired += 1;
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). Idempotent.
|
||||
*/
|
||||
expire(matchId, reason) {
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room || room._finalized) return;
|
||||
const engine = this.engines.get(matchId);
|
||||
if (engine) {
|
||||
engine.expire(reason); // room.expire + finalize + onFinish -> _finalize
|
||||
} else {
|
||||
room.expire(reason);
|
||||
room.finalize();
|
||||
this._finalize(room).catch(err => this._log('[MatchManager] finalize error', err.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || room._finalized) return;
|
||||
// Engine callbacks, watchdogs, and disconnect handling can converge here together. Share
|
||||
// one attempt so persistence/payout/leaderboard work never runs concurrently.
|
||||
if (room._finalizePromise) return room._finalizePromise;
|
||||
const attempt = this._finalizeAttempt(room);
|
||||
room._finalizePromise = attempt;
|
||||
try {
|
||||
return await attempt;
|
||||
} finally {
|
||||
if (room._finalizePromise === attempt) room._finalizePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async _finalizeAttempt(room) {
|
||||
|
||||
// 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 the winner first. Once that transaction commits, the accepted liability is
|
||||
// fully reconstructable from SQL even if payout insertion fails or the process crashes.
|
||||
let finishPersisted = !this.db;
|
||||
if (this.db) {
|
||||
try {
|
||||
await this._persistFinish(room);
|
||||
finishPersisted = true;
|
||||
}
|
||||
catch (err) { this._log('[MatchManager] persist error', err.message); }
|
||||
}
|
||||
if (!finishPersisted) {
|
||||
// This is deliberately not `match_end`: clients must not publish a winner which the
|
||||
// authoritative database cannot prove. Keep the room mapped and retry the idempotent
|
||||
// finish transaction; boot recovery remains the final fallback if the process exits.
|
||||
try {
|
||||
this._broadcast(room.id, 'match_settlement_pending', {
|
||||
matchId: room.id,
|
||||
code: 'MATCH_FINISH_NOT_DURABLE',
|
||||
retrying: true
|
||||
});
|
||||
} catch (err) { this._log('[MatchManager] pending broadcast error', err.message); }
|
||||
this._scheduleFinalizeRetry(room);
|
||||
return;
|
||||
}
|
||||
|
||||
room._finalized = true;
|
||||
if (room._finalizeRetryTimer) {
|
||||
clearTimeout(room._finalizeRetryTimer);
|
||||
this._pendingCleanups.delete(room._finalizeRetryTimer);
|
||||
room._finalizeRetryTimer = null;
|
||||
}
|
||||
// Insert from the immutable admission snapshot, not current MATCH_PAYOUTS_ENABLED. A
|
||||
// failure is retried by initialize() reconciliation because the finished winner is now
|
||||
// durable. Never create a payout when the finish itself did not persist.
|
||||
if (room.economy === 'crypto_race' && finishPersisted) {
|
||||
try { await this.matchPayoutService.payoutWinner(room); }
|
||||
catch (err) {
|
||||
// The match result and accepted liability are already durable. Close every paid
|
||||
// admission surface immediately, then attempt the idempotent SQL reconstruction;
|
||||
// the bounded timer remains the fallback if this immediate pass also fails.
|
||||
this.financialRecoveryReady = false;
|
||||
this._log('[MatchManager] payout error', err.message);
|
||||
await this._reconcileFinishedLiabilities({ failClosed: false });
|
||||
}
|
||||
}
|
||||
// Leaderboard rows are derived from the durable match result. Never create synthetic
|
||||
// `games` rows (or announce a prestige refresh) when the finish transaction failed: that
|
||||
// would publish a result which the authoritative matches tables do not contain.
|
||||
if (finishPersisted) {
|
||||
try { await this.matchLeaderboard.postMatch(room); }
|
||||
catch (err) { this._log('[MatchManager] leaderboard error', err.message); }
|
||||
}
|
||||
// Only a durable result is announced as final.
|
||||
try {
|
||||
this._broadcast(room.id, 'match_end', this._terminalPayload(room));
|
||||
} catch (err) { this._log('[MatchManager] broadcast error', err.message); }
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
_scheduleFinalizeRetry(room) {
|
||||
if (!room || room._finalized || room._finalizeRetryTimer) return;
|
||||
const retryTimer = setTimeout(() => {
|
||||
this._pendingCleanups.delete(retryTimer);
|
||||
if (room._finalizeRetryTimer === retryTimer) room._finalizeRetryTimer = null;
|
||||
if (!this.rooms.has(room.id) || room._finalized) return;
|
||||
this._finalize(room).catch(err => this._log('[MatchManager] finalize retry error', err.message));
|
||||
}, FINALIZE_RETRY_MS);
|
||||
room._finalizeRetryTimer = retryTimer;
|
||||
this._pendingCleanups.add(retryTimer);
|
||||
}
|
||||
|
||||
_terminalPayload(room) {
|
||||
return {
|
||||
matchId: room.id,
|
||||
reason: room.endReason,
|
||||
winnerId: room.winnerId,
|
||||
ruleset: room._rulesetSummary?.() || null,
|
||||
fairness: room.fairnessProof?.(true) || null,
|
||||
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
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
_emitTerminalToReconnect(socket, room) {
|
||||
if (!socket || !room?._finalized) return false;
|
||||
if (!room._terminalDeliveredSocketIds) room._terminalDeliveredSocketIds = new Set();
|
||||
if (room._terminalDeliveredSocketIds.has(socket.id)) return false;
|
||||
socket.emit('match_end', this._terminalPayload(room));
|
||||
room._terminalDeliveredSocketIds.add(socket.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
async _persistFinish(room) {
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const finished = await client.query(`
|
||||
UPDATE matches
|
||||
SET status = 'finished',
|
||||
seed = $1,
|
||||
end_block_height = $2,
|
||||
started_at = COALESCE(started_at, $3),
|
||||
ended_at = NOW(),
|
||||
winner_user_id = $4
|
||||
WHERE id = $5
|
||||
`, [
|
||||
room.seed,
|
||||
room.endBlockHeight || null,
|
||||
room.startedAt ? new Date(room.startedAt) : null,
|
||||
room.winnerId ? room.playerStates.get(room.winnerId)?.userId : null,
|
||||
room.id
|
||||
]);
|
||||
if (finished.rowCount !== 1) {
|
||||
throw new Error(`Match finish persistence missed row ${room.id}`);
|
||||
}
|
||||
|
||||
for (const [socketId, state] of room.playerStates.entries()) {
|
||||
await client.query(`
|
||||
UPDATE match_entrants
|
||||
SET placement = $1,
|
||||
escaped = $2,
|
||||
has_treasure = $3,
|
||||
killed_by = $4,
|
||||
score = $5,
|
||||
entry_consumed = TRUE
|
||||
WHERE match_id = $6 AND socket_id = $7
|
||||
`, [state.placement, state.escaped, state.hasTreasure, state.killedBy, state.score, room.id, socketId]);
|
||||
}
|
||||
|
||||
// Persist event log.
|
||||
const rows = MatchState.toEventRows(room, room.events);
|
||||
for (const row of rows) {
|
||||
await client.query(`
|
||||
INSERT INTO match_events (match_id, tick, type, payload)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, [row.match_id, row.tick, row.type, JSON.stringify(row.payload)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_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 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. 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 || !socket) return;
|
||||
|
||||
const now = Date.now();
|
||||
const last = this._lastMoveAt.get(socket.id) || 0;
|
||||
if (now - last < this.moveCooldownMs) return;
|
||||
this._lastMoveAt.set(socket.id, now);
|
||||
|
||||
const matchId = this.socketToMatch.get(socket.id);
|
||||
if (!matchId) return;
|
||||
const room = this.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
// Reject moves until the countdown elapses and the room is active (no head-start cheat).
|
||||
if (room.status !== 'active') return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
// 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 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(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 || 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.status === 'finished') {
|
||||
this._endNow(room, room.endReason || 'all_dead');
|
||||
}
|
||||
}
|
||||
}, RECONNECT_GRACE_MS);
|
||||
|
||||
this.disconnectTimeouts.set(key, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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;
|
||||
|
||||
// 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); }
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
socket.emit('match_rejoined', {
|
||||
matchId,
|
||||
state: room.toGameState(newId)
|
||||
});
|
||||
|
||||
// A finalized room is retained briefly precisely so a reconnecting player can recover its
|
||||
// result. Deliver the same terminal payload immediately, once per socket. If persistence
|
||||
// is still retrying, withhold the winner and tell this socket settlement is pending.
|
||||
if (room.status === 'finished') {
|
||||
if (room._finalized) this._emitTerminalToReconnect(socket, room);
|
||||
else {
|
||||
socket.emit('match_settlement_pending', {
|
||||
matchId: room.id,
|
||||
code: 'MATCH_FINISH_NOT_DURABLE',
|
||||
retrying: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public stats for admin/health endpoints.
|
||||
*/
|
||||
getStats() {
|
||||
const active = [];
|
||||
for (const [id, room] of this.rooms.entries()) {
|
||||
active.push({
|
||||
id,
|
||||
economy: room.economy,
|
||||
status: room.status,
|
||||
tick: room.tickCount,
|
||||
players: room.occupants.size,
|
||||
active: room.activePlayerCount,
|
||||
finished: room.finishCount
|
||||
});
|
||||
}
|
||||
return { activeMatches: active.length, matches: active };
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.financialRecoveryReady = false;
|
||||
if (this._liabilityReconcileTimer) {
|
||||
clearInterval(this._liabilityReconcileTimer);
|
||||
this._liabilityReconcileTimer = null;
|
||||
}
|
||||
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)); room._watchdogs = null; }
|
||||
const engine = this.engines.get(id);
|
||||
if (engine) { try { engine.stop(); } catch (_) {} }
|
||||
}
|
||||
this.rooms.clear();
|
||||
this.engines.clear();
|
||||
this.socketToMatch.clear();
|
||||
this.userToMatch.clear();
|
||||
this._lastMoveAt.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MatchManager;
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* MatchPayoutService: crypto-race pot accounting and winner payout.
|
||||
*
|
||||
* 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 path in GameModeManager. All pot/fee arithmetic is BigInt: no float precision
|
||||
* loss on large atomic amounts.
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
const { PG_BIGINT_MAX, matchPayoutAdmissionPolicy } = require('./matchEconomyPolicy');
|
||||
const { reservePayoutCapacity } = require('../services/payoutAdmissionService');
|
||||
|
||||
// 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';
|
||||
|
||||
class MatchPayoutService {
|
||||
constructor({ db, walletService, gameModeManager, debugManager, env = process.env } = {}) {
|
||||
this.db = db;
|
||||
this.walletService = walletService;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.debugManager = debugManager;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
_log(...args) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
|
||||
}
|
||||
|
||||
_admissionPolicy(room) {
|
||||
return matchPayoutAdmissionPolicy({
|
||||
env: this.env,
|
||||
gameModeManager: this.gameModeManager,
|
||||
ruleset: room?.ruleset,
|
||||
requestedMaxPlayers: room?.maxPlayers
|
||||
});
|
||||
}
|
||||
|
||||
_admissionError(reason) {
|
||||
const error = new Error(`Crypto match payout admission rejected: ${reason}`);
|
||||
error.code = String(reason || 'match_payout_admission_rejected').toUpperCase();
|
||||
return error;
|
||||
}
|
||||
|
||||
_validateEntrants(room, entrants, policy) {
|
||||
const list = Array.isArray(entrants) ? entrants : [];
|
||||
if (list.length < policy.minPlayers || list.length > policy.maxPlayers) {
|
||||
throw this._admissionError('invalid_player_count');
|
||||
}
|
||||
const userIds = list.map(entrant => entrant?.userId);
|
||||
if (userIds.some(userId => userId == null) || new Set(userIds.map(String)).size !== userIds.length) {
|
||||
throw this._admissionError('invalid_entrants');
|
||||
}
|
||||
const queueEntryIds = list.map(entrant => entrant?.queueEntryId);
|
||||
if (queueEntryIds.some(id => id == null) || new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) {
|
||||
throw this._admissionError('invalid_queue_entries');
|
||||
}
|
||||
if (room?.playerStates && room.playerStates.size !== list.length) {
|
||||
throw this._admissionError('entrant_state_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || room.economy !== 'crypto_race') return;
|
||||
|
||||
const policy = this._admissionPolicy(room);
|
||||
if (!policy.enabled) throw this._admissionError(policy.reason);
|
||||
this._validateEntrants(room, entrants, policy);
|
||||
|
||||
const entryFee = policy.entryFee; // required funded value per ticket
|
||||
const houseFeeBp = policy.houseFeeBasisPoints; // integer basis points (0..9999)
|
||||
const count = BigInt((entrants && entrants.length) || 0);
|
||||
const housePercent = houseFeeBp / 100; // for the DECIMAL(5,2) audit column
|
||||
|
||||
// Fast outer-bound check before opening the commitment transaction. The authoritative
|
||||
// amount below is summed from locked paid ticket lots, never fabricated from this config.
|
||||
const configuredPot = entryFee * count;
|
||||
const configuredFee = (configuredPot * BigInt(houseFeeBp)) / 10000n;
|
||||
const configuredLiability = configuredPot - configuredFee;
|
||||
if (configuredPot > PG_BIGINT_MAX) {
|
||||
throw this._admissionError('pot_overflow');
|
||||
}
|
||||
if (configuredPot - configuredFee <= 0n || configuredPot - configuredFee > policy.payoutCap) {
|
||||
throw this._admissionError('payout_cap_exceeded');
|
||||
}
|
||||
|
||||
const acceptedSnapshot = await this.db.withTransaction(async (client) => {
|
||||
// Acquire the shared bankroll-admission lock before any per-match row lock. This
|
||||
// preserves one lock order across solo and match starts and prevents concurrent
|
||||
// commitments from all passing against the same unlocked wallet snapshot.
|
||||
await reservePayoutCapacity({
|
||||
client,
|
||||
walletService: this.walletService,
|
||||
newLiability: configuredLiability,
|
||||
gameModeManager: this.gameModeManager,
|
||||
env: this.env
|
||||
});
|
||||
const queueEntryIds = entrants.map(entrant => entrant.queueEntryId);
|
||||
const backingResult = await client.query(`
|
||||
SELECT id, user_id, race_entry_lot_id, escrow_amount, escrow_value_atomic
|
||||
FROM match_queue_entries
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND match_id = $2
|
||||
AND economy = 'crypto_race'
|
||||
AND status = 'matched'
|
||||
ORDER BY id ASC
|
||||
FOR UPDATE
|
||||
`, [queueEntryIds, room.id]);
|
||||
if (backingResult.rowCount !== queueEntryIds.length) {
|
||||
throw this._admissionError('queue_backing_mismatch');
|
||||
}
|
||||
|
||||
const backingById = new Map(backingResult.rows.map(row => [String(row.id), row]));
|
||||
let pot = 0n;
|
||||
const ticketBacking = [];
|
||||
for (const entrant of entrants) {
|
||||
const backing = backingById.get(String(entrant.queueEntryId));
|
||||
let value;
|
||||
try { value = BigInt(backing?.escrow_value_atomic); }
|
||||
catch (_) { throw this._admissionError('invalid_ticket_backing'); }
|
||||
if (!backing
|
||||
|| String(backing.user_id) !== String(entrant.userId)
|
||||
|| Number(backing.escrow_amount) !== 1
|
||||
|| backing.race_entry_lot_id == null
|
||||
|| value !== entryFee) {
|
||||
throw this._admissionError('invalid_ticket_backing');
|
||||
}
|
||||
pot += value;
|
||||
ticketBacking.push({
|
||||
queueEntryId: String(backing.id),
|
||||
raceEntryLotId: String(backing.race_entry_lot_id),
|
||||
valueAtomic: value.toString()
|
||||
});
|
||||
}
|
||||
const houseFee = (pot * BigInt(houseFeeBp)) / 10000n;
|
||||
const liabilityAmount = pot - houseFee;
|
||||
if (liabilityAmount <= 0n || liabilityAmount > policy.payoutCap) {
|
||||
throw this._admissionError('payout_cap_exceeded');
|
||||
}
|
||||
|
||||
const terms = {
|
||||
version: 2,
|
||||
model: 'winner-takes-funded-ticket-pool-minus-house-fee',
|
||||
funding: 'confirmed-payment-ticket-lots',
|
||||
rulesetId: room.ruleset?.id || 'race',
|
||||
winCondition: room.ruleset?.winCondition?.type || 'first-to-exit',
|
||||
playerCount: Number(count),
|
||||
ticketBacking,
|
||||
entryFeeAtomic: entryFee.toString(),
|
||||
potAtomic: pot.toString(),
|
||||
houseFeeAtomic: houseFee.toString(),
|
||||
houseFeeBasisPoints: houseFeeBp,
|
||||
payoutAmountAtomic: liabilityAmount.toString(),
|
||||
payoutCapAtomic: policy.payoutCap.toString(),
|
||||
cryptoType: String(room.cryptoType || this.env.CRYPTO_TYPE || 'WOW').toUpperCase(),
|
||||
network: String(this.env.MONERO_NETWORK || 'mainnet').toLowerCase()
|
||||
};
|
||||
|
||||
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, (
|
||||
SELECT race_entries FROM users WHERE id = $1
|
||||
), 'match_start', $2)
|
||||
`, [entrant.userId, room.id]);
|
||||
|
||||
const consumed = await client.query(`
|
||||
UPDATE match_entrants
|
||||
SET entry_consumed = TRUE
|
||||
WHERE match_id = $1 AND user_id = $2 AND entry_consumed = FALSE
|
||||
RETURNING id
|
||||
`, [room.id, entrant.userId]);
|
||||
if (consumed.rowCount !== 1) {
|
||||
throw this._admissionError('entrant_commit_mismatch');
|
||||
}
|
||||
|
||||
const queueConsumed = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET status = 'consumed', consumed_at = NOW()
|
||||
WHERE id = $1 AND match_id = $2 AND user_id = $3
|
||||
AND economy = 'crypto_race' AND status = 'matched'
|
||||
RETURNING id
|
||||
`, [entrant.queueEntryId, room.id, entrant.userId]);
|
||||
if (queueConsumed.rowCount !== 1) {
|
||||
throw this._admissionError('queue_commit_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
// Lock the true pot, fee, cap, and accepted liability in the SAME transaction as
|
||||
// ticket commitment. The migration makes this snapshot immutable after acceptance.
|
||||
const accepted = await client.query(`
|
||||
UPDATE matches
|
||||
SET entry_fee_atomic = $1,
|
||||
pot_atomic = $2,
|
||||
house_fee_atomic = $3,
|
||||
house_fee_percent = $4,
|
||||
payout_liability_amount_atomic = $5,
|
||||
payout_liability_cap_atomic = $6,
|
||||
payout_liability_terms = $7::jsonb,
|
||||
payout_liability_accepted_at = NOW()
|
||||
WHERE id = $8
|
||||
AND economy = 'crypto_race'
|
||||
AND status = 'starting'
|
||||
AND payout_liability_accepted_at IS NULL
|
||||
RETURNING id, payout_liability_accepted_at
|
||||
`, [
|
||||
entryFee.toString(),
|
||||
pot.toString(),
|
||||
houseFee.toString(),
|
||||
housePercent,
|
||||
liabilityAmount.toString(),
|
||||
policy.payoutCap.toString(),
|
||||
JSON.stringify(terms),
|
||||
room.id
|
||||
]);
|
||||
if (accepted.rowCount !== 1) {
|
||||
throw this._admissionError('liability_snapshot_failed');
|
||||
}
|
||||
return { pot, houseFee, liabilityAmount, terms };
|
||||
});
|
||||
|
||||
// Atomic amounts stay decimal strings in memory so persistence/debug snapshots never
|
||||
// round values above Number.MAX_SAFE_INTEGER. The authoritative values live in SQL.
|
||||
room.entryFeeAtomic = entryFee.toString();
|
||||
room.potAtomic = acceptedSnapshot.pot.toString();
|
||||
room.houseFeeAtomic = acceptedSnapshot.houseFee.toString();
|
||||
room.houseFeePercent = housePercent;
|
||||
room.payoutLiabilityAmountAtomic = acceptedSnapshot.liabilityAmount.toString();
|
||||
room.payoutLiabilityCapAtomic = policy.payoutCap.toString();
|
||||
room.payoutLiabilityTerms = acceptedSnapshot.terms;
|
||||
|
||||
this._log(`[MatchPayoutService] accepted liability ${acceptedSnapshot.liabilityAmount} from funded pool ${acceptedSnapshot.pot} (house fee ${acceptedSnapshot.houseFee}, ${housePercent}%) for match ${room.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ambiguous transaction acknowledgement. A database connection can fail while
|
||||
* COMMIT is being acknowledged; the caller must inspect the durable snapshot before deciding
|
||||
* to refund/abort, otherwise it could cancel a committed liability without returning tickets.
|
||||
*/
|
||||
async getAcceptedLiability(matchId) {
|
||||
if (!this.db || !matchId) return null;
|
||||
const result = await this.db.query(`
|
||||
SELECT entry_fee_atomic, pot_atomic, house_fee_atomic, house_fee_percent,
|
||||
payout_liability_amount_atomic, payout_liability_cap_atomic,
|
||||
payout_liability_terms, payout_liability_accepted_at
|
||||
FROM matches
|
||||
WHERE id = $1
|
||||
AND economy = 'crypto_race'
|
||||
AND status IN ('starting', 'active')
|
||||
AND payout_liability_accepted_at IS NOT NULL
|
||||
LIMIT 1
|
||||
`, [matchId]);
|
||||
const row = result.rows?.[0];
|
||||
if (!row) return null;
|
||||
try {
|
||||
const amount = BigInt(row.payout_liability_amount_atomic);
|
||||
const cap = BigInt(row.payout_liability_cap_atomic);
|
||||
const pot = BigInt(row.pot_atomic);
|
||||
const fee = BigInt(row.house_fee_atomic);
|
||||
if (amount <= 0n || cap <= 0n || amount > cap || pot <= 0n || fee < 0n || amount !== pot - fee) {
|
||||
return null;
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || room.economy !== 'crypto_race' || !room.winnerId) return;
|
||||
try {
|
||||
const result = await this.db.withTransaction(client => this._createPayoutForMatch(client, room.id));
|
||||
if (result.created && result.status === 'pending') {
|
||||
// Trigger the existing batch payout processor (5s debounce).
|
||||
if (this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
||||
this.gameModeManager._scheduleBatchPayout();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to queue match payout');
|
||||
this._log('[MatchPayoutService] payout error:', normalized.message);
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the payout promised by an accepted snapshot. No current admission flag is read:
|
||||
* MATCH_PAYOUTS_ENABLED can stop new matches, but cannot erase an existing liability.
|
||||
* The match's finished winner and immutable amount are authoritative, not in-memory values.
|
||||
*/
|
||||
async _createPayoutForMatch(client, matchId) {
|
||||
const matchResult = await client.query(`
|
||||
SELECT id, economy, status, winner_user_id,
|
||||
payout_liability_amount_atomic, payout_liability_cap_atomic,
|
||||
payout_liability_accepted_at
|
||||
FROM matches
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [matchId]);
|
||||
const match = matchResult.rows?.[0];
|
||||
if (!match || match.economy !== 'crypto_race' || match.status !== 'finished' || match.winner_user_id == null) {
|
||||
return { created: false, reason: 'match_not_settleable' };
|
||||
}
|
||||
// OFF -> ON must never create a retroactive liability: only the durable admission
|
||||
// snapshot, written while tickets were committed, authorizes a payout.
|
||||
if (!match.payout_liability_accepted_at) {
|
||||
return { created: false, reason: 'liability_not_accepted' };
|
||||
}
|
||||
|
||||
let amount, cap;
|
||||
try {
|
||||
amount = BigInt(match.payout_liability_amount_atomic);
|
||||
cap = BigInt(match.payout_liability_cap_atomic);
|
||||
} catch (_) {
|
||||
throw this._admissionError('invalid_liability_snapshot');
|
||||
}
|
||||
if (amount <= 0n || cap <= 0n || amount > cap) {
|
||||
throw this._admissionError('invalid_liability_snapshot');
|
||||
}
|
||||
|
||||
const existing = await client.query(`
|
||||
SELECT id, status FROM payouts WHERE match_id = $1 LIMIT 1
|
||||
`, [match.id]);
|
||||
if (existing.rows?.length) {
|
||||
return { created: false, reason: 'payout_exists', payoutId: existing.rows[0].id };
|
||||
}
|
||||
|
||||
const userResult = await client.query(`
|
||||
SELECT id, payout_address FROM users WHERE id = $1 LIMIT 1
|
||||
`, [match.winner_user_id]);
|
||||
const user = userResult.rows?.[0];
|
||||
if (!user) throw this._admissionError('winner_not_found');
|
||||
|
||||
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';
|
||||
const inserted = 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())
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`, [user.id, match.id, address, amount.toString(), 0, reason, status]);
|
||||
if (inserted.rowCount !== 1) {
|
||||
return { created: false, reason: 'payout_exists' };
|
||||
}
|
||||
|
||||
this._log(`[MatchPayoutService] recorded ${status} liability ${amount} for match ${match.id}`);
|
||||
return { created: true, status, amount: amount.toString(), payoutId: inserted.rows?.[0]?.id };
|
||||
}
|
||||
|
||||
/** Reconcile durable finished liabilities after a crash or transient payout-insert failure. */
|
||||
async reconcileFinishedLiabilities({ limit = 100 } = {}) {
|
||||
if (!this.db) return { ok: true, scanned: 0, created: 0, failed: 0, unresolved: [] };
|
||||
const candidates = await this.db.query(`
|
||||
SELECT m.id
|
||||
FROM matches m
|
||||
WHERE m.economy = 'crypto_race'
|
||||
AND m.status = 'finished'
|
||||
AND m.winner_user_id IS NOT NULL
|
||||
AND m.payout_liability_accepted_at IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id)
|
||||
ORDER BY m.ended_at ASC NULLS LAST, m.id ASC
|
||||
LIMIT $1
|
||||
`, [Math.max(1, Math.min(1000, parseInt(limit, 10) || 100))]);
|
||||
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
const unresolved = [];
|
||||
let pendingCreated = false;
|
||||
for (const candidate of candidates.rows || []) {
|
||||
try {
|
||||
const result = await this.db.withTransaction(client => this._createPayoutForMatch(client, candidate.id));
|
||||
if (result.created) {
|
||||
created += 1;
|
||||
if (result.status === 'pending') pendingCreated = true;
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
unresolved.push({ type: 'match_liability', id: candidate.id });
|
||||
this._log(`[MatchPayoutService] reconciliation failed for ${candidate.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
if (pendingCreated && this.gameModeManager && typeof this.gameModeManager._scheduleBatchPayout === 'function') {
|
||||
this.gameModeManager._scheduleBatchPayout();
|
||||
}
|
||||
return {
|
||||
ok: failed === 0,
|
||||
scanned: candidates.rows?.length || 0,
|
||||
created,
|
||||
failed,
|
||||
unresolved
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = MatchPayoutService;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,963 @@
|
||||
/**
|
||||
* MatchScheduler: block-cadence match maker.
|
||||
*
|
||||
* Subscribes to the same block event as the solo queue handler. Free matches may drain and start
|
||||
* immediately. Paid matches first persist an exact FIFO entrant freeze, then wait for their
|
||||
* configured future header and confirmation depth before deriving a seed and starting. Single
|
||||
* players carry over 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, 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 crypto = require('crypto');
|
||||
const { resolveMatchRuleset } = require('../game/rulesets');
|
||||
const { matchPayoutAdmissionPolicy, playerContract } = require('./matchEconomyPolicy');
|
||||
const {
|
||||
PAID_FREEZE_VERSION,
|
||||
PAID_SEED_VERSION,
|
||||
buildPaidEntrantFreeze,
|
||||
deriveFutureBlockMatchSeed
|
||||
} = require('./matchFairness');
|
||||
|
||||
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;
|
||||
const DEFAULT_PAID_ENTROPY_DELAY_BLOCKS = 2;
|
||||
const DEFAULT_PAID_ENTROPY_CONFIRMATIONS = 2;
|
||||
const BLOCK_SEED_VERSION = 'chain-block-v1';
|
||||
|
||||
function extractBlockHash(block) {
|
||||
const value = typeof block === 'string'
|
||||
? block
|
||||
: (block?.block_header?.hash || block?.blockHeader?.hash || block?.hash || '');
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function extractBlockHeight(block) {
|
||||
if (!block || typeof block === 'string') return null;
|
||||
const value = block?.block_header?.height ?? block?.blockHeader?.height ?? block?.height;
|
||||
const height = Number(value);
|
||||
return Number.isSafeInteger(height) && height >= 0 ? height : null;
|
||||
}
|
||||
|
||||
function exactCanonicalHeader(block, expectedHeight) {
|
||||
const hash = extractBlockHash(block);
|
||||
const height = extractBlockHeight(block);
|
||||
return hash && height === Number(expectedHeight) ? { hash, height } : null;
|
||||
}
|
||||
|
||||
function boundedBlockSetting(value, fallback, label) {
|
||||
if (value == null || String(value).trim() === '') return fallback;
|
||||
const raw = String(value).trim();
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`${label} must be a canonical integer`);
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) {
|
||||
throw new Error(`${label} must be an integer from 1 through 100`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function deriveBlockMatchSeed({ blockHash, blockHeight, economy, rulesetId, queueEntryIds }) {
|
||||
const normalizedHash = extractBlockHash(blockHash);
|
||||
if (!normalizedHash) throw new Error('A canonical 64-hex block hash is required');
|
||||
const ids = (queueEntryIds || []).map(id => String(id)).sort((left, right) => {
|
||||
if (left.length !== right.length) return left.length - right.length;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
if (ids.length < 2 || ids.some(id => !/^\d+$/.test(id))) {
|
||||
throw new Error('At least two durable numeric queue entry ids are required');
|
||||
}
|
||||
const canonical = [
|
||||
'wowngeon-match', BLOCK_SEED_VERSION, normalizedHash, String(blockHeight),
|
||||
String(economy), String(rulesetId), ids.join(',')
|
||||
].join('|');
|
||||
return {
|
||||
seed: crypto.createHash('sha256').update(canonical).digest('hex'),
|
||||
derivation: {
|
||||
version: BLOCK_SEED_VERSION,
|
||||
blockHash: normalizedHash,
|
||||
blockHeight: Number(blockHeight),
|
||||
economy: String(economy),
|
||||
rulesetId: String(rulesetId),
|
||||
queueEntryIds: ids
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class MatchScheduler {
|
||||
constructor({
|
||||
matchQueue,
|
||||
matchManager,
|
||||
debugManager,
|
||||
maxPlayers = null,
|
||||
tickMs = null,
|
||||
minDurationMs = null,
|
||||
hardCeilingMs = null,
|
||||
countdownMs = null,
|
||||
rulesetId = null,
|
||||
blockEntropyProvider = null,
|
||||
blockCountProvider = null,
|
||||
paidEntropyDelayBlocks = null,
|
||||
paidEntropyConfirmations = null
|
||||
} = {}) {
|
||||
this.matchQueue = matchQueue;
|
||||
this.matchManager = matchManager;
|
||||
this.debugManager = debugManager;
|
||||
this.enabled = process.env.MATCH_ENABLED === 'true';
|
||||
this.ruleset = resolveMatchRuleset(rulesetId || process.env.MATCH_RULESET_ID || 'race');
|
||||
|
||||
const requestedMax = maxPlayers ?? process.env.MATCH_MAX_PLAYERS ?? DEFAULT_MAX_PLAYERS;
|
||||
const players = playerContract(this.ruleset, requestedMax);
|
||||
this.minPlayers = players.minPlayers;
|
||||
this.maxPlayers = players.maxPlayers;
|
||||
this.tickMs = tickMs || parseInt(process.env.MATCH_TICK_MS, 10) || this.ruleset.timing.tickMs || DEFAULT_TICK_MS;
|
||||
this.minDurationMs = minDurationMs || parseInt(process.env.MATCH_MIN_DURATION_MS, 10) || this.ruleset.timing.minDurationMs || DEFAULT_MIN_DURATION_MS;
|
||||
this.hardCeilingMs = hardCeilingMs || parseInt(process.env.MATCH_HARD_CEILING_MS, 10) || this.ruleset.timing.hardCeilingMs || DEFAULT_HARD_CEILING_MS;
|
||||
this.countdownMs = countdownMs || parseInt(process.env.MATCH_COUNTDOWN_MS, 10) || DEFAULT_COUNTDOWN_MS;
|
||||
this.paidEntropyDelayBlocks = boundedBlockSetting(
|
||||
paidEntropyDelayBlocks ?? process.env.MATCH_PAID_ENTROPY_DELAY_BLOCKS,
|
||||
DEFAULT_PAID_ENTROPY_DELAY_BLOCKS,
|
||||
'MATCH_PAID_ENTROPY_DELAY_BLOCKS'
|
||||
);
|
||||
this.paidEntropyConfirmations = boundedBlockSetting(
|
||||
paidEntropyConfirmations ?? process.env.MATCH_PAID_ENTROPY_CONFIRMATIONS,
|
||||
DEFAULT_PAID_ENTROPY_CONFIRMATIONS,
|
||||
'MATCH_PAID_ENTROPY_CONFIRMATIONS'
|
||||
);
|
||||
this.blockEntropyProvider = blockEntropyProvider || (async headerHeight => {
|
||||
const rpc = this.debugManager?.rpcService;
|
||||
// Callers pass an actual zero-based canonical header height. DebugManager's external
|
||||
// event is a block count and is normalized exactly once in _processBlock().
|
||||
return typeof rpc?.getBlockByHeight === 'function'
|
||||
? rpc.getBlockByHeight(Number(headerHeight))
|
||||
: null;
|
||||
});
|
||||
this.blockCountProvider = blockCountProvider || (async () => {
|
||||
const rpc = this.debugManager?.rpcService;
|
||||
if (typeof rpc?.getBlockCountStrict === 'function') {
|
||||
return rpc.getBlockCountStrict();
|
||||
}
|
||||
if (typeof rpc?.makeRPCCall === 'function') {
|
||||
const result = await rpc.makeRPCCall('getblockcount');
|
||||
return result?.count;
|
||||
}
|
||||
throw new Error('Strict daemon block count is unavailable');
|
||||
});
|
||||
|
||||
// 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 = [];
|
||||
this._shuttingDown = false;
|
||||
this._lastProcessedBlockCount = null;
|
||||
// SocketHandlers intentionally does not await match scheduling on the solo block path.
|
||||
// Serialize blocks here so a later block can never race ahead of the durable freeze that
|
||||
// it is supposed to supply entropy for.
|
||||
this._blockTail = Promise.resolve();
|
||||
}
|
||||
|
||||
_log(...args) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
|
||||
}
|
||||
|
||||
async _strictDaemonBlockCount() {
|
||||
const response = await this.blockCountProvider();
|
||||
const count = Number(response?.count ?? response);
|
||||
if (!Number.isSafeInteger(count) || count < 1) {
|
||||
throw new Error('Strict daemon block count is invalid');
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async _strictDaemonTipHeight() {
|
||||
return (await this._strictDaemonBlockCount()) - 1;
|
||||
}
|
||||
|
||||
_minimumConfirmedTip(targetBlockHeight) {
|
||||
return Number(targetBlockHeight) + this.paidEntropyConfirmations - 1;
|
||||
}
|
||||
|
||||
async _readStableConfirmedHeader(targetBlockHeight) {
|
||||
const target = Number(targetBlockHeight);
|
||||
const minimumTip = this._minimumConfirmedTip(target);
|
||||
const firstTip = await this._strictDaemonTipHeight();
|
||||
if (firstTip < minimumTip) return { ready: false, reason: 'confirmations_pending' };
|
||||
|
||||
const first = exactCanonicalHeader(await this.blockEntropyProvider(target), target);
|
||||
if (!first) throw new Error(`Daemon did not return exact canonical header ${target}`);
|
||||
|
||||
const secondTip = await this._strictDaemonTipHeight();
|
||||
if (secondTip < minimumTip) return { ready: false, reason: 'confirmations_pending' };
|
||||
const second = exactCanonicalHeader(await this.blockEntropyProvider(target), target);
|
||||
if (!second) throw new Error(`Daemon did not re-confirm exact canonical header ${target}`);
|
||||
if (first.hash !== second.hash) {
|
||||
const error = new Error(`Canonical header ${target} changed during paid activation`);
|
||||
error.code = 'PAID_ENTROPY_REORG';
|
||||
throw error;
|
||||
}
|
||||
return { ready: true, hash: second.hash, height: target };
|
||||
}
|
||||
|
||||
async _recheckActivatedHeader(seedProof) {
|
||||
const target = Number(seedProof?.derivation?.targetBlockHeight
|
||||
?? seedProof?.derivation?.blockHeight);
|
||||
const expectedHash = String(seedProof?.derivation?.blockHash || '').toLowerCase();
|
||||
try {
|
||||
const tip = await this._strictDaemonTipHeight();
|
||||
if (tip < this._minimumConfirmedTip(target)) return false;
|
||||
const header = exactCanonicalHeader(await this.blockEntropyProvider(target), target);
|
||||
return Boolean(header && header.hash === expectedHash);
|
||||
} catch (err) {
|
||||
this._log(`[MatchScheduler] post-activation header ${target} unavailable`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async _cancelFrozen(frozen, reason = 'match_cancel') {
|
||||
if (!frozen?.id || typeof this.matchQueue?.cancelFrozenMatch !== 'function') {
|
||||
const error = new Error('Exact paid-freeze cancellation API is unavailable');
|
||||
error.code = 'PAID_FREEZE_CANCEL_UNRESOLVED';
|
||||
throw error;
|
||||
}
|
||||
const result = await this.matchQueue.cancelFrozenMatch(frozen.id, reason);
|
||||
if (!result?.resolved) {
|
||||
const error = new Error(`Paid freeze ${frozen.id} cancellation remains unresolved`);
|
||||
error.code = 'PAID_FREEZE_CANCEL_UNRESOLVED';
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook this into the existing DebugManager.onNewBlockCallback().
|
||||
* @param {number} blockCount Monero-family getblockcount count (tip header height + 1)
|
||||
*/
|
||||
onBlock(blockCount) {
|
||||
if (!this.enabled || !this.matchQueue || !this.matchManager || this._shuttingDown) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const scheduled = this._blockTail.then(() => this._processBlock(blockCount));
|
||||
this._blockTail = scheduled.catch(() => {});
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
async _processBlock(blockCount) {
|
||||
if (this._shuttingDown) return;
|
||||
const count = Number(blockCount);
|
||||
if (!Number.isSafeInteger(count) || count < 1) {
|
||||
throw new Error('Match scheduler requires a positive safe block count');
|
||||
}
|
||||
// getblockcount is the number of blocks, while get_block(height) is zero-based. Every
|
||||
// persisted/disclosed fairness height below is the actual header height whose hash is
|
||||
// requested, not the count emitted by DebugManager.
|
||||
const observedHeaderHeight = count - 1;
|
||||
|
||||
// A hot economy/payout disable must release both queued and already-frozen escrow.
|
||||
if (typeof this.matchQueue.refundUnavailableQueues === 'function') {
|
||||
await this.matchQueue.refundUnavailableQueues('match_cancel');
|
||||
}
|
||||
if (this._shuttingDown) return;
|
||||
|
||||
const advancingBlock = this._lastProcessedBlockCount == null
|
||||
|| count > this._lastProcessedBlockCount;
|
||||
if (advancingBlock) this._lastProcessedBlockCount = count;
|
||||
|
||||
// Existing active rooms see this header before any new room is attached. The manager
|
||||
// requires a strict height advance beyond each room's start header and its active-play
|
||||
// duration floor, so duplicate polls and same-header starts cannot end a match.
|
||||
if (advancingBlock && typeof this.matchManager.expireBlockDeadlines === 'function') {
|
||||
try {
|
||||
await this.matchManager.expireBlockDeadlines(observedHeaderHeight);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] block-deadline processing error', err.message);
|
||||
}
|
||||
}
|
||||
if (this._shuttingDown) return;
|
||||
|
||||
// Phase 1: commit every currently eligible paid entrant set without reading ANY block
|
||||
// hash. Each exact target is H+the configured delay, then a fresh strict daemon count is
|
||||
// durably recorded after commit. Both paid economies complete this guard before phase 2.
|
||||
let paidPreflightBlocked = false;
|
||||
if (advancingBlock) {
|
||||
for (const economy of ['credits_prestige', 'crypto_race']) {
|
||||
try {
|
||||
await this._freezePaidEconomy(economy, observedHeaderHeight);
|
||||
} catch (err) {
|
||||
paidPreflightBlocked = true;
|
||||
this._log(`[MatchScheduler] freeze ${economy} error`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._shuttingDown) return;
|
||||
|
||||
// Phase 2a validates/cancels every due paid freeze without reading a hash. This prevents a
|
||||
// verified freeze in one economy from revealing entropy before an unverified freeze in the
|
||||
// other economy has been refunded. Phase 2b alone performs exact target-header reads.
|
||||
if (typeof this.matchQueue.listFrozenPaidMatches === 'function') {
|
||||
let freezes = [];
|
||||
try {
|
||||
freezes = await this.matchQueue.listFrozenPaidMatches(observedHeaderHeight);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] frozen-match scan error', err.message);
|
||||
}
|
||||
const prepared = [];
|
||||
for (const freeze of freezes) {
|
||||
if (this._shuttingDown) return;
|
||||
try {
|
||||
const candidate = await this._prepareFrozenMatch(freeze, observedHeaderHeight);
|
||||
if (candidate) prepared.push(candidate);
|
||||
} catch (err) {
|
||||
paidPreflightBlocked = true;
|
||||
this._log(`[MatchScheduler] frozen match ${freeze?.id || 'unknown'} preflight retained`, err.message);
|
||||
}
|
||||
}
|
||||
for (const candidate of paidPreflightBlocked ? [] : prepared) {
|
||||
if (this._shuttingDown) return;
|
||||
try {
|
||||
await this._resolvePreparedFrozenMatch(candidate);
|
||||
} catch (err) {
|
||||
this._log(`[MatchScheduler] frozen match ${candidate?.frozen?.id || 'unknown'} retained`, err.message);
|
||||
if (err?.code === 'PAID_FREEZE_CANCEL_UNRESOLVED') break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this._shuttingDown) return;
|
||||
// Free matches preserve immediate scheduling. Drain first so even their current hash is
|
||||
// not learned before this block's paid FIFO sets have been frozen.
|
||||
if (advancingBlock) {
|
||||
try {
|
||||
await this._drainEconomy('free', observedHeaderHeight);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] drain free error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _freezePaidEconomy(economy, blockHeight) {
|
||||
if (this.matchQueue?.initialized === false || this._shuttingDown) return null;
|
||||
if (typeof this.matchQueue?.isFinancialRecoveryReady === 'function'
|
||||
&& this.matchQueue.isFinancialRecoveryReady() !== true) return null;
|
||||
if (typeof this.matchQueue.freezePaidMatch !== 'function') {
|
||||
this._log(`[MatchScheduler] retaining ${economy}: durable freeze API unavailable`);
|
||||
return null;
|
||||
}
|
||||
if (economy === 'crypto_race') {
|
||||
const policy = matchPayoutAdmissionPolicy({
|
||||
env: process.env,
|
||||
gameModeManager: this.matchManager?.gameModeManager,
|
||||
ruleset: this.ruleset,
|
||||
requestedMaxPlayers: this.maxPlayers
|
||||
});
|
||||
if (!policy.enabled) return null;
|
||||
}
|
||||
const frozen = await this.matchQueue.freezePaidMatch({
|
||||
economy,
|
||||
maxPlayers: this.maxPlayers,
|
||||
minPlayers: this.minPlayers,
|
||||
freezeBlockHeight: blockHeight,
|
||||
targetBlockHeight: blockHeight + this.paidEntropyDelayBlocks,
|
||||
rulesetId: this.ruleset.id,
|
||||
variant: this.ruleset.entities.pvpCombat ? 'pvp' : 'race',
|
||||
difficultyPreset: this.ruleset.world.difficultyPreset || 'race'
|
||||
});
|
||||
if (!frozen) return null;
|
||||
|
||||
// The count must be fetched only after freezePaidMatch's transaction is durably accepted.
|
||||
// A delayed COMMIT that crosses the target can therefore never be disguised as a valid
|
||||
// precommit; refund it without consulting that target's hash.
|
||||
let tip;
|
||||
try {
|
||||
tip = await this._strictDaemonTipHeight();
|
||||
} catch (err) {
|
||||
this._log(`[MatchScheduler] paid freeze ${frozen.id} precommit count unavailable`, err.message);
|
||||
await this._cancelFrozen(frozen);
|
||||
return null; // a crash before this cleanup is caught by startup recovery
|
||||
}
|
||||
if (tip < Number(frozen.freeze?.freezeBlockHeight)
|
||||
|| tip >= Number(frozen.targetBlockHeight)) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (typeof this.matchQueue.verifyFrozenPrecommit !== 'function') {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
let proof;
|
||||
try {
|
||||
proof = await this.matchQueue.verifyFrozenPrecommit(
|
||||
frozen.id,
|
||||
frozen.freezeCommitment,
|
||||
tip
|
||||
);
|
||||
} catch (err) {
|
||||
this._log(`[MatchScheduler] paid freeze ${frozen.id} precommit write unavailable`, err.message);
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (!proof?.verified) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
...frozen,
|
||||
precommitTipHeight: proof.observedTipHeight,
|
||||
precommitVerifiedAt: proof.verifiedAt
|
||||
});
|
||||
}
|
||||
|
||||
async _prepareFrozenMatch(frozen, observedBlockHeight) {
|
||||
if (typeof this.matchQueue?.isFinancialRecoveryReady === 'function'
|
||||
&& this.matchQueue.isFinancialRecoveryReady() !== true) return null;
|
||||
if (!frozen || !frozen.freeze) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (frozen.freeze.version !== PAID_FREEZE_VERSION) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (typeof this.matchQueue.isEconomyAvailable === 'function'
|
||||
&& !this.matchQueue.isEconomyAvailable(frozen.economy)) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (frozen.economy === 'crypto_race') {
|
||||
const policy = matchPayoutAdmissionPolicy({
|
||||
env: process.env,
|
||||
gameModeManager: this.matchManager?.gameModeManager,
|
||||
ruleset: this.ruleset,
|
||||
requestedMaxPlayers: this.maxPlayers
|
||||
});
|
||||
if (!policy.enabled) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let verifiedFreeze;
|
||||
try {
|
||||
verifiedFreeze = buildPaidEntrantFreeze(frozen.freeze);
|
||||
} catch (err) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (String(frozen.freezeCommitment || '') !== verifiedFreeze.freezeCommitment) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (verifiedFreeze.targetBlockHeight > observedBlockHeight) return null;
|
||||
if (verifiedFreeze.economy !== frozen.economy) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
if (verifiedFreeze.rulesetId !== this.ruleset.id) {
|
||||
// A legitimate operator ruleset change must not reinterpret an old paid freeze.
|
||||
// Its exact queue anchors are safely refundable because activation has not claimed it.
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
const precommitTip = Number(frozen.precommitTipHeight);
|
||||
const verifiedAt = new Date(frozen.precommitVerifiedAt).getTime();
|
||||
const minimumDelay = String(process.env.NODE_ENV || '').toLowerCase() === 'production' ? 2 : 1;
|
||||
if (frozen.precommitTipHeight == null
|
||||
|| !Number.isSafeInteger(precommitTip)
|
||||
|| precommitTip < verifiedFreeze.freezeBlockHeight
|
||||
|| precommitTip >= verifiedFreeze.targetBlockHeight
|
||||
|| !Number.isFinite(verifiedAt)
|
||||
|| verifiedFreeze.entropyDelayBlocks < minimumDelay) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
let exactEntrants;
|
||||
try {
|
||||
exactEntrants = buildPaidEntrantFreeze({
|
||||
...verifiedFreeze,
|
||||
queueEntryIds: (frozen.entries || []).map(entry => entry.queueEntryId)
|
||||
});
|
||||
} catch (_) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
const frozenEntries = frozen.entries || [];
|
||||
if (exactEntrants.freezeCommitment !== verifiedFreeze.freezeCommitment
|
||||
|| new Set(frozenEntries.map(entry => String(entry.userId))).size !== frozenEntries.length
|
||||
|| frozenEntries.some(entry => !String(entry.socketId || ''))
|
||||
|| new Set(frozenEntries.map(entry => String(entry.socketId))).size !== frozenEntries.length) {
|
||||
await this._cancelFrozen(frozen);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tip = await this._strictDaemonTipHeight();
|
||||
if (tip < this._minimumConfirmedTip(verifiedFreeze.targetBlockHeight)) {
|
||||
return null;
|
||||
}
|
||||
return { frozen, verifiedFreeze };
|
||||
}
|
||||
|
||||
async _resolvePreparedFrozenMatch({ frozen, verifiedFreeze }) {
|
||||
let canonical;
|
||||
try {
|
||||
canonical = await this._readStableConfirmedHeader(verifiedFreeze.targetBlockHeight);
|
||||
} catch (err) {
|
||||
if (err?.code === 'PAID_ENTROPY_REORG') await this._cancelFrozen(frozen);
|
||||
else this._log(`[MatchScheduler] future block ${verifiedFreeze.targetBlockHeight} unavailable`, err.message);
|
||||
return;
|
||||
}
|
||||
if (!canonical?.ready) return;
|
||||
if (this._shuttingDown) return;
|
||||
const derived = deriveFutureBlockMatchSeed({
|
||||
blockHash: canonical.hash,
|
||||
blockHeight: verifiedFreeze.targetBlockHeight,
|
||||
freeze: verifiedFreeze
|
||||
});
|
||||
// Confirmation depth is activation-safety metadata, not additional seed material. Persist
|
||||
// it with the proof so an auditor can evaluate the wait contract after configuration has
|
||||
// changed, together with the durable post-commit daemon-tip witness.
|
||||
const seedProof = Object.freeze({
|
||||
seed: derived.seed,
|
||||
derivation: Object.freeze({
|
||||
...derived.derivation,
|
||||
entropyConfirmations: this.paidEntropyConfirmations,
|
||||
minimumConfirmedTipHeight: this._minimumConfirmedTip(
|
||||
verifiedFreeze.targetBlockHeight
|
||||
),
|
||||
precommitTipHeight: Number(frozen.precommitTipHeight),
|
||||
precommitVerifiedAt: new Date(frozen.precommitVerifiedAt).toISOString()
|
||||
})
|
||||
});
|
||||
return this._startEntries({
|
||||
entries: frozen.entries,
|
||||
economy: frozen.economy,
|
||||
blockHeight: verifiedFreeze.targetBlockHeight,
|
||||
seedProof,
|
||||
frozen
|
||||
});
|
||||
}
|
||||
|
||||
async _resolveFrozenMatch(frozen, observedBlockHeight) {
|
||||
const prepared = await this._prepareFrozenMatch(frozen, observedBlockHeight);
|
||||
if (prepared) return this._resolvePreparedFrozenMatch(prepared);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async _drainEconomy(economy, blockHeight, blockHash = null) {
|
||||
// Debug/block polling starts before database recovery completes. The concrete MatchQueue
|
||||
// exposes an explicit readiness bit; injected test/legacy queues without it keep working.
|
||||
if (this.matchQueue?.initialized === false) return;
|
||||
// Paid economies can only enter through the durable future-block freeze path above.
|
||||
if (economy !== 'free') return;
|
||||
|
||||
const drain = await this.matchQueue.drain(economy, this.maxPlayers, this.minPlayers);
|
||||
if (!drain) return;
|
||||
const { entries } = drain;
|
||||
if (!extractBlockHash(blockHash)) {
|
||||
try {
|
||||
blockHash = extractBlockHash(await this.blockEntropyProvider(blockHeight));
|
||||
} catch (err) {
|
||||
this._log(`[MatchScheduler] free block entropy unavailable at ${blockHeight}`, err.message);
|
||||
}
|
||||
}
|
||||
const seedProof = extractBlockHash(blockHash) ? deriveBlockMatchSeed({
|
||||
blockHash,
|
||||
blockHeight,
|
||||
economy,
|
||||
rulesetId: this.ruleset.id,
|
||||
queueEntryIds: entries.map(entry => entry.queueEntryId)
|
||||
}) : null;
|
||||
return this._startEntries({ entries, economy, blockHeight, seedProof, frozen: null });
|
||||
}
|
||||
|
||||
async _startEntries({ entries, economy, blockHeight, seedProof = null, frozen = null }) {
|
||||
if (this._shuttingDown) return;
|
||||
if (!entries || entries.length < this.minPlayers || entries.length > this.maxPlayers) {
|
||||
// Defensive: drain() already marked these 'matched'; refund them when the actual
|
||||
// entrant count violates the selected ruleset's player contract.
|
||||
if (entries && entries.length) {
|
||||
if (frozen && typeof this.matchQueue.cancelFrozenMatch === 'function') {
|
||||
await this.matchQueue.cancelFrozenMatch(frozen.id, 'match_cancel');
|
||||
} else {
|
||||
await this._refund(entries, economy, 'match_cancel');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (economy !== 'free' && seedProof?.derivation?.version !== PAID_SEED_VERSION) {
|
||||
// No environment exception and no server-random fallback for value-bearing matches.
|
||||
this._log(`[MatchScheduler] retaining frozen ${economy} entrants: future-block proof missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log(`[MatchScheduler] Starting ${economy} race with ${entries.length} players at block ${blockHeight}`);
|
||||
|
||||
// Build entrants map for MatchRoom.
|
||||
const entrants = {};
|
||||
for (const e of entries) {
|
||||
entrants[e.socketId] = {
|
||||
userId: e.userId,
|
||||
name: null, // resolved later by MatchManager
|
||||
socketId: e.socketId,
|
||||
sessionToken: e.sessionToken
|
||||
};
|
||||
}
|
||||
|
||||
const room = new MatchRoom({
|
||||
id: frozen?.id,
|
||||
ruleset: this.ruleset,
|
||||
economy,
|
||||
maxPlayers: this.maxPlayers,
|
||||
entrants,
|
||||
seed: seedProof?.seed,
|
||||
startBlockHeight: blockHeight,
|
||||
cryptoType: process.env.CRYPTO_TYPE || 'WOW'
|
||||
});
|
||||
room.seedDerivation = seedProof?.derivation || {
|
||||
version: 'server-random-v1',
|
||||
blockHeight: Number(blockHeight),
|
||||
economy: String(economy),
|
||||
rulesetId: this.ruleset.id,
|
||||
queueEntryIds: entries.map(entry => String(entry.queueEntryId)).sort()
|
||||
};
|
||||
room.minDurationMs = this.minDurationMs;
|
||||
room.hardCeilingMs = this.hardCeilingMs;
|
||||
|
||||
// Paid rooms claim an already-durable pending freeze. Free rooms keep the historical
|
||||
// create-and-link path. Never refund an ambiguous paid activation: prove it committed or
|
||||
// leave the row for crash recovery.
|
||||
if (frozen) {
|
||||
try {
|
||||
await this._activateFrozenMatch(room, entries, frozen);
|
||||
} catch (err) {
|
||||
const accepted = await this._frozenActivationAccepted(room, frozen).catch(() => false);
|
||||
if (!accepted) {
|
||||
this._log(`[MatchScheduler] frozen activation ${room.id} unproven; retaining for recovery`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The activation transaction changes the row to `starting` but leaves every queue
|
||||
// escrow anchor untouched. Re-read the exact target now, before ticket collection,
|
||||
// engine creation, or client notification. A reorg/unavailable daemon therefore
|
||||
// takes the normal exact-anchor abort/refund path with no gameplay side effects.
|
||||
if (!await this._recheckActivatedHeader(seedProof)) {
|
||||
this._log(`[MatchScheduler] canonical target changed after activation of ${room.id}; aborting + refunding`);
|
||||
await this._abortMatch(room, entries, economy);
|
||||
return;
|
||||
}
|
||||
if (typeof this.matchQueue.noteFrozenStarted === 'function') {
|
||||
this.matchQueue.noteFrozenStarted(frozen.id);
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// COMMIT acknowledgements can fail after PostgreSQL durably accepted the whole
|
||||
// ticket/liability transaction. Inspect that immutable snapshot before aborting;
|
||||
// cancelling a committed row while leaving consumed tickets would strand value.
|
||||
const accepted = (payoutService && typeof payoutService.getAcceptedLiability === 'function')
|
||||
? await payoutService.getAcceptedLiability(room.id).catch(() => null)
|
||||
: null;
|
||||
if (accepted) {
|
||||
room.entryFeeAtomic = String(accepted.entry_fee_atomic);
|
||||
room.potAtomic = String(accepted.pot_atomic);
|
||||
room.houseFeeAtomic = String(accepted.house_fee_atomic);
|
||||
room.houseFeePercent = Number(accepted.house_fee_percent);
|
||||
room.payoutLiabilityAmountAtomic = String(accepted.payout_liability_amount_atomic);
|
||||
room.payoutLiabilityCapAtomic = String(accepted.payout_liability_cap_atomic);
|
||||
room.payoutLiabilityTerms = accepted.payout_liability_terms;
|
||||
this._log(`[MatchScheduler] collection acknowledgement failed but liability ${room.id} is durable; continuing`);
|
||||
} else {
|
||||
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,
|
||||
onTick: (result) => this.matchManager.onTick(room.id, result),
|
||||
onFinish: (finishedRoom) => this.matchManager.onFinish(finishedRoom)
|
||||
});
|
||||
this.matchManager.setEngine(room.id, engine);
|
||||
|
||||
// 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 { attempted: entries?.length || 0, resolved: 0, failed: entries?.length || 0 };
|
||||
try {
|
||||
if (typeof this.matchQueue.refundEntries === 'function') {
|
||||
return await this.matchQueue.refundEntries(entries, economy, reason);
|
||||
}
|
||||
let resolved = 0;
|
||||
for (const e of entries) {
|
||||
const result = await this.matchQueue.leave(e.userId, economy).catch(() => null);
|
||||
if (result?.success) resolved += 1;
|
||||
}
|
||||
return { attempted: entries.length, resolved, failed: entries.length - resolved };
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] refund error', err.message);
|
||||
return { attempted: entries?.length || 0, resolved: 0, failed: entries?.length || 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a match whose pot could not be collected: mark it cancelled and refund everyone.
|
||||
*/
|
||||
async _abortMatch(room, entries, economy) {
|
||||
// Refund first. Each refund is anchored to its exact queue row and marks the entrant in
|
||||
// the same transaction. If the process dies before the match status update, boot recovery
|
||||
// sees those anchors already resolved and cannot double-refund them.
|
||||
const refundResult = await this._refund(entries, economy, 'match_cancel');
|
||||
if (refundResult?.failed > 0) {
|
||||
this._log(`[MatchScheduler] leaving match ${room.id} recoverable after ${refundResult.failed} refund failure(s)`);
|
||||
return;
|
||||
}
|
||||
if (this.matchManager?.db) {
|
||||
try {
|
||||
await this.matchManager.db.query(
|
||||
`UPDATE matches
|
||||
SET status = 'cancelled', ended_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = 'starting'
|
||||
AND payout_liability_accepted_at IS NULL`,
|
||||
[room.id]
|
||||
);
|
||||
} catch (err) {
|
||||
this._log('[MatchScheduler] abort mark-cancelled error', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _persistMatch(room, entries, economy) {
|
||||
if (!this.matchManager?.db) return;
|
||||
|
||||
const queueEntryIds = entries.map(entry => entry?.queueEntryId);
|
||||
if (queueEntryIds.some(id => id == null)
|
||||
|| new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) {
|
||||
throw new Error('Cannot persist match without unique durable queue entry ids');
|
||||
}
|
||||
|
||||
const matchRow = MatchState.toMatchRow(room);
|
||||
// Override row defaults with the actual queued economy and status.
|
||||
matchRow.status = 'starting';
|
||||
matchRow.economy = economy;
|
||||
|
||||
await this.matchManager.db.withTransaction(async (client) => {
|
||||
await client.query(`
|
||||
INSERT INTO matches (
|
||||
id, status, economy, variant, ruleset_id, difficulty_preset, max_players,
|
||||
seed_hash, dungeon, start_block_height, entry_fee_atomic,
|
||||
pot_atomic, house_fee_atomic, house_fee_percent, created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11,
|
||||
$12, $13, $14, NOW()
|
||||
)
|
||||
`, [
|
||||
matchRow.id,
|
||||
matchRow.status,
|
||||
matchRow.economy,
|
||||
matchRow.variant,
|
||||
matchRow.ruleset_id,
|
||||
matchRow.difficulty_preset,
|
||||
matchRow.max_players,
|
||||
matchRow.seed_hash,
|
||||
JSON.stringify(matchRow.dungeon),
|
||||
matchRow.start_block_height,
|
||||
matchRow.entry_fee_atomic,
|
||||
matchRow.pot_atomic,
|
||||
matchRow.house_fee_atomic,
|
||||
matchRow.house_fee_percent
|
||||
]);
|
||||
|
||||
const linked = await client.query(`
|
||||
UPDATE match_queue_entries
|
||||
SET match_id = $1
|
||||
WHERE id = ANY($2::bigint[])
|
||||
AND economy = $3
|
||||
AND status = 'matched'
|
||||
AND match_id IS NULL
|
||||
RETURNING id, user_id
|
||||
`, [room.id, queueEntryIds, economy]);
|
||||
const expectedByQueueId = new Map(entries.map(entry => [
|
||||
String(entry.queueEntryId),
|
||||
String(entry.userId)
|
||||
]));
|
||||
const linksMatch = linked.rowCount === queueEntryIds.length
|
||||
&& (linked.rows || []).every(row => (
|
||||
expectedByQueueId.get(String(row.id)) === String(row.user_id)
|
||||
));
|
||||
if (!linksMatch) {
|
||||
throw new Error(`Queue-link mismatch for match ${room.id}: expected ${queueEntryIds.length}, got ${linked.rowCount}`);
|
||||
}
|
||||
|
||||
for (const entrant of entries) {
|
||||
await client.query(`
|
||||
INSERT INTO match_entrants (match_id, user_id, socket_id, queue_entry_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, [room.id, entrant.userId, entrant.socketId, entrant.queueEntryId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _activateFrozenMatch(room, entries, frozen) {
|
||||
if (!this.matchManager?.db) return;
|
||||
const queueEntryIds = entries.map(entry => entry?.queueEntryId);
|
||||
if (queueEntryIds.some(id => id == null)
|
||||
|| new Set(queueEntryIds.map(String)).size !== queueEntryIds.length) {
|
||||
throw new Error('Cannot activate a freeze without unique durable queue entry ids');
|
||||
}
|
||||
const matchRow = MatchState.toMatchRow(room);
|
||||
await this.matchManager.db.withTransaction(async (client) => {
|
||||
// This status claim serializes against MatchQueue.cancelFrozenMatch(). Whichever
|
||||
// transaction wins owns every linked escrow anchor; the loser must not touch them.
|
||||
const activated = await client.query(`
|
||||
UPDATE matches
|
||||
SET status = 'starting',
|
||||
variant = $1,
|
||||
ruleset_id = $2,
|
||||
difficulty_preset = $3,
|
||||
max_players = $4,
|
||||
seed_hash = $5,
|
||||
dungeon = $6::jsonb,
|
||||
start_block_height = $7
|
||||
WHERE id = $8
|
||||
AND status = 'pending'
|
||||
AND economy = $9
|
||||
AND start_block_height = $7
|
||||
AND seed_hash = $10
|
||||
AND entropy_precommit_tip_height IS NOT NULL
|
||||
AND entropy_precommit_verified_at IS NOT NULL
|
||||
AND entropy_precommit_tip_height < start_block_height
|
||||
RETURNING id
|
||||
`, [
|
||||
matchRow.variant,
|
||||
matchRow.ruleset_id,
|
||||
matchRow.difficulty_preset,
|
||||
matchRow.max_players,
|
||||
matchRow.seed_hash,
|
||||
JSON.stringify(matchRow.dungeon),
|
||||
matchRow.start_block_height,
|
||||
room.id,
|
||||
room.economy,
|
||||
frozen.freezeCommitment
|
||||
]);
|
||||
if (activated.rowCount !== 1) {
|
||||
throw new Error(`Pending paid freeze ${room.id} was not claimable`);
|
||||
}
|
||||
|
||||
const expectedByQueueId = new Map(entries.map(entry => [
|
||||
String(entry.queueEntryId), String(entry.userId)
|
||||
]));
|
||||
const linked = await client.query(`
|
||||
SELECT id, user_id
|
||||
FROM match_queue_entries
|
||||
WHERE match_id = $1
|
||||
AND economy = $2
|
||||
AND status = 'matched'
|
||||
ORDER BY id ASC
|
||||
FOR UPDATE
|
||||
`, [room.id, room.economy]);
|
||||
const linksMatch = linked.rowCount === queueEntryIds.length
|
||||
&& (linked.rows || []).every(row => (
|
||||
expectedByQueueId.get(String(row.id)) === String(row.user_id)
|
||||
));
|
||||
if (!linksMatch) throw new Error(`Frozen queue-link mismatch for match ${room.id}`);
|
||||
|
||||
const entrantRows = await client.query(`
|
||||
SELECT user_id, socket_id, queue_entry_id
|
||||
FROM match_entrants
|
||||
WHERE match_id = $1
|
||||
ORDER BY queue_entry_id ASC
|
||||
FOR UPDATE
|
||||
`, [room.id]);
|
||||
const entrantLinksMatch = entrantRows.rowCount === entries.length
|
||||
&& (entrantRows.rows || []).every(row => (
|
||||
expectedByQueueId.get(String(row.queue_entry_id)) === String(row.user_id)
|
||||
));
|
||||
if (!entrantLinksMatch) throw new Error(`Frozen entrant mismatch for match ${room.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
async _frozenActivationAccepted(room, frozen) {
|
||||
if (!this.matchManager?.db) return true;
|
||||
const result = await this.matchManager.db.query(`
|
||||
SELECT status, economy, seed_hash, start_block_height, dungeon,
|
||||
entropy_precommit_tip_height, entropy_precommit_verified_at
|
||||
FROM matches
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`, [room.id]);
|
||||
const row = result.rows?.[0];
|
||||
let dungeon = row?.dungeon;
|
||||
if (typeof dungeon === 'string') {
|
||||
try { dungeon = JSON.parse(dungeon); } catch (_) { return false; }
|
||||
}
|
||||
const proof = dungeon?.match_fairness;
|
||||
return row?.status === 'starting'
|
||||
&& row.economy === room.economy
|
||||
&& String(row.seed_hash) === room.seedHash
|
||||
&& Number(row.start_block_height) === Number(room.startBlockHeight)
|
||||
&& row.entropy_precommit_tip_height != null
|
||||
&& Number(row.entropy_precommit_tip_height) < Number(row.start_block_height)
|
||||
&& row.entropy_precommit_verified_at != null
|
||||
&& proof?.version === PAID_SEED_VERSION
|
||||
&& proof?.freezeCommitment === frozen.freezeCommitment;
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this._shuttingDown = true;
|
||||
await this._blockTail.catch(() => {});
|
||||
for (const id of this._timeoutIds) clearTimeout(id);
|
||||
this._timeoutIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MatchScheduler;
|
||||
module.exports.BLOCK_SEED_VERSION = BLOCK_SEED_VERSION;
|
||||
module.exports.deriveBlockMatchSeed = deriveBlockMatchSeed;
|
||||
module.exports.deriveFutureBlockMatchSeed = deriveFutureBlockMatchSeed;
|
||||
module.exports.extractBlockHash = extractBlockHash;
|
||||
@@ -0,0 +1,964 @@
|
||||
/**
|
||||
* Payment Handlers
|
||||
* Encapsulates payment request creation, monitoring, mempool detection,
|
||||
* confirmation handling, and queue integration.
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
const money = require('../money/atomic');
|
||||
const {
|
||||
buildCommerceDisclosure,
|
||||
validatePaidAcknowledgement
|
||||
} = require('../config/commerceDisclosurePolicy');
|
||||
|
||||
const PENDING_COMMERCE_ACK_TTL_MS = 5 * 60 * 1000;
|
||||
const PENDING_COMMERCE_ACK_MAX = 256;
|
||||
|
||||
class PaymentHandlers {
|
||||
constructor({ io, gameModeManager, walletService, debugManager, queueManager, broadcastManager, sessionManager }) {
|
||||
this.io = io;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.walletService = walletService;
|
||||
this.debugManager = debugManager;
|
||||
this.queueManager = queueManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.sessionManager = sessionManager;
|
||||
// Address -> timestamp of the first mempool notification, so notification and queueing
|
||||
// happen once per payment address. A Map, not a Set, so the sweep below can evict by TTL
|
||||
// and the collection stays bounded.
|
||||
this.mempoolNotified = new Map();
|
||||
this._mempoolNotifiedTtlMs = 60 * 60 * 1000; // 1 hour
|
||||
// Payment ids already warned about as underpaid: warn once, keep monitoring for a top-up.
|
||||
this.underpaidNotified = new Set();
|
||||
this.paymentMonitors = new Map();
|
||||
// Pending payment metadata per socket; a reused request replaces the entry in place.
|
||||
this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt }
|
||||
// A single-game fairness offer may arrive before payout-address confirmation. Preserve
|
||||
// the already-consumed proof across that prompt; never fall back to generating a layout
|
||||
// from an unpublished server seed when payment later confirms.
|
||||
this.pendingEntryFairness = new Map();
|
||||
// Retain a canonical acknowledgement across the payout-address prompt. Entries are tiny,
|
||||
// bounded, expire quickly, and are cleared on disconnect; raw client objects never enter
|
||||
// this map.
|
||||
this.pendingCommerceAcknowledgement = new Map();
|
||||
// Payment ids already confirmed, so duplicate emits and queue actions are suppressed.
|
||||
this.confirmedPayments = new Set();
|
||||
// Confirmation times, so the sweep can drop ids past the retention window.
|
||||
this._confirmedTimestamps = new Map();
|
||||
this._confirmedRetentionMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||
// Expiry timeouts, retained so they can be cleared on shutdown.
|
||||
this._expiryTimeouts = new Map(); // socketId -> timeout
|
||||
this._isShuttingDown = false;
|
||||
this._statusUpdatesInFlight = new Set();
|
||||
this._invoiceCreationsInFlight = new Set();
|
||||
// Retained so dispose() can clear it.
|
||||
this._cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [pid, ts] of this._confirmedTimestamps.entries()) {
|
||||
if (now - ts > this._confirmedRetentionMs) {
|
||||
this._confirmedTimestamps.delete(pid);
|
||||
this.confirmedPayments.delete(pid);
|
||||
}
|
||||
}
|
||||
// TTL-evict stale mempool-notified addresses.
|
||||
for (const [addr, ts] of this.mempoolNotified.entries()) {
|
||||
if (now - ts > this._mempoolNotifiedTtlMs) {
|
||||
this.mempoolNotified.delete(addr);
|
||||
}
|
||||
}
|
||||
this._sweepPendingCommerceAcknowledgements(now);
|
||||
}, 30 * 60 * 1000); // sweep every 30 min
|
||||
if (this._cleanupInterval && this._cleanupInterval.unref) {
|
||||
// Unref'd so the interval alone never keeps the event loop alive.
|
||||
this._cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
_entryModeForPaymentType(paymentType) {
|
||||
if (paymentType === 'single_game') return 'PAID_SINGLE';
|
||||
if (paymentType === 'credits_package') return 'PAID_CREDITS';
|
||||
return null;
|
||||
}
|
||||
|
||||
_requiresBoundPaidFairness() {
|
||||
return typeof this.gameModeManager?._requiresPaidFairnessV2 === 'function'
|
||||
&& this.gameModeManager._requiresPaidFairnessV2();
|
||||
}
|
||||
|
||||
_invoiceCanLeadDirectlyToPayout(paymentType, { preselection = false } = {}) {
|
||||
const type = String(paymentType || '').toLowerCase();
|
||||
if (type === 'single_game' || type === 'pay_direct') {
|
||||
return !!this.gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE');
|
||||
}
|
||||
if (type === 'use_credit' || type === 'paid_credits') {
|
||||
return !!this.gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS');
|
||||
}
|
||||
if (preselection && !type) {
|
||||
return !!(this.gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE')
|
||||
|| this.gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS'));
|
||||
}
|
||||
// Buying credits/products creates no immediate payout promise. The authoritative reserve
|
||||
// gate runs later when a credit is actually consumed for a payout-bearing game.
|
||||
return false;
|
||||
}
|
||||
|
||||
_sweepPendingCommerceAcknowledgements(now = Date.now()) {
|
||||
for (const [socketId, entry] of this.pendingCommerceAcknowledgement.entries()) {
|
||||
if (!entry || entry.expiresAt <= now) this.pendingCommerceAcknowledgement.delete(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
_rememberPendingCommerceAcknowledgement(socketId, acknowledgement) {
|
||||
if (!socketId || !acknowledgement) return;
|
||||
this._sweepPendingCommerceAcknowledgements();
|
||||
this.pendingCommerceAcknowledgement.delete(socketId);
|
||||
while (this.pendingCommerceAcknowledgement.size >= PENDING_COMMERCE_ACK_MAX) {
|
||||
const oldest = this.pendingCommerceAcknowledgement.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.pendingCommerceAcknowledgement.delete(oldest);
|
||||
}
|
||||
// Clone only the bounded canonical contract. Never retain a caller-owned object or any
|
||||
// incidental socket payload fields in this short-lived continuation cache.
|
||||
const canonicalAcknowledgement = Object.freeze({
|
||||
policyVersion: String(acknowledgement.policyVersion),
|
||||
ageEligible: acknowledgement.ageEligible === true,
|
||||
termsRead: acknowledgement.termsRead === true,
|
||||
riskAccepted: acknowledgement.riskAccepted === true,
|
||||
testnetUnderstood: acknowledgement.testnetUnderstood === true
|
||||
});
|
||||
this.pendingCommerceAcknowledgement.set(socketId, {
|
||||
acknowledgement: canonicalAcknowledgement,
|
||||
expiresAt: Date.now() + PENDING_COMMERCE_ACK_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
_getPendingCommerceAcknowledgement(socketId) {
|
||||
const entry = this.pendingCommerceAcknowledgement.get(socketId);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.pendingCommerceAcknowledgement.delete(socketId);
|
||||
return null;
|
||||
}
|
||||
return entry.acknowledgement;
|
||||
}
|
||||
|
||||
clearPendingCommerceAcknowledgement(socketId) {
|
||||
this.pendingCommerceAcknowledgement.delete(socketId);
|
||||
}
|
||||
|
||||
_requirePaidAcknowledgement(socket, acknowledgement) {
|
||||
const disclosure = buildCommerceDisclosure(this.gameModeManager, process.env);
|
||||
// This method is reached only for value-bearing actions. Force the action-level gate even
|
||||
// when payment intake is paused and a user is spending an existing credit/ticket.
|
||||
const result = validatePaidAcknowledgement(acknowledgement, disclosure, { required: true });
|
||||
if (result.ok) return result.acknowledgement;
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
const payload = {
|
||||
error: result.message,
|
||||
message: result.message,
|
||||
code: result.code,
|
||||
policyVersion: disclosure.policyVersion
|
||||
};
|
||||
this.io.to(socket.id).emit('commerce_ack_required', payload);
|
||||
this.io.to(socket.id).emit('payment_error', payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
async _allowInvoiceFlowForReserve(socket, paymentType, options = {}) {
|
||||
if (!this._invoiceCanLeadDirectlyToPayout(paymentType, options)) return true;
|
||||
const alertService = this.gameModeManager?.alertService;
|
||||
if (!alertService || typeof alertService.checkBalanceForGameStart !== 'function') {
|
||||
return true; // authoritative transactional start gate remains fail-closed
|
||||
}
|
||||
const balanceCheck = await alertService.checkBalanceForGameStart();
|
||||
if (!balanceCheck.halted) return true;
|
||||
this.io.to(socket.id).emit('balance_critical', {
|
||||
reason: 'low_balance',
|
||||
message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed before creating an invoice when the resulting entry mode can pay out and the
|
||||
* operator requires an address. The browser flow calls handlePaymentRequest directly, so this
|
||||
* check cannot live only in the createAndShowPaymentRequest path.
|
||||
*/
|
||||
async _ensureRequiredPayoutAddress(socket, paymentType) {
|
||||
const mode = this._entryModeForPaymentType(paymentType);
|
||||
if (!mode || !this.gameModeManager) return { ok: true, reason: 'not_required' };
|
||||
const needsAddress = typeof this.gameModeManager.requiresPayoutAddressForMode === 'function'
|
||||
? this.gameModeManager.requiresPayoutAddressForMode(mode)
|
||||
: ((mode === 'PAID_SINGLE')
|
||||
|| (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled));
|
||||
if (!needsAddress) return { ok: true, reason: 'not_required' };
|
||||
|
||||
try {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
|
||||
if (userRow?.payout_address) return { ok: true, reason: 'present' };
|
||||
const message = 'Before paying, add a payout address so winnings can be delivered.';
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 ${message}`);
|
||||
this.io.to(socket.id).emit('payment_error', { error: message, code: 'PAYOUT_ADDRESS_REQUIRED' });
|
||||
return { ok: false, reason: 'address_required' };
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to verify payout address');
|
||||
console.error('Address pre-check failed:', normalized.message);
|
||||
this.io.to(socket.id).emit('payment_error', {
|
||||
error: 'Could not verify your payout address. Please try again.',
|
||||
code: 'PAYOUT_ADDRESS_CHECK_FAILED'
|
||||
});
|
||||
return { ok: false, reason: 'address_check_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
_runInvoiceCreation(socket, task) {
|
||||
if (this._isShuttingDown) {
|
||||
this.io?.to?.(socket.id)?.emit?.('payment_error', {
|
||||
error: 'The server is restarting; new payment requests are temporarily closed.',
|
||||
code: 'SERVER_SHUTTING_DOWN'
|
||||
});
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
let work;
|
||||
try {
|
||||
work = Promise.resolve(task());
|
||||
} catch (error) {
|
||||
work = Promise.reject(error);
|
||||
}
|
||||
this._invoiceCreationsInFlight.add(work);
|
||||
return work.finally(() => this._invoiceCreationsInFlight.delete(work));
|
||||
}
|
||||
|
||||
async handlePaymentRequest(socket, data) {
|
||||
return this._runInvoiceCreation(socket, () => this._handlePaymentRequestActive(socket, data));
|
||||
}
|
||||
|
||||
async _handlePaymentRequestActive(socket, data) {
|
||||
if (!this.gameModeManager) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentType = data?.type || data?.gameMode || 'single_game';
|
||||
if (typeof this.gameModeManager.isPaymentIntakeEnabled === 'function'
|
||||
&& !this.gameModeManager.isPaymentIntakeEnabled(paymentType)) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
this.io.to(socket.id).emit('payment_error', {
|
||||
error: 'That paid product is not available on this server.',
|
||||
code: 'PAYMENT_INTAKE_DISABLED'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const acknowledgement = this._requirePaidAcknowledgement(socket, data?.legalAcknowledgement);
|
||||
if (!acknowledgement) return;
|
||||
|
||||
try {
|
||||
const { type, packageId, productId } = data || {};
|
||||
const paymentType = type || data?.gameMode || 'single_game';
|
||||
if (!await this._allowInvoiceFlowForReserve(socket, paymentType)) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
return;
|
||||
}
|
||||
if (paymentType === 'single_game' && data?.fairnessProof) {
|
||||
this.pendingEntryFairness.set(socket.id, data.fairnessProof);
|
||||
}
|
||||
const addressCheck = await this._ensureRequiredPayoutAddress(socket, paymentType);
|
||||
if (!addressCheck.ok) {
|
||||
if (addressCheck.reason === 'address_required') {
|
||||
this._rememberPendingCommerceAcknowledgement(socket.id, acknowledgement);
|
||||
} else {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
packageId,
|
||||
productId,
|
||||
reuseExisting: true,
|
||||
fairnessProof: data.fairnessProof || this.pendingEntryFairness.get(socket.id) || null
|
||||
};
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, options);
|
||||
const cryptoType = this.gameModeManager.cryptoType;
|
||||
|
||||
let qrDataUrl = null;
|
||||
try {
|
||||
const { generatePaymentQR } = require('../payments/qrService');
|
||||
qrDataUrl = await generatePaymentQR(
|
||||
paymentRequest.address,
|
||||
paymentRequest.amount,
|
||||
cryptoType,
|
||||
paymentType === 'single_game'
|
||||
? 'Single game'
|
||||
: (paymentRequest.package?.label || (paymentType === 'credits_package' ? 'Credits package' : 'Product purchase')),
|
||||
this.gameModeManager.currencyDecimals
|
||||
);
|
||||
} catch (qrErr) {
|
||||
console.warn('QR generation failed:', qrErr.message);
|
||||
}
|
||||
|
||||
this.io.to(socket.id).emit('payment_created', {
|
||||
paymentId: paymentRequest.id,
|
||||
address: paymentRequest.address,
|
||||
amount: paymentRequest.amount,
|
||||
amountFormatted: paymentRequest.amountFormatted,
|
||||
humanAmount: paymentRequest.amountFormatted,
|
||||
currency: cryptoType,
|
||||
cryptoType: cryptoType,
|
||||
package: paymentRequest.package,
|
||||
productId: paymentRequest.productId,
|
||||
grants: paymentRequest.grants,
|
||||
paymentType: paymentType,
|
||||
qr: qrDataUrl,
|
||||
reused: !!paymentRequest.reused,
|
||||
fairness: paymentRequest.fairnessProof ? {
|
||||
proofVersion: paymentRequest.fairnessProof.proofVersion,
|
||||
offerId: paymentRequest.fairnessProof.offerId,
|
||||
offerIssuedAt: paymentRequest.fairnessProof.offerIssuedAt,
|
||||
commitment: paymentRequest.fairnessProof.commitment,
|
||||
clientSeed: paymentRequest.fairnessProof.clientSeed
|
||||
} : null
|
||||
});
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
|
||||
// Drop any monitor left over from an earlier request on this socket so only one
|
||||
// watcher runs per socket.
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
|
||||
const currentUser = this.queueManager?.getUserBySocket ?
|
||||
this.queueManager.getUserBySocket(socket.id) : { serverId: socket.id };
|
||||
|
||||
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser,
|
||||
paymentType, paymentRequest.fairnessProof || options.fairnessProof || null);
|
||||
if (paymentType === 'single_game') this.pendingEntryFairness.delete(socket.id);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
|
||||
} catch (e) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
const err = normalizeError(e, 'Failed to create payment request');
|
||||
console.error('Error creating payment request:', err.message);
|
||||
this.io.to(socket.id).emit('payment_error', { error: err.safeMessage });
|
||||
}
|
||||
}
|
||||
|
||||
async createAndShowPaymentRequest(socket, options = {}) {
|
||||
return this._runInvoiceCreation(socket,
|
||||
() => this._createAndShowPaymentRequestActive(socket, options));
|
||||
}
|
||||
|
||||
async _createAndShowPaymentRequestActive(socket, options = {}) {
|
||||
if (!this.gameModeManager) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const suppliedAcknowledgement = options.legalAcknowledgement
|
||||
|| this._getPendingCommerceAcknowledgement(socket.id);
|
||||
const acknowledgement = this._requirePaidAcknowledgement(socket, suppliedAcknowledgement);
|
||||
if (!acknowledgement) return;
|
||||
|
||||
if (!await this._allowInvoiceFlowForReserve(socket, options.paymentType, {
|
||||
preselection: !options.paymentType
|
||||
})) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// With both direct and credits modes enabled the user picks the method.
|
||||
const bothModesEnabled = this.gameModeManager.directModeEnabled && this.gameModeManager.creditsModeEnabled;
|
||||
const forceShowOptions = options.showOptions === true;
|
||||
|
||||
if (bothModesEnabled && !options.paymentType) {
|
||||
this.io.to(socket.id).emit('show_payment_options', {
|
||||
reason: 'choose_payment_method',
|
||||
message: 'Choose how you want to play'
|
||||
});
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null;
|
||||
// The caller (SocketHandlers) owns user existence; this method stays generic.
|
||||
const gameMode = this.gameModeManager.gameMode;
|
||||
const cryptoType = this.gameModeManager.cryptoType;
|
||||
let paymentType = options.paymentType;
|
||||
let amount;
|
||||
let description;
|
||||
|
||||
if (!paymentType) {
|
||||
if (gameMode === 'PAID_SINGLE' || this.gameModeManager.directModeEnabled) {
|
||||
paymentType = 'single_game';
|
||||
} else if (gameMode === 'PAID_CREDITS') {
|
||||
paymentType = 'credits_package';
|
||||
}
|
||||
}
|
||||
|
||||
if (paymentType === 'single_game' && options.fairnessProof) {
|
||||
this.pendingEntryFairness.set(socket.id, options.fairnessProof);
|
||||
}
|
||||
|
||||
const fairnessProof = paymentType === 'single_game'
|
||||
? (options.fairnessProof || this.pendingEntryFairness.get(socket.id) || null)
|
||||
: null;
|
||||
|
||||
const addressCheck = await this._ensureRequiredPayoutAddress(socket, paymentType);
|
||||
if (!addressCheck.ok) {
|
||||
if (addressCheck.reason === 'address_required') {
|
||||
this._rememberPendingCommerceAcknowledgement(socket.id, acknowledgement);
|
||||
} else {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentType === 'single_game') {
|
||||
amount = this.gameModeManager.singleGamePrice;
|
||||
description = 'Single game entry';
|
||||
} else if (paymentType === 'credits_package') {
|
||||
const primaryPackage = this.gameModeManager.getPrimaryCreditPackage();
|
||||
amount = money.toSafe(money.toBig(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice));
|
||||
const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10;
|
||||
description = `${credits} credit package`;
|
||||
} else if (paymentType === 'cosmetic_pack') {
|
||||
const product = this.gameModeManager.getCosmeticProduct(options.productId || options.packageId);
|
||||
if (!product) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid product selection.');
|
||||
return;
|
||||
}
|
||||
amount = money.toSafe(money.toBig(product.price));
|
||||
description = product.label || product.id;
|
||||
} else {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Session userId is stable across socket reconnects; socket.id is not.
|
||||
let sessionUserId = null;
|
||||
if (this.sessionManager?.sessions?.has(socket.id)) {
|
||||
sessionUserId = this.sessionManager.sessions.get(socket.id).id;
|
||||
}
|
||||
|
||||
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, {
|
||||
reuseExisting: true,
|
||||
packageId: options.packageId,
|
||||
productId: options.productId,
|
||||
userId: sessionUserId,
|
||||
fairnessProof
|
||||
});
|
||||
const reused = !!paymentRequest.reused;
|
||||
|
||||
// Release watchers and address maps for requests this one superseded.
|
||||
if (paymentRequest.expiredAddresses?.length > 0) {
|
||||
for (const addr of paymentRequest.expiredAddresses) {
|
||||
this.walletService.stopPaymentMonitoring(addr);
|
||||
this.walletService.addressToUser.delete(addr);
|
||||
this.walletService.addressToSocket.delete(addr);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
|
||||
let qrDataUrl = null;
|
||||
try {
|
||||
const { generatePaymentQR } = require('../payments/qrService');
|
||||
qrDataUrl = await generatePaymentQR(
|
||||
paymentRequest.address,
|
||||
paymentRequest.amount,
|
||||
cryptoType,
|
||||
description,
|
||||
this.gameModeManager.currencyDecimals
|
||||
);
|
||||
} catch (e) {}
|
||||
|
||||
this.io.to(socket.id).emit('payment_created', {
|
||||
paymentId: paymentRequest.id,
|
||||
address: paymentRequest.address,
|
||||
amount: paymentRequest.amount,
|
||||
amountFormatted: formattedAmount,
|
||||
humanAmount: formattedAmount,
|
||||
paymentType,
|
||||
productId: paymentRequest.productId,
|
||||
grants: paymentRequest.grants,
|
||||
gameMode,
|
||||
cryptoType,
|
||||
description,
|
||||
expiresAt: paymentRequest.expiresAt,
|
||||
qr: qrDataUrl,
|
||||
package: paymentRequest.package,
|
||||
reused,
|
||||
fairness: paymentRequest.fairnessProof ? {
|
||||
proofVersion: paymentRequest.fairnessProof.proofVersion,
|
||||
offerId: paymentRequest.fairnessProof.offerId,
|
||||
offerIssuedAt: paymentRequest.fairnessProof.offerIssuedAt,
|
||||
commitment: paymentRequest.fairnessProof.commitment,
|
||||
clientSeed: paymentRequest.fairnessProof.clientSeed
|
||||
} : null
|
||||
});
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
|
||||
const statusHeader = reused
|
||||
? '🔁 Existing payment request still pending. Use the details below to pay.'
|
||||
: `💳 PAYMENT REQUIRED (${description})`;
|
||||
|
||||
const nextStep = paymentType === 'single_game'
|
||||
? '🔄 Added to queue once mempool seen.'
|
||||
: '🔄 Purchase applies after block confirmation.';
|
||||
const statusBody = `\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n${nextStep}\n⏰ Expires in 30 minutes.`;
|
||||
|
||||
this.broadcastManager.sendStatusUpdate(
|
||||
socket.id,
|
||||
'payment',
|
||||
`${statusHeader}${statusBody}`
|
||||
);
|
||||
|
||||
// A reused request already has a watcher; stop it so monitoring is not duplicated.
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser,
|
||||
paymentType, paymentRequest.fairnessProof || fairnessProof);
|
||||
if (paymentType === 'single_game') this.pendingEntryFairness.delete(socket.id);
|
||||
} catch (e) {
|
||||
this.clearPendingCommerceAcknowledgement(socket.id);
|
||||
this.pendingEntryFairness.delete(socket.id);
|
||||
const err = normalizeError(e, 'Failed to create payment request');
|
||||
console.error('Error creating payment request:', err.message);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', err.safeMessage);
|
||||
}
|
||||
}
|
||||
|
||||
_monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game', fairnessProof = null) {
|
||||
// Select the provider that owns this chain: native wallet-RPC by default, or a BTCPay/
|
||||
// checkout gateway when this.cryptoType is routed to one. Native watches by subaddress;
|
||||
// a gateway watches by invoice id. The confirmation handler is identical either way.
|
||||
const registry = this.gameModeManager && this.gameModeManager.paymentProviders;
|
||||
const provider = registry ? registry.getProvider(cryptoType) : null;
|
||||
const isGateway = !!(provider && provider.id !== 'native-monero');
|
||||
const watchRef = isGateway ? (paymentRequest.invoiceId || paymentRequest.address) : paymentRequest.address;
|
||||
|
||||
// Mapping is required to stop the watcher later; it replaces any existing entry.
|
||||
this.socketPaymentMap.set(socket.id, {
|
||||
address: paymentRequest.address,
|
||||
paymentId: paymentRequest.id,
|
||||
amount: paymentRequest.amount,
|
||||
cryptoType,
|
||||
paymentType,
|
||||
fairnessProof: paymentType === 'single_game' ? fairnessProof : null,
|
||||
package: paymentRequest.package,
|
||||
provider: provider || null,
|
||||
watchRef,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
// Expire request after 30 minutes if not confirmed
|
||||
const expiryTimeout = setTimeout(() => {
|
||||
const mapping = this.socketPaymentMap.get(socket.id);
|
||||
if (mapping && mapping.address === paymentRequest.address) { // still active, not replaced
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.');
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
// Unref'd so the 30-minute timer never keeps the event loop alive on its own; the server
|
||||
// itself keeps the process running.
|
||||
if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref();
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
|
||||
// The status callback receives a raw wallet-style status: for native that is the
|
||||
// walletService status directly, for a gateway it is mapped from the Greenfield invoice.
|
||||
// With no provider registered, watch the wallet directly.
|
||||
const onStatus = (status) => this._handlePaymentStatus(socket, paymentRequest, currentUser, status);
|
||||
if (provider && typeof provider.startWatch === 'function') {
|
||||
provider.startWatch(watchRef, onStatus, 2000);
|
||||
} else {
|
||||
this.walletService.startPaymentMonitoring(paymentRequest.address, onStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop new monitor callbacks from creating queue/game/entitlement work. */
|
||||
beginShutdown() {
|
||||
this._isShuttingDown = true;
|
||||
}
|
||||
|
||||
async drainShutdownWork() {
|
||||
while (this._statusUpdatesInFlight.size > 0 || this._invoiceCreationsInFlight.size > 0) {
|
||||
await Promise.allSettled([
|
||||
...this._statusUpdatesInFlight,
|
||||
...this._invoiceCreationsInFlight
|
||||
]);
|
||||
}
|
||||
return { pending: 0 };
|
||||
}
|
||||
|
||||
// Handles one payment status update (raw wallet-style shape: in_mempool/confirmed/complete/
|
||||
// amount/required/confirmations). A callback that begins before the synchronous shutdown gate
|
||||
// is tracked to completion; later callbacks do no admission work, and startup recovery
|
||||
// observes the underlying durable payment state.
|
||||
async _handlePaymentStatus(socket, paymentRequest, currentUser, status) {
|
||||
if (this._isShuttingDown) return;
|
||||
const update = this._handlePaymentStatusActive(socket, paymentRequest, currentUser, status);
|
||||
this._statusUpdatesInFlight.add(update);
|
||||
try {
|
||||
return await update;
|
||||
} finally {
|
||||
this._statusUpdatesInFlight.delete(update);
|
||||
}
|
||||
}
|
||||
|
||||
async _handlePaymentStatusActive(socket, paymentRequest, currentUser, status) {
|
||||
if (status.in_mempool && !status.confirmed) {
|
||||
if (this.mempoolNotified.has(paymentRequest.address)) return;
|
||||
this.mempoolNotified.set(paymentRequest.address, Date.now());
|
||||
|
||||
const mapping = this.socketPaymentMap.get(socket.id);
|
||||
const isGameEntry = !mapping || mapping.paymentType === 'single_game';
|
||||
|
||||
// A paid entry is only usable with its fairness proof durably bound to the
|
||||
// invoice. An unbound payment is never queued merely because its transaction
|
||||
// appeared in the mempool.
|
||||
if (isGameEntry && this._requiresBoundPaidFairness() && !mapping?.fairnessProof) {
|
||||
console.error(`[PaymentHandlers] Refusing to queue unbound paid entry ${paymentRequest.id}`);
|
||||
socket.emit('payment_review_required', {
|
||||
paymentId: paymentRequest.id,
|
||||
code: 'PAYMENT_FAIRNESS_UNBOUND',
|
||||
message: 'This payment requires support review before a game can start.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const detectMessage = !isGameEntry
|
||||
? 'Payment detected in mempool! Awaiting block confirmation to apply your purchase...'
|
||||
: 'Payment detected in mempool! Adding you to the game queue...';
|
||||
socket.emit('payment_detected', {
|
||||
paymentId: paymentRequest.id,
|
||||
message: detectMessage,
|
||||
amount: status.observedAmount ?? status.amount,
|
||||
confirmations: 0
|
||||
});
|
||||
|
||||
// Only queue for single_game payments. Product purchases just apply grants.
|
||||
if (isGameEntry) {
|
||||
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
|
||||
if (existingIdx === -1) {
|
||||
let userId = null;
|
||||
if (this.sessionManager?.sessions?.has(socket.id)) {
|
||||
userId = this.sessionManager.sessions.get(socket.id).id;
|
||||
}
|
||||
|
||||
this.queueManager.addPlayer({
|
||||
serverId: socket.id,
|
||||
clientId: currentUser ? currentUser.clientId : null,
|
||||
userId: userId,
|
||||
paymentId: paymentRequest.id,
|
||||
requiresConfirmation: true,
|
||||
confirmed: false,
|
||||
fairnessProof: mapping?.fairnessProof || null
|
||||
});
|
||||
}
|
||||
socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null });
|
||||
}
|
||||
} else if (status.confirmed) {
|
||||
// A confirmed transaction alone does not grant a game or credits; the received
|
||||
// amount must cover what was expected. checkPaymentStatus() sets `confirmed` for
|
||||
// any incoming tx on the subaddress, while `complete` means
|
||||
// totalReceived >= required. Without this gate a single atomic unit would buy a
|
||||
// game or credits.
|
||||
let exactCoverage = false;
|
||||
try {
|
||||
exactCoverage = money.toBig(status.amount || 0)
|
||||
>= money.toBig(paymentRequest.amount);
|
||||
} catch (_) { exactCoverage = false; }
|
||||
if (!status.complete || !exactCoverage) {
|
||||
// Underpaid: monitoring continues so a later top-up to the same address can
|
||||
// complete it. The warning fires once, not on every 2s poll.
|
||||
if (!this.underpaidNotified.has(paymentRequest.id)) {
|
||||
this.underpaidNotified.add(paymentRequest.id);
|
||||
const required = status.required || 0;
|
||||
const received = status.amount || 0;
|
||||
let shortfall = 0;
|
||||
try {
|
||||
const exact = money.toBig(required) - money.toBig(received);
|
||||
shortfall = money.toSafe(exact > 0n ? exact : 0n);
|
||||
} catch (_) { shortfall = 0; }
|
||||
console.warn(`[PaymentHandlers] Underpaid payment ${paymentRequest.id}: received ${received}, required ${required} (shortfall ${shortfall})`);
|
||||
try {
|
||||
socket.emit('payment_underpaid', {
|
||||
paymentId: paymentRequest.id,
|
||||
received,
|
||||
required,
|
||||
shortfall
|
||||
});
|
||||
} catch (_) {}
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`⚠️ UNDERPAYMENT DETECTED\n\nReceived less than the required amount. Send the remaining balance to the SAME address to complete your payment, or wait for it to expire and try again.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In-memory fast path against double-processing; the DB remains the source of
|
||||
// truth, so a restart that empties this Set is still safe.
|
||||
if (this.confirmedPayments.has(paymentRequest.id)) {
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmedMapping = this.socketPaymentMap.get(socket.id);
|
||||
const confirmedIsGameEntry = !confirmedMapping
|
||||
|| confirmedMapping.paymentType === 'single_game';
|
||||
if (confirmedIsGameEntry && this._requiresBoundPaidFairness()
|
||||
&& !confirmedMapping?.fairnessProof) {
|
||||
console.error(`[PaymentHandlers] Refusing to confirm unbound paid entry ${paymentRequest.id}`);
|
||||
socket.emit('payment_review_required', {
|
||||
paymentId: paymentRequest.id,
|
||||
code: 'PAYMENT_FAIRNESS_UNBOUND',
|
||||
message: 'Funds were detected, but this entry requires support review.'
|
||||
});
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'Payment received, but its fairness commitment is missing. No game was started; contact support.');
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.confirmedPayments.add(paymentRequest.id);
|
||||
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
|
||||
|
||||
// Handle non-game products: add credits / cosmetic grants / premium tier.
|
||||
const mapping = this.socketPaymentMap.get(socket.id);
|
||||
if (mapping && mapping.paymentType !== 'single_game' && this.gameModeManager) {
|
||||
try {
|
||||
// Product confirmation atomically checks status='pending' and applies grants.
|
||||
const creditsResult = await this.gameModeManager.processProductPaymentConfirmation(
|
||||
socket.id,
|
||||
paymentRequest.id,
|
||||
mapping.package,
|
||||
money.toBig(status.amount || 0).toString(),
|
||||
status.receipts || []
|
||||
);
|
||||
if (creditsResult.success) {
|
||||
socket.emit('credits_update', {
|
||||
balance: creditsResult.newBalance,
|
||||
totalCreditsPurchased: creditsResult.totalCreditsPurchased || 0,
|
||||
...(creditsResult.entitlements || {})
|
||||
});
|
||||
if (creditsResult.entitlements) {
|
||||
socket.emit('identity_update', { entitlements: creditsResult.entitlements });
|
||||
}
|
||||
const packs = creditsResult.grantsApplied?.packs || [];
|
||||
const packText = packs.length ? `\nUnlocked: ${packs.map(p => p.id).join(', ')}` : '';
|
||||
socket.emit('payment_confirmed', {
|
||||
paymentId: paymentRequest.id,
|
||||
message: `Payment confirmed! Added ${creditsResult.creditsAdded} credits. New balance: ${creditsResult.newBalance}${packText}`,
|
||||
creditsAdded: creditsResult.creditsAdded,
|
||||
newBalance: creditsResult.newBalance,
|
||||
grantsApplied: creditsResult.grantsApplied,
|
||||
confirmations: status.confirmations
|
||||
});
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'success',
|
||||
`✅ PURCHASE CONFIRMED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.${packText}\n\nType 'enter' to start a game!`);
|
||||
} else if (creditsResult.alreadyProcessed) {
|
||||
console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already processed, skipping duplicate`);
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations });
|
||||
} else {
|
||||
console.error('Failed to process credits package:', creditsResult.reason);
|
||||
socket.emit('payment_review_required', {
|
||||
paymentId: paymentRequest.id,
|
||||
code: 'PAYMENT_GRANT_REJECTED',
|
||||
message: 'Funds were detected, but the purchase was not applied and requires support review.'
|
||||
});
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'Payment received, but no entitlement was applied. Contact support for review.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing credits package confirmation:', e.message);
|
||||
socket.emit('payment_review_required', {
|
||||
paymentId: paymentRequest.id,
|
||||
code: 'PAYMENT_RECEIPT_REJECTED',
|
||||
message: 'Funds were detected, but the purchase transaction failed and requires support review.'
|
||||
});
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'Payment received, but no entitlement was applied. Contact support for review.');
|
||||
}
|
||||
// Credit and product purchases are never queue entries.
|
||||
this.queueManager.removePlayer(socket.id);
|
||||
} else {
|
||||
// single_game entry. The status update is conditional on status='pending',
|
||||
// so the returned row count is what authorizes the game logic below and a
|
||||
// second confirmation (including after a restart) is a no-op.
|
||||
let wasUpdated = false;
|
||||
let confirmationError = null;
|
||||
try {
|
||||
if (typeof this.gameModeManager?.confirmSingleGamePayment === 'function') {
|
||||
const confirmation = await this.gameModeManager.confirmSingleGamePayment(
|
||||
paymentRequest.id,
|
||||
money.toBig(status.amount || 0).toString(),
|
||||
status.receipts || []
|
||||
);
|
||||
wasUpdated = confirmation?.updated === true;
|
||||
} else if (this.gameModeManager && this.gameModeManager.db) {
|
||||
const updateResult = await this.gameModeManager.db.query(`
|
||||
UPDATE payments
|
||||
SET status = 'confirmed',
|
||||
confirmed_at = NOW(),
|
||||
received_amount = $2
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id
|
||||
`, [paymentRequest.id, money.toBig(status.amount || 0).toString()]);
|
||||
wasUpdated = updateResult.rows.length > 0;
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[PaymentHandlers] Payment ${paymentRequest.id} status update: ${wasUpdated ? 'success' : 'already confirmed'}`);
|
||||
}
|
||||
}
|
||||
} catch (dbErr) {
|
||||
confirmationError = dbErr;
|
||||
console.error('[PaymentHandlers] Failed to update payment status in DB:', dbErr.message);
|
||||
}
|
||||
|
||||
if (!wasUpdated) {
|
||||
if (confirmationError) {
|
||||
socket.emit('payment_review_required', {
|
||||
paymentId: paymentRequest.id,
|
||||
code: 'PAYMENT_RECEIPT_REJECTED',
|
||||
message: 'Funds were detected, but their receipt evidence requires support review.'
|
||||
});
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
return;
|
||||
}
|
||||
console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already confirmed, skipping duplicate processing`);
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations });
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations });
|
||||
|
||||
// A direct single_game entry counts as buying and spending one credit, so it
|
||||
// advances total_credits_purchased and unlocks the same threshold cosmetics
|
||||
// as a credit purchase. The wasUpdated guard above keeps this to one run.
|
||||
if (this.gameModeManager && typeof this.gameModeManager.recordDirectEntryPurchase === 'function') {
|
||||
try {
|
||||
const rec = await this.gameModeManager.recordDirectEntryPurchase(socket.id);
|
||||
if (rec) {
|
||||
socket.emit('credits_update', {
|
||||
balance: rec.balance,
|
||||
totalCreditsPurchased: rec.totalCreditsPurchased,
|
||||
...(rec.entitlements || {})
|
||||
});
|
||||
if (rec.entitlements) socket.emit('identity_update', { entitlements: rec.entitlements });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PaymentHandlers] direct-entry credit record failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// On fast blocks confirmation can precede mempool detection, leaving the
|
||||
// player out of the queue; they are added here as already confirmed.
|
||||
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
|
||||
if (existingIdx === -1) {
|
||||
console.log(`[PaymentHandlers] Payment confirmed but player not in queue - adding now (socket: ${socket.id})`);
|
||||
let userId = null;
|
||||
if (this.sessionManager?.sessions?.has(socket.id)) {
|
||||
userId = this.sessionManager.sessions.get(socket.id).id;
|
||||
}
|
||||
this.queueManager.addPlayer({
|
||||
serverId: socket.id,
|
||||
clientId: currentUser ? currentUser.clientId : null,
|
||||
userId: userId,
|
||||
paymentId: paymentRequest.id,
|
||||
requiresConfirmation: false, // Already confirmed
|
||||
confirmed: true,
|
||||
fairnessProof: mapping?.fairnessProof || null
|
||||
});
|
||||
} else {
|
||||
// Already queued from mempool detection.
|
||||
this.queueManager.markConfirmed(socket.id);
|
||||
}
|
||||
|
||||
// Start immediately so the player does not wait another full block.
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
|
||||
if (currentBlock !== null) {
|
||||
const started = await this.queueManager.startGameImmediately(socket.id, currentBlock);
|
||||
if (!started) {
|
||||
console.log(`[PaymentHandlers] Immediate start failed for ${socket.id} - player will start on next block`);
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
this.queueManager.debugDumpQueue();
|
||||
}
|
||||
} else {
|
||||
console.log(`[PaymentHandlers] ✅ Immediate game start successful for ${socket.id}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[PaymentHandlers] No block height available - player will start on next block`);
|
||||
}
|
||||
}
|
||||
// Monitoring ends here on every confirmed path, duplicates included.
|
||||
this.stopMonitoringForSocket(socket.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a socket has active payment monitoring
|
||||
* @param {string} socketId - The socket ID to check
|
||||
* @returns {boolean} True if monitoring is active
|
||||
*/
|
||||
hasActiveMonitoring(socketId) {
|
||||
return this.socketPaymentMap.has(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring for a given socket on disconnect or expiry. Safe when none is active.
|
||||
*/
|
||||
stopMonitoringForSocket(socketId) {
|
||||
const mapping = this.socketPaymentMap.get(socketId);
|
||||
if (mapping) {
|
||||
if (mapping.provider && typeof mapping.provider.stopWatch === 'function') {
|
||||
mapping.provider.stopWatch(mapping.watchRef || mapping.address); // native provider delegates to stopPaymentMonitoring
|
||||
} else {
|
||||
this.walletService.stopPaymentMonitoring(mapping.address);
|
||||
}
|
||||
this.socketPaymentMap.delete(socketId);
|
||||
this.mempoolNotified.delete(mapping.address);
|
||||
if (mapping.paymentId != null) {
|
||||
this.underpaidNotified.delete(mapping.paymentId);
|
||||
}
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🛑 Stopped monitoring for socket ${socketId}`);
|
||||
}
|
||||
}
|
||||
const expiry = this._expiryTimeouts.get(socketId);
|
||||
if (expiry) {
|
||||
clearTimeout(expiry);
|
||||
this._expiryTimeouts.delete(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release intervals and monitors for graceful shutdown and test teardown.
|
||||
*/
|
||||
dispose() {
|
||||
this.beginShutdown();
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
this._cleanupInterval = null;
|
||||
}
|
||||
for (const socketId of Array.from(this.socketPaymentMap.keys())) {
|
||||
this.stopMonitoringForSocket(socketId);
|
||||
}
|
||||
for (const [socketId, to] of this._expiryTimeouts.entries()) {
|
||||
clearTimeout(to);
|
||||
this._expiryTimeouts.delete(socketId);
|
||||
}
|
||||
this.pendingCommerceAcknowledgement.clear();
|
||||
this.pendingEntryFairness.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentHandlers;
|
||||
@@ -0,0 +1,147 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
OPERATED_PRODUCT_PROFILE_IDS
|
||||
} = require('../config/operatedProductProfiles');
|
||||
|
||||
const PERIOD_MS = Object.freeze({
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000
|
||||
});
|
||||
const LEADERBOARD_BOARDS = new Set(['pleb', 'champions', 'prestige']);
|
||||
|
||||
function normalizeLeaderboardPeriod(value) {
|
||||
const period = String(value || 'all').toLowerCase();
|
||||
return Object.prototype.hasOwnProperty.call(PERIOD_MS, period) ? period : 'all';
|
||||
}
|
||||
|
||||
function normalizeLeaderboardBoard(value) {
|
||||
// A request that omits the board gets the no-entry-cost board. Explicit legacy `all`, blank,
|
||||
// and unknown values are invalid: silently translating them would conceal an API contract
|
||||
// change and could make a caller believe it received a mixed board.
|
||||
if (value === undefined || value === null) return 'pleb';
|
||||
const board = String(value).trim().toLowerCase();
|
||||
return LEADERBOARD_BOARDS.has(board) ? board : null;
|
||||
}
|
||||
|
||||
function prestigeCutoff(period, now = new Date()) {
|
||||
const normalized = normalizeLeaderboardPeriod(period);
|
||||
if (normalized === 'all') return null;
|
||||
return new Date(now.getTime() - PERIOD_MS[normalized]);
|
||||
}
|
||||
|
||||
async function queryPrestigeLeaderboard(db, { period = 'all', limit = 20, now = new Date() } = {}) {
|
||||
const cutoff = prestigeCutoff(period, now);
|
||||
return db.query(`
|
||||
SELECT
|
||||
u.id,
|
||||
COALESCE(u.display_name, 'Anon#' || u.id) AS name,
|
||||
MAX(me.score) AS best_score,
|
||||
COUNT(*) FILTER (
|
||||
WHERE me.placement = 1
|
||||
AND m.winner_user_id = me.user_id
|
||||
) AS wins,
|
||||
COUNT(*) AS games_played
|
||||
FROM match_entrants me
|
||||
JOIN matches m ON me.match_id = m.id
|
||||
JOIN users u ON me.user_id = u.id
|
||||
WHERE m.economy = 'credits_prestige'
|
||||
AND m.status = 'finished'
|
||||
AND m.ruleset_id <> 'coop-escape'
|
||||
AND me.score > 0
|
||||
AND ($1::timestamptz IS NULL OR m.ended_at > $1::timestamptz)
|
||||
GROUP BY u.id, u.display_name
|
||||
ORDER BY best_score DESC
|
||||
LIMIT $2
|
||||
`, [cutoff, limit]);
|
||||
}
|
||||
|
||||
function soloLeaderboardPolicy(profileId, board) {
|
||||
if (board === 'pleb') {
|
||||
// Free competitive matches intentionally create durable FREE rows for this board.
|
||||
return Object.freeze({
|
||||
gameModes: Object.freeze(['FREE']),
|
||||
excludeMatchGenerated: false
|
||||
});
|
||||
}
|
||||
if (board !== 'champions') {
|
||||
throw new TypeError(`Unsupported solo leaderboard board: ${board}`);
|
||||
}
|
||||
|
||||
if (profileId === OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE) {
|
||||
return Object.freeze({
|
||||
// The operated Wownero product is credits-only. Historical PAID_SINGLE rows are not
|
||||
// part of its current Hall of Champions.
|
||||
gameModes: Object.freeze(['PAID_CREDITS']),
|
||||
excludeMatchGenerated: true
|
||||
});
|
||||
}
|
||||
if (profileId === OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET) {
|
||||
return Object.freeze({
|
||||
// The operated stagenet product accepts direct entries only. Historical credit
|
||||
// package rows are not part of its current Hall of Champions.
|
||||
gameModes: Object.freeze(['PAID_SINGLE']),
|
||||
excludeMatchGenerated: true
|
||||
});
|
||||
}
|
||||
|
||||
// Independent MIT deployments retain the generic historical mapping, including an explicitly
|
||||
// enabled crypto_race whose synthetic row is represented as PAID_CREDITS.
|
||||
return Object.freeze({
|
||||
gameModes: Object.freeze(['PAID_SINGLE', 'PAID_CREDITS']),
|
||||
excludeMatchGenerated: false
|
||||
});
|
||||
}
|
||||
|
||||
async function querySoloLeaderboard(db, {
|
||||
profileId = null,
|
||||
board = 'pleb',
|
||||
period = 'all',
|
||||
limit = 20,
|
||||
now = new Date()
|
||||
} = {}) {
|
||||
const policy = soloLeaderboardPolicy(profileId, board);
|
||||
const cutoff = prestigeCutoff(period, now);
|
||||
return db.query(`
|
||||
SELECT
|
||||
u.id,
|
||||
COALESCE(u.display_name,
|
||||
CASE WHEN u.payout_address IS NOT NULL
|
||||
THEN LEFT(u.payout_address, 4) || '...' || RIGHT(u.payout_address, 4)
|
||||
ELSE 'Anon#' || u.id
|
||||
END
|
||||
) AS name,
|
||||
MAX(g.score) AS best_score,
|
||||
COUNT(*) FILTER (WHERE g.status = 'won') AS wins,
|
||||
COUNT(*) AS games_played
|
||||
FROM games g
|
||||
JOIN users u ON g.user_id = u.id
|
||||
WHERE g.status IN ('won', 'lost')
|
||||
AND g.score > 0
|
||||
AND ($1::timestamptz IS NULL OR g.completed_at > $1::timestamptz)
|
||||
AND g.game_mode = ANY($2::varchar[])
|
||||
AND (
|
||||
$3::boolean = FALSE
|
||||
OR (
|
||||
LEFT(COALESCE(g.outcome, ''), 6) <> 'match_'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM matches source_match
|
||||
WHERE source_match.id::text = g.dungeon_seed
|
||||
)
|
||||
)
|
||||
)
|
||||
GROUP BY u.id, u.display_name, u.payout_address
|
||||
ORDER BY best_score DESC
|
||||
LIMIT $4
|
||||
`, [cutoff, policy.gameModes, policy.excludeMatchGenerated, limit]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeLeaderboardBoard,
|
||||
normalizeLeaderboardPeriod,
|
||||
prestigeCutoff,
|
||||
queryPrestigeLeaderboard,
|
||||
querySoloLeaderboard,
|
||||
soloLeaderboardPolicy
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Queue Handler Module
|
||||
* Handles game queue operations, validation, and queue management
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
class QueueHandler {
|
||||
constructor({ queueManager, gameModeManager, paymentHandlers, activeGames, broadcastManager, debugManager, rateLimiter }) {
|
||||
this.queueManager = queueManager;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.paymentHandlers = paymentHandlers;
|
||||
this.activeGames = activeGames;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.rateLimiter = rateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game queue request (typing "enter") with rate limiting
|
||||
*/
|
||||
async handleGameQueue(socket, getUserBySocket, opts = {}) {
|
||||
try {
|
||||
// Explicit FREE play (Pleb board) intent, honoured only when the instance allows free
|
||||
// play; otherwise the paid eligibility check below applies. Matches the auto_start free
|
||||
// path so a free queued game never raises the payment modal.
|
||||
const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled;
|
||||
let authorizedFairnessProof = opts.fairnessProof || null;
|
||||
let authorizedPaymentId = null;
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue');
|
||||
if (!rateLimitResult.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Please wait ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds before trying to join queue again.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`Player ${socket.id} requested to enter the dungeon`);
|
||||
}
|
||||
|
||||
const currentUser = getUserBySocket(socket.id);
|
||||
if (!currentUser) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Error: Could not add to queue. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = this.queueManager.getPlayerIndex(socket.id);
|
||||
if (existingIndex !== -1) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'You are already in the queue!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeGames.has(socket.id)) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'You are already in a game!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Payment eligibility and payout address requirements do not apply to free play.
|
||||
if (!wantsFree) {
|
||||
const paymentCheckResult = await this._checkPaymentEligibility(socket.id);
|
||||
if (!paymentCheckResult.allowed) {
|
||||
switch (paymentCheckResult.action) {
|
||||
case 'set_address':
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Paste your payout address first, then type confirm.');
|
||||
break;
|
||||
case 'make_payment':
|
||||
await this.paymentHandlers.createAndShowPaymentRequest(socket, {
|
||||
fairnessProof: opts.fairnessProof || null,
|
||||
legalAcknowledgement: opts.legalAcknowledgement
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
paymentCheckResult.reason || 'Not allowed to join queue');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (paymentCheckResult.effectiveMode === 'PAID_SINGLE') {
|
||||
authorizedFairnessProof = paymentCheckResult.fairnessProof || null;
|
||||
authorizedPaymentId = paymentCheckResult.paymentId || null;
|
||||
if (this.gameModeManager._requiresPaidFairnessV2?.() && !authorizedFairnessProof) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'This paid entry has no durable fairness binding and requires support review.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(socket.id, 'game:queue');
|
||||
|
||||
let userId = null;
|
||||
if (this.paymentHandlers?.gameModeManager?.getOrCreateUser) {
|
||||
try {
|
||||
const dbUser = await this.paymentHandlers.gameModeManager.getOrCreateUser(socket.id);
|
||||
userId = dbUser.id;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Reached only in free mode or with an already authorized paid entry. `free` carries the
|
||||
// Pleb-board intent to processGameStart when the block lands, so no credit/payment is taken.
|
||||
this.queueManager.addPlayer({
|
||||
serverId: socket.id,
|
||||
clientId: currentUser.clientId,
|
||||
userId: userId,
|
||||
paymentId: authorizedPaymentId,
|
||||
requiresConfirmation: false,
|
||||
confirmed: true,
|
||||
free: wantsFree,
|
||||
fairnessProof: authorizedFairnessProof
|
||||
});
|
||||
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight();
|
||||
const nextBlock = currentBlock + 1;
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'queue',
|
||||
`Added to queue! You will enter when block ${nextBlock} is found. Current block: ${currentBlock}`);
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🕒 QUEUE ENTRY: Player ${socket.id} queued for block ${nextBlock}. Queue length: ${this.queueManager.getQueueLength()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to join queue');
|
||||
console.error('handleGameQueue error:', normalized.message);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle queue cancellation
|
||||
*/
|
||||
handleCancelEntry(socket) {
|
||||
this.queueManager.removePlayer(socket.id);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Removed from queue.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start games for waiting players when a new block is found
|
||||
*/
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
return await this.queueManager.startGamesForWaiting(blockHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
length: this.queueManager.getQueueLength(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if early entry is allowed for the current mode
|
||||
* @returns {Object} { allowed: boolean, reason: string }
|
||||
*/
|
||||
isEarlyEntryAllowed() {
|
||||
if (!this.gameModeManager) {
|
||||
return { allowed: true, reason: 'No game mode manager' };
|
||||
}
|
||||
|
||||
const config = this.gameModeManager.configSnapshot;
|
||||
const earlyEntry = config?.earlyEntry;
|
||||
|
||||
if (!earlyEntry?.enabled) {
|
||||
return { allowed: false, reason: 'Early entry is disabled' };
|
||||
}
|
||||
|
||||
const mode = this.gameModeManager.gameMode;
|
||||
|
||||
if (mode === 'FREE' && !earlyEntry.allowInFreeMode) {
|
||||
return { allowed: false, reason: 'Early entry not allowed in free mode' };
|
||||
}
|
||||
|
||||
if (mode === 'PAID_CREDITS' && !earlyEntry.allowInCreditsMode) {
|
||||
return { allowed: false, reason: 'Early entry not allowed in credits mode' };
|
||||
}
|
||||
|
||||
// Direct payment buys a full block, so PAID_SINGLE never allows early entry.
|
||||
if (mode === 'PAID_SINGLE') {
|
||||
return { allowed: false, reason: 'Early entry not allowed for direct payment games' };
|
||||
}
|
||||
|
||||
return { allowed: true, reason: `Early entry allowed for ${mode}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle early entry request: start the game immediately instead of waiting for the next block.
|
||||
* The player dies if the next block is found before they escape.
|
||||
*/
|
||||
async handleEarlyEntry(socket, getUserBySocket, opts = {}) {
|
||||
try {
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue');
|
||||
if (!rateLimitResult.allowed) {
|
||||
const msg = `Please wait ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds before trying again.`;
|
||||
socket.emit('early_entry_error', { message: msg });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', msg);
|
||||
return { success: false, reason: 'rate_limited' };
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`⚡ Player ${socket.id} requested early entry`);
|
||||
}
|
||||
|
||||
const earlyEntryCheck = this.isEarlyEntryAllowed();
|
||||
if (!earlyEntryCheck.allowed) {
|
||||
socket.emit('early_entry_error', { message: earlyEntryCheck.reason });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', earlyEntryCheck.reason);
|
||||
return { success: false, reason: earlyEntryCheck.reason };
|
||||
}
|
||||
|
||||
const currentUser = getUserBySocket(socket.id);
|
||||
if (!currentUser) {
|
||||
const msg = 'Error: Could not start game. Please try again.';
|
||||
socket.emit('early_entry_error', { message: msg });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', msg);
|
||||
return { success: false, reason: 'user_not_found' };
|
||||
}
|
||||
|
||||
if (this.queueManager.isPlayerQueued(socket.id)) {
|
||||
const msg = 'You are already in the queue! Use early entry only when not queued.';
|
||||
socket.emit('early_entry_error', { message: msg });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', msg);
|
||||
return { success: false, reason: 'already_queued' };
|
||||
}
|
||||
|
||||
if (this.activeGames.has(socket.id)) {
|
||||
const msg = 'You are already in a game!';
|
||||
socket.emit('early_entry_error', { message: msg });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', msg);
|
||||
return { success: false, reason: 'already_in_game' };
|
||||
}
|
||||
|
||||
const paymentCheckResult = await this._checkPaymentEligibility(socket.id);
|
||||
if (!paymentCheckResult.allowed) {
|
||||
switch (paymentCheckResult.action) {
|
||||
case 'set_address':
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Paste your payout address first, then type confirm.');
|
||||
break;
|
||||
case 'make_payment':
|
||||
await this.paymentHandlers.createAndShowPaymentRequest(socket, {
|
||||
legalAcknowledgement: opts.legalAcknowledgement
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
paymentCheckResult.reason || 'Not allowed to start game');
|
||||
}
|
||||
return { success: false, reason: paymentCheckResult.reason };
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(socket.id, 'game:queue');
|
||||
|
||||
// The player's blockRec is the current block, so they die when block currentBlock + 1
|
||||
// is found.
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight();
|
||||
const result = await this.queueManager.startEarlyGame(socket.id, currentUser, currentBlock, {
|
||||
fairnessProof: opts.fairnessProof || null
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
socket.emit('early_entry_success', { blockHeight: currentBlock });
|
||||
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
|
||||
`⚡ Early entry! Game started on block ${currentBlock}. Escape before block ${currentBlock + 1}!`);
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`⚡ EARLY ENTRY: Player ${socket.id} started on block ${currentBlock}`);
|
||||
}
|
||||
return { success: true, blockHeight: currentBlock };
|
||||
} else {
|
||||
socket.emit('early_entry_error', { message: result.reason || 'Failed to start early game' });
|
||||
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', result.reason || 'Failed to start early game');
|
||||
return { success: false, reason: result.reason };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to start early game');
|
||||
console.error('handleEarlyEntry error:', normalized.message);
|
||||
socket.emit('early_entry_error', { message: normalized.message });
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.message);
|
||||
return { success: false, reason: normalized.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/**
|
||||
* Check payment eligibility and payout address requirements
|
||||
* @param {string} socketId - Socket ID
|
||||
* @returns {Object} { allowed: boolean, action?: string, reason?: string }
|
||||
*/
|
||||
async _checkPaymentEligibility(socketId) {
|
||||
if (!this.gameModeManager) {
|
||||
return { allowed: true, reason: 'Free mode' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Eligibility resolves first: on a mixed instance the entry may consume a credit or a
|
||||
// confirmed direct payment, and those modes have different payout policies. The address
|
||||
// gate applies to the mode processGameStart will actually use.
|
||||
const eligibility = await this.gameModeManager.canUserStartGame(socketId);
|
||||
|
||||
if (!eligibility.allowed) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`❌ Payment required for ${socketId}: ${eligibility.reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
action: eligibility.action === 'purchase_credits' ? 'make_payment' : 'make_payment',
|
||||
reason: eligibility.reason
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveMode = eligibility.effectiveMode || this.gameModeManager.gameMode;
|
||||
const needsAddress = typeof this.gameModeManager.requiresPayoutAddressForMode === 'function'
|
||||
? this.gameModeManager.requiresPayoutAddressForMode(effectiveMode)
|
||||
: ((effectiveMode === 'PAID_SINGLE')
|
||||
|| (effectiveMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled));
|
||||
|
||||
if (needsAddress) {
|
||||
const user = await this.gameModeManager.getOrCreateUser(socketId);
|
||||
if (!user.payout_address) {
|
||||
return {
|
||||
allowed: false,
|
||||
action: 'set_address',
|
||||
reason: 'Payout address required'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`✅ Payment validated for ${socketId}: ${eligibility.reason}`);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: eligibility.reason,
|
||||
effectiveMode,
|
||||
paymentId: eligibility.paymentId || null,
|
||||
fairnessProof: eligibility.fairnessProof || null
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking payment eligibility:', error);
|
||||
|
||||
// A failed payment check blocks queue entry in any paid mode.
|
||||
if (this.gameModeManager.gameMode !== 'FREE') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Payment system error. Please try again or contact support.'
|
||||
};
|
||||
}
|
||||
|
||||
// FREE mode degrades gracefully: queue entry continues when the payment system errors.
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log('Payment system unavailable, allowing free mode queue entry');
|
||||
}
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'warning', 'Payment system unavailable. Playing in FREE mode.');
|
||||
return { allowed: true, reason: 'Free mode fallback' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QueueHandler;
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Queue Manager
|
||||
* Encapsulates logic for players waiting for the next block to start a game.
|
||||
* Responsible for:
|
||||
* - Managing queue entries (add/remove/confirm)
|
||||
* - Starting games when a new block arrives
|
||||
* - Preserving unconfirmed (mempool-only) entries across blocks
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
class QueueManager {
|
||||
constructor({ debugManager, broadcastManager, io, createGameForUser, getUserBySocket, activeGames, gameModeManager, consoleLogging }) {
|
||||
this.debugManager = debugManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.io = io;
|
||||
this.createGameForUser = createGameForUser; // (user, options) => Game
|
||||
this.getUserBySocket = getUserBySocket; // (socketId) => User
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.CONSOLE_LOGGING = !!consoleLogging;
|
||||
this._waitingPlayers = []; // internal queue
|
||||
}
|
||||
|
||||
addPlayer({ serverId, clientId, userId = null, entryTime = Date.now(), paymentId = null, requiresConfirmation = false, confirmed = true, free = false, fairnessProof = null }) {
|
||||
if (this.getPlayerIndex(serverId) !== -1) return; // already queued
|
||||
this._waitingPlayers.push({ serverId, clientId, userId, entryTime, paymentId, requiresConfirmation, confirmed, free, fairnessProof });
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Added player ${serverId} (userId=${userId}, confirmed=${confirmed}, requiresConfirmation=${requiresConfirmation}). Queue length: ${this._waitingPlayers.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the serverId (socket.id) for a queued player when their session resumes
|
||||
*/
|
||||
updateSocketId(userId, newSocketId) {
|
||||
const entry = this._waitingPlayers.find(p => p.userId === userId);
|
||||
if (entry) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Updating socket ID for user ${userId}: ${entry.serverId} -> ${newSocketId}`);
|
||||
}
|
||||
entry.serverId = newSocketId;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player entry has persistent data that should be preserved on disconnect
|
||||
*/
|
||||
isValuableEntry(serverId) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx === -1) return false;
|
||||
const entry = this._waitingPlayers[idx];
|
||||
return !!(entry.paymentId || entry.userId);
|
||||
}
|
||||
|
||||
removePlayer(serverId) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx !== -1) {
|
||||
this._waitingPlayers.splice(idx, 1);
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Removed player ${serverId}. Queue length: ${this._waitingPlayers.length}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
markConfirmed(serverId) {
|
||||
const entry = this._waitingPlayers.find(p => p.serverId === serverId);
|
||||
if (entry) {
|
||||
entry.confirmed = true;
|
||||
entry.requiresConfirmation = false;
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Marked player ${serverId} confirmed.`);
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerIndex(serverId) { return this._waitingPlayers.findIndex(p => p.serverId === serverId); }
|
||||
isPlayerQueued(serverId) { return this.getPlayerIndex(serverId) !== -1; }
|
||||
getQueueLength() { return this._waitingPlayers.length; }
|
||||
getQueuePosition(serverId) { const i = this.getPlayerIndex(serverId); return i === -1 ? -1 : i + 1; }
|
||||
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Starting games for ${this._waitingPlayers.length} waiting players at block ${blockHeight}`);
|
||||
}
|
||||
const remainingQueue = [];
|
||||
while (this._waitingPlayers.length > 0) {
|
||||
const entry = this._waitingPlayers.shift();
|
||||
const serverId = entry.serverId;
|
||||
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Processing queued player ${serverId}`);
|
||||
const currentUser = this.getUserBySocket(serverId);
|
||||
if (!currentUser) {
|
||||
if (this.CONSOLE_LOGGING) console.warn(`[QueueManager] User not found for ${serverId}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep unconfirmed paid entries in queue
|
||||
if (entry.requiresConfirmation && !entry.confirmed) {
|
||||
remainingQueue.push(entry);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Skipping unconfirmed paid entry ${serverId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentUser.blockRec = blockHeight;
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`);
|
||||
}
|
||||
try {
|
||||
const game = await this.createGameForUser(currentUser, {
|
||||
fairnessProof: entry.fairnessProof || null
|
||||
});
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// forceFree honours a queued Pleb-board entry, so no credit or payment is taken
|
||||
// when the block lands.
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(serverId, game.id, { forceFree: !!entry.free });
|
||||
if (!startRes.success) {
|
||||
if (this.activeGames) this.activeGames.delete(serverId);
|
||||
await this._cleanupOrphanedGame(game);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed'));
|
||||
continue;
|
||||
}
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Game started for ${serverId}`);
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to start game');
|
||||
console.error('[QueueManager] Error creating game:', normalized.message);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + normalized.message);
|
||||
}
|
||||
}
|
||||
if (remainingQueue.length > 0) {
|
||||
this._waitingPlayers = remainingQueue.concat(this._waitingPlayers);
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Re-queued ${remainingQueue.length} unconfirmed entries.`);
|
||||
remainingQueue.forEach(e => {
|
||||
console.log(`[QueueManager] -> ${e.serverId} confirmed=${e.confirmed} requiresConfirmation=${e.requiresConfirmation}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugDumpQueue() {
|
||||
if (!this.CONSOLE_LOGGING) return;
|
||||
console.log('[QueueManager] Current queue dump:');
|
||||
this._waitingPlayers.forEach((e,i) => {
|
||||
console.log(` [${i}] ${e.serverId} confirmed=${e.confirmed} requiresConfirmation=${e.requiresConfirmation}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start a game for a single confirmed player whose payment confirms after the
|
||||
* block tick has already processed, avoiding an additional full-block wait.
|
||||
* Returns true if a game was started.
|
||||
*/
|
||||
async startGameImmediately(serverId, blockHeight) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx === -1) return false; // not queued
|
||||
const entry = this._waitingPlayers[idx];
|
||||
if (entry.requiresConfirmation && !entry.confirmed) {
|
||||
return false;
|
||||
}
|
||||
this._waitingPlayers.splice(idx, 1);
|
||||
const currentUser = this.getUserBySocket(serverId);
|
||||
if (!currentUser) return false;
|
||||
currentUser.blockRec = blockHeight; // lifetime until next block
|
||||
try {
|
||||
const game = await this.createGameForUser(currentUser, {
|
||||
fairnessProof: entry.fairnessProof || null
|
||||
});
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// forceFree honours a queued Pleb-board entry confirmed after the tick, so no
|
||||
// credit or payment is taken.
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(serverId, game.id, { forceFree: !!entry.free });
|
||||
if (!startRes.success) {
|
||||
if (this.activeGames) this.activeGames.delete(serverId);
|
||||
await this._cleanupOrphanedGame(game);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed'));
|
||||
return false;
|
||||
}
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] (immediate) Game started for ${serverId} at block ${blockHeight}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to start immediate game');
|
||||
console.error('[QueueManager] Error starting immediate game:', normalized.message);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + normalized.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a game immediately for early entry, bypassing the queue, when a player opts in
|
||||
* without waiting for the next block.
|
||||
* @param {string} serverId - Socket ID
|
||||
* @param {Object} currentUser - User object
|
||||
* @param {number} blockHeight - Current block height
|
||||
* @returns {Object} { success: boolean, reason?: string }
|
||||
*/
|
||||
async startEarlyGame(serverId, currentUser, blockHeight, options = {}) {
|
||||
if (!currentUser) {
|
||||
return { success: false, reason: 'User not found' };
|
||||
}
|
||||
|
||||
if (this.activeGames && this.activeGames.has(serverId)) {
|
||||
return { success: false, reason: 'Already in a game' };
|
||||
}
|
||||
|
||||
// blockRec is the entry block; the player dies when block blockHeight + 1 is found.
|
||||
currentUser.blockRec = blockHeight;
|
||||
currentUser.isEarlyEntry = true;
|
||||
|
||||
try {
|
||||
const game = await this.createGameForUser(currentUser, {
|
||||
earlyEntry: true,
|
||||
fairnessProof: options.fairnessProof || null
|
||||
});
|
||||
const gameState = game.getState();
|
||||
gameState.blockHeight = blockHeight;
|
||||
gameState.isEarlyEntry = true;
|
||||
gameState.deathBlock = blockHeight + 1; // Explicit death block for client display
|
||||
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// Deducts credits or links the payment for this entry.
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
|
||||
if (!startRes.success) {
|
||||
if (this.activeGames) this.activeGames.delete(serverId);
|
||||
await this._cleanupOrphanedGame(game);
|
||||
return { success: false, reason: startRes.reason || 'Payment processing failed' };
|
||||
}
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] ⚡ Early entry game started for ${serverId} at block ${blockHeight} (dies at ${blockHeight + 1})`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to start early game');
|
||||
console.error('[QueueManager] Error starting early game:', normalized.message);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + normalized.message);
|
||||
return { success: false, reason: normalized.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a game record from the DB when processGameStart fails.
|
||||
* Prevents orphaned "active" records that never complete.
|
||||
*/
|
||||
async _cleanupOrphanedGame(game) {
|
||||
if (game && game.dbId && this.gameModeManager && this.gameModeManager.db) {
|
||||
await this.gameModeManager.db.query(
|
||||
`UPDATE games
|
||||
SET status = 'expired', outcome = 'aborted', completed_at = NOW()
|
||||
WHERE id = $1 AND entry_consumed_at IS NULL AND status = 'active'`,
|
||||
[game.dbId]
|
||||
).catch(err => console.error('[QueueManager] Failed to clean up orphaned game:', err.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymized view of players waiting in queue, used by the spectator system to show
|
||||
* upcoming games.
|
||||
* @returns {Array} List of pending game entries
|
||||
*/
|
||||
getPendingGamesList() {
|
||||
return this._waitingPlayers.map(entry => ({
|
||||
playerId: entry.serverId.substring(0, 6), // Anonymized
|
||||
queuedAt: entry.entryTime,
|
||||
isConfirmed: entry.confirmed,
|
||||
waitingForConfirmation: entry.requiresConfirmation && !entry.confirmed
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full queue details for admin dashboard (not anonymized)
|
||||
* @returns {Array} Full queue entries with all details
|
||||
*/
|
||||
getQueueDetailsForAdmin() {
|
||||
const now = Date.now();
|
||||
return this._waitingPlayers.map((entry, index) => ({
|
||||
position: index + 1,
|
||||
serverId: entry.serverId,
|
||||
playerId: entry.serverId.substring(0, 6), // For display
|
||||
entryTime: entry.entryTime ? new Date(entry.entryTime).toISOString() : null,
|
||||
waitingSeconds: entry.entryTime ? Math.floor((now - entry.entryTime) / 1000) : 0,
|
||||
paymentId: entry.paymentId || null,
|
||||
userId: entry.userId || null,
|
||||
requiresConfirmation: !!entry.requiresConfirmation,
|
||||
confirmed: !!entry.confirmed,
|
||||
isValuable: !!(entry.paymentId || entry.userId)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QueueManager;
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Rate-limit context resolver.
|
||||
*
|
||||
* Produces a STABLE identity and the real client IP for rate limiting, so limits cannot
|
||||
* be bypassed by simply reconnecting (which mints a fresh socket.id).
|
||||
*
|
||||
* - 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 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) {
|
||||
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 (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stableId(socket, sessionManager) {
|
||||
try {
|
||||
const u = sessionManager?.sessions?.get(socket.id);
|
||||
if (u && u.id != null) return `u:${u.id}`;
|
||||
} catch (_) {}
|
||||
return `s:${socket.id}`;
|
||||
}
|
||||
|
||||
module.exports = { clientIp, stableId };
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Rate Limiter Module
|
||||
* Provides rate limiting functionality for various user actions
|
||||
*/
|
||||
|
||||
class RateLimiter {
|
||||
constructor(options = {}) {
|
||||
// Default rate limits. SocketHandlers passes a `limits` override that takes
|
||||
// precedence (merged below); these defaults stay aligned with it so a RateLimiter
|
||||
// constructed without options behaves the same.
|
||||
this.limits = {
|
||||
'payment:create': { window: 60000, max: 3 }, // 3 payments per minute
|
||||
'game:start': { window: 60000, max: 15 }, // 15 game starts per minute
|
||||
'game:queue': { window: 30000, max: 5 }, // 5 queue attempts per 30s
|
||||
'chat:message': { window: 10000, max: 12 }, // 12 messages per 10s
|
||||
'address:set': { window: 300000, max: 3 }, // 3 address changes per 5min
|
||||
'move:player': { window: 1000, max: 50 }, // 50 moves per second
|
||||
'connection:new': { window: 60000, max: 10 }, // 10 connections per minute per IP
|
||||
// Identity reads each perform a users SELECT + UPDATE (last_active) and an
|
||||
// entitlements SELECT. Without a limit they can be pipelined at packet rate,
|
||||
// turning one socket into a database load generator.
|
||||
'identity:read': { window: 10000, max: 20 }, // 20 identity/address reads per 10s
|
||||
...options.limits
|
||||
};
|
||||
|
||||
// In-process storage; a multi-instance deployment needs a shared store such as Redis.
|
||||
this.storage = new Map(); // key: userId:action, value: { count, firstAttempt }
|
||||
this.ipStorage = new Map(); // key: ip:action, value: { count, firstAttempt }
|
||||
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
|
||||
|
||||
this.debugMode = options.debugMode || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is rate limited for a user
|
||||
* @param {string} userId - User identifier (socket ID)
|
||||
* @param {string} action - Action type (e.g., 'payment:create')
|
||||
* @param {string} ip - Optional IP address for IP-based limits
|
||||
* @returns {Object} { allowed: boolean, retryAfter: number, remaining: number }
|
||||
*/
|
||||
async checkLimit(userId, action, ip = null) {
|
||||
const limit = this.limits[action];
|
||||
if (!limit) {
|
||||
return { allowed: true, remaining: Infinity, retryAfter: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const key = `${userId}:${action}`;
|
||||
|
||||
const userResult = this._checkSingleLimit(key, limit, now);
|
||||
|
||||
let ipResult = { allowed: true, remaining: Infinity, retryAfter: 0 };
|
||||
if (ip && this._shouldApplyIpLimit(action)) {
|
||||
const ipKey = `${ip}:${action}`;
|
||||
ipResult = this._checkSingleLimit(ipKey, limit, now, this.ipStorage);
|
||||
}
|
||||
|
||||
// Most restrictive of the two applies.
|
||||
const result = {
|
||||
allowed: userResult.allowed && ipResult.allowed,
|
||||
remaining: Math.min(userResult.remaining, ipResult.remaining),
|
||||
retryAfter: Math.max(userResult.retryAfter, ipResult.retryAfter)
|
||||
};
|
||||
|
||||
if (this.debugMode && !result.allowed) {
|
||||
console.log(`🚫 Rate limit exceeded for ${userId}:${action}. Remaining: ${result.remaining}, RetryAfter: ${result.retryAfter}ms`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an action should have IP-based rate limiting applied
|
||||
* @param {string} action - Action type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldApplyIpLimit(action) {
|
||||
// Limited by IP in addition to socket ID, so reconnecting cannot reset the counter.
|
||||
const ipLimitedPrefixes = [
|
||||
'connection:', // Connection attempts
|
||||
'payment:', // Payment creation/operations
|
||||
'game:', // Game start/queue operations
|
||||
'address:', // Address changes
|
||||
'identity:', // Identity/entitlement reads, each of which hits the database
|
||||
'chat:' // Chat messages
|
||||
];
|
||||
return ipLimitedPrefixes.some(prefix => action.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an action attempt (increment counter)
|
||||
* @param {string} userId - User identifier
|
||||
* @param {string} action - Action type
|
||||
* @param {string} ip - Optional IP address
|
||||
*/
|
||||
async recordAttempt(userId, action, ip = null) {
|
||||
const now = Date.now();
|
||||
const key = `${userId}:${action}`;
|
||||
|
||||
this._recordSingleAttempt(key, now);
|
||||
|
||||
if (ip && this._shouldApplyIpLimit(action)) {
|
||||
const ipKey = `${ip}:${action}`;
|
||||
this._recordSingleAttempt(ipKey, now, this.ipStorage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current usage stats for a user/action
|
||||
* @param {string} userId
|
||||
* @param {string} action
|
||||
* @returns {Object} { count: number, remaining: number, resetTime: number }
|
||||
*/
|
||||
getUsageStats(userId, action) {
|
||||
const limit = this.limits[action];
|
||||
if (!limit) return { count: 0, remaining: Infinity, resetTime: 0 };
|
||||
|
||||
const key = `${userId}:${action}`;
|
||||
const data = this.storage.get(key);
|
||||
|
||||
if (!data) {
|
||||
return { count: 0, remaining: limit.max, resetTime: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const resetTime = data.firstAttempt + limit.window;
|
||||
|
||||
if (now > resetTime) {
|
||||
return { count: 0, remaining: limit.max, resetTime: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
count: data.count,
|
||||
remaining: Math.max(0, limit.max - data.count),
|
||||
resetTime: resetTime
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rate limit data for a user (useful for admin overrides)
|
||||
* @param {string} userId
|
||||
* @param {string} action - Optional, if not provided clears all actions for user
|
||||
*/
|
||||
clearLimits(userId, action = null) {
|
||||
if (action) {
|
||||
const key = `${userId}:${action}`;
|
||||
this.storage.delete(key);
|
||||
} else {
|
||||
for (const key of this.storage.keys()) {
|
||||
if (key.startsWith(`${userId}:`)) {
|
||||
this.storage.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to check a single limit
|
||||
*/
|
||||
_checkSingleLimit(key, limit, now, storage = this.storage) {
|
||||
const data = storage.get(key);
|
||||
|
||||
if (!data) {
|
||||
return { allowed: true, remaining: limit.max - 1, retryAfter: 0 };
|
||||
}
|
||||
|
||||
if (now - data.firstAttempt > limit.window) {
|
||||
storage.delete(key);
|
||||
return { allowed: true, remaining: limit.max - 1, retryAfter: 0 };
|
||||
}
|
||||
|
||||
if (data.count >= limit.max) {
|
||||
const retryAfter = (data.firstAttempt + limit.window) - now;
|
||||
return { allowed: false, remaining: 0, retryAfter: Math.max(0, retryAfter) };
|
||||
}
|
||||
|
||||
return { allowed: true, remaining: limit.max - data.count - 1, retryAfter: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to record a single attempt
|
||||
*/
|
||||
_recordSingleAttempt(key, now, storage = this.storage) {
|
||||
const data = storage.get(key);
|
||||
|
||||
if (!data) {
|
||||
storage.set(key, { count: 1, firstAttempt: now });
|
||||
} else {
|
||||
const limit = this._getLimitFromKey(key);
|
||||
if (limit && now - data.firstAttempt > limit.window) {
|
||||
storage.set(key, { count: 1, firstAttempt: now });
|
||||
} else {
|
||||
data.count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get limit config from storage key.
|
||||
*
|
||||
* Storage keys are `${id}:${action}`, and the id itself contains colons: `u:123` / `s:<socketId>`
|
||||
* from rateLimitContext.stableId, and raw IPv6 addresses in ipStorage. The action is therefore
|
||||
* matched as a key suffix rather than by splitting on the first colon, which stays correct no
|
||||
* matter how many colons the id contains. An unmatched key would make `cleanup()` skip the entry
|
||||
* (leaking it for the process lifetime) and stop `_recordSingleAttempt` from resetting an
|
||||
* expired window.
|
||||
*/
|
||||
_getLimitFromKey(key) {
|
||||
for (const action of Object.keys(this.limits)) {
|
||||
if (key === action || key.endsWith(`:${action}`)) return this.limits[action];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired entries to prevent memory leaks
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [key, data] of this.storage.entries()) {
|
||||
const limit = this._getLimitFromKey(key);
|
||||
if (limit && now - data.firstAttempt > limit.window) {
|
||||
this.storage.delete(key);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, data] of this.ipStorage.entries()) {
|
||||
const limit = this._getLimitFromKey(key);
|
||||
if (limit && now - data.firstAttempt > limit.window) {
|
||||
this.ipStorage.delete(key);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugMode && cleanedCount > 0) {
|
||||
console.log(`🧹 RateLimiter cleanup: removed ${cleanedCount} expired entries`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current statistics about rate limiter state
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalKeys: this.storage.size + this.ipStorage.size,
|
||||
userKeys: this.storage.size,
|
||||
ipKeys: this.ipStorage.size,
|
||||
limits: Object.keys(this.limits).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tracked entries (for dashboard)
|
||||
*/
|
||||
getTrackedCount() {
|
||||
return this.storage.size + this.ipStorage.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of currently blocked users (rough estimate)
|
||||
*/
|
||||
getBlockedCount() {
|
||||
const now = Date.now();
|
||||
let blocked = 0;
|
||||
for (const [key, data] of this.storage.entries()) {
|
||||
const limit = this._getLimitFromKey(key);
|
||||
if (limit && data.count >= limit.max && (now - data.firstAttempt) < limit.window) {
|
||||
blocked++;
|
||||
}
|
||||
}
|
||||
return blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the rate limiter (cleanup interval)
|
||||
*/
|
||||
shutdown() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RateLimiter;
|
||||
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* SessionManager
|
||||
* Issues and resumes anonymous session tokens to persist credits & payout address across reconnects.
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
const { createFinancialRecoveryError } = require('../utils/financialRecoveryError');
|
||||
|
||||
class SessionManager {
|
||||
constructor({ db, debugManager, gameModeManager, io = null }) {
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.io = io;
|
||||
this.sessions = new Map(); // Keyed by socket id
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupExpiredSessions();
|
||||
}, 3600000); // Hourly
|
||||
}
|
||||
|
||||
async resumeOrCreate({ socketId, ipAddress, resumeToken }) {
|
||||
try {
|
||||
if (resumeToken) {
|
||||
const result = await this.db.query(
|
||||
'SELECT * FROM users WHERE anon_token = $1',
|
||||
[resumeToken]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const selectedUser = result.rows[0];
|
||||
|
||||
const recovered = await this.recoverPendingPayments(selectedUser.id, socketId);
|
||||
|
||||
// Compare-and-swap the bearer credential and socket ownership in one UPDATE. Two
|
||||
// concurrent resumes may both read the old token, but exactly one can consume it;
|
||||
// the losing connection is rejected instead of receiving an already-stale token.
|
||||
const issuedToken = this.generateSecureToken();
|
||||
const rotated = await this.db.query(`
|
||||
UPDATE users
|
||||
SET socket_id = $1, last_seen = NOW(), anon_token = $2
|
||||
WHERE id = $3 AND anon_token = $4
|
||||
RETURNING *
|
||||
`, [socketId, issuedToken, selectedUser.id, resumeToken]);
|
||||
if (rotated.rows.length !== 1) {
|
||||
const replayError = new Error('Session token was already consumed by another resume');
|
||||
replayError.code = 'SESSION_TOKEN_REPLAY';
|
||||
throw replayError;
|
||||
}
|
||||
const user = rotated.rows[0];
|
||||
|
||||
// A bearer rotation revokes every older live socket for this stable user. Evict and
|
||||
// disconnect them before caching the newly authenticated socket.
|
||||
this.disconnectUserSessions([user.id], [], { exceptSocketId: socketId });
|
||||
this.sessions.set(socketId, user);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Resumed session for user ${user.id}`);
|
||||
return {
|
||||
resumed: true,
|
||||
token: issuedToken,
|
||||
user: {
|
||||
...user,
|
||||
socket_id: socketId
|
||||
},
|
||||
recovered
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newToken = this.generateSecureToken();
|
||||
|
||||
const result = await this.db.query(
|
||||
'INSERT INTO users (socket_id, ip_address, anon_token, created_at, last_seen) VALUES ($1, $2, $3, NOW(), NOW()) RETURNING *',
|
||||
[socketId, ipAddress, newToken]
|
||||
);
|
||||
|
||||
const newUser = result.rows[0];
|
||||
this.sessions.set(socketId, newUser);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Created new anonymous user ${newUser.id}`);
|
||||
return {
|
||||
resumed: false,
|
||||
token: newToken,
|
||||
user: newUser
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code === 'SESSION_TOKEN_REPLAY') throw error;
|
||||
const normalized = normalizeError(error, 'Failed to resume or create session');
|
||||
console.error('[SessionManager] Error in resumeOrCreate:', normalized.message);
|
||||
throw normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async getBySocket(socketId) {
|
||||
if (this.sessions.has(socketId)) {
|
||||
return this.sessions.get(socketId);
|
||||
}
|
||||
|
||||
const result = await this.db.query(
|
||||
'SELECT * FROM users WHERE socket_id = $1',
|
||||
[socketId]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const user = result.rows[0];
|
||||
this.sessions.set(socketId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateUser(userId, updates) {
|
||||
// Column names come from this allowlist only; values are always parameterized.
|
||||
const allowedFields = ['credits', 'payout_address', 'last_seen'];
|
||||
const updateFields = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
updateFields.push(`${key} = $${paramIndex++}`);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
throw new Error('No valid fields to update');
|
||||
}
|
||||
|
||||
values.push(userId);
|
||||
|
||||
const query = `UPDATE users SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`;
|
||||
await this.db.query(query, values);
|
||||
|
||||
// Evict the cached row so the next getBySocket re-reads the updated columns.
|
||||
for (const [socketId, user] of this.sessions.entries()) {
|
||||
if (user.id === userId) {
|
||||
this.sessions.delete(socketId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover confirmed payments whose entitlement was never delivered: credits_package
|
||||
* invoices confirmed without credits added, and unconsumed single_game invoices.
|
||||
*/
|
||||
async recoverPendingPayments(userId, socketId) {
|
||||
const result = { creditsRecovered: 0, paymentsProcessed: 0 };
|
||||
|
||||
try {
|
||||
// Confirmed credits_package payments with credits_purchased null or 0: confirmation
|
||||
// was never processed through to a credit grant.
|
||||
const unprocessedPayments = await this.db.query(`
|
||||
SELECT p.id, p.payment_type, p.description, p.expected_amount, p.confirmed_at
|
||||
FROM payments p
|
||||
WHERE p.user_id = $1
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'credits_package'
|
||||
AND (p.credits_purchased IS NULL OR p.credits_purchased = 0)
|
||||
AND p.confirmed_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
// Payments where credits_purchased is set but no matching ledger row exists, so the
|
||||
// balance was never actually moved.
|
||||
const orphanedPayments = await this.db.query(`
|
||||
SELECT p.id, p.credits_purchased, p.confirmed_at
|
||||
FROM payments p
|
||||
WHERE p.user_id = $1
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'credits_package'
|
||||
AND p.credits_purchased > 0
|
||||
AND p.confirmed_at > NOW() - INTERVAL '7 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM credit_transactions ct
|
||||
WHERE ct.user_id = p.user_id
|
||||
AND ct.amount = p.credits_purchased
|
||||
AND ct.reason IN ('package_purchase', 'package_purchase_recovered')
|
||||
AND ct.created_at >= p.confirmed_at - INTERVAL '1 minute'
|
||||
)
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
// Both sets take the same recovery path.
|
||||
for (const orphan of orphanedPayments.rows) {
|
||||
unprocessedPayments.rows.push({
|
||||
id: orphan.id,
|
||||
payment_type: 'credits_package',
|
||||
description: null,
|
||||
expected_amount: null,
|
||||
confirmed_at: orphan.confirmed_at,
|
||||
_creditsFromRecord: orphan.credits_purchased
|
||||
});
|
||||
}
|
||||
|
||||
// An empty credits_package set must not short-circuit the method: the single_game
|
||||
// recovery below still has to run.
|
||||
for (const payment of (unprocessedPayments.rows.length === 0 ? [] : unprocessedPayments.rows)) {
|
||||
try {
|
||||
// Prefer the stored credit count; otherwise parse it out of the description.
|
||||
let creditsToAdd = payment._creditsFromRecord || 0;
|
||||
if (!creditsToAdd) {
|
||||
creditsToAdd = 10; // Fallback when the description carries no count
|
||||
const desc = payment.description || '';
|
||||
const match = desc.match(/(\d+)\s*credits?/i);
|
||||
if (match) {
|
||||
creditsToAdd = parseInt(match[1], 10) || 10;
|
||||
}
|
||||
}
|
||||
const isOrphaned = !!payment._creditsFromRecord;
|
||||
|
||||
// The row lock serializes recovery of a payment across instances, so a payment
|
||||
// cannot be credited twice.
|
||||
const recovered = await this.db.withTransaction(async (client) => {
|
||||
const lockResult = await client.query(`
|
||||
SELECT id, credits_purchased
|
||||
FROM payments
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [payment.id]);
|
||||
|
||||
if (!lockResult.rows[0]) return null;
|
||||
|
||||
// Idempotency marker differs per case: credits_purchased for a plain
|
||||
// unprocessed payment, an existing ledger row for an orphaned one.
|
||||
if (!isOrphaned) {
|
||||
if (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0) {
|
||||
return null; // Already processed
|
||||
}
|
||||
} else {
|
||||
const txCheck = await client.query(`
|
||||
SELECT 1 FROM credit_transactions
|
||||
WHERE user_id = $1
|
||||
AND amount = $2
|
||||
AND reason IN ('package_purchase', 'package_purchase_recovered')
|
||||
AND created_at >= $3::timestamp - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [userId, creditsToAdd, payment.confirmed_at]);
|
||||
if (txCheck.rows.length > 0) {
|
||||
return null; // Already recovered
|
||||
}
|
||||
}
|
||||
|
||||
const updateResult = await client.query(`
|
||||
UPDATE users
|
||||
SET credits = credits + $1,
|
||||
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1
|
||||
WHERE id = $2
|
||||
RETURNING credits
|
||||
`, [creditsToAdd, userId]);
|
||||
|
||||
const newBalance = updateResult.rows[0]?.credits ?? 0;
|
||||
|
||||
// Marking the payment and writing the ledger row share the transaction with
|
||||
// the balance update, so all three commit together or not at all.
|
||||
await client.query(`
|
||||
UPDATE payments
|
||||
SET credits_purchased = $1
|
||||
WHERE id = $2
|
||||
`, [creditsToAdd, payment.id]);
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'package_purchase_recovered', $3, 'purchase')
|
||||
`, [userId, creditsToAdd, newBalance]);
|
||||
|
||||
return { creditsToAdd, newBalance };
|
||||
});
|
||||
|
||||
if (recovered) {
|
||||
result.creditsRecovered += recovered.creditsToAdd;
|
||||
result.paymentsProcessed++;
|
||||
console.log(`💰 [PaymentRecovery] Recovered ${recovered.creditsToAdd} credits for user ${userId} from payment ${payment.id}`);
|
||||
} else {
|
||||
console.log(`ℹ️ [PaymentRecovery] Payment ${payment.id} already processed by another instance`);
|
||||
}
|
||||
} catch (paymentError) {
|
||||
console.error(`[PaymentRecovery] Failed to process payment ${payment.id}:`, paymentError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmed single_game payments no games row references were paid for but never
|
||||
// played. Grant the equivalent of one paid game in credits instead. Idempotent via
|
||||
// the per-payment entitlement grant and reason key.
|
||||
try {
|
||||
const creditsPerGame = (this.gameModeManager && this.gameModeManager.creditsPerGameCost) || 1;
|
||||
const unconsumed = await this.db.query(`
|
||||
SELECT p.id, p.confirmed_at
|
||||
FROM payments p
|
||||
WHERE p.user_id = $1
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'single_game'
|
||||
AND p.confirmed_at > NOW() - INTERVAL '7 days'
|
||||
AND NOT EXISTS (SELECT 1 FROM games g WHERE g.payment_id = p.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM payment_entitlement_grants peg WHERE peg.payment_id = p.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM credit_transactions ct
|
||||
WHERE ct.user_id = p.user_id AND ct.reason = 'single_game_recovered:' || p.id
|
||||
)
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
for (const payment of unconsumed.rows) {
|
||||
try {
|
||||
const recovered = await this.db.withTransaction(async (client) => {
|
||||
// Re-check ownership, status and consumption under the lock so concurrent
|
||||
// instances cannot both credit the same invoice.
|
||||
const lock = await client.query(`
|
||||
SELECT id, user_id, status, payment_type
|
||||
FROM payments
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [payment.id]);
|
||||
if (!lock.rows[0]) return null;
|
||||
if (String(lock.rows[0].user_id) !== String(userId)
|
||||
|| lock.rows[0].status !== 'confirmed'
|
||||
|| lock.rows[0].payment_type !== 'single_game') return null;
|
||||
|
||||
const gameCheck = await client.query(`SELECT 1 FROM games WHERE payment_id = $1 LIMIT 1`, [payment.id]);
|
||||
if (gameCheck.rows.length > 0) return null; // A game consumed it in the meantime
|
||||
|
||||
const existingGrant = await client.query(
|
||||
`SELECT payment_id FROM payment_entitlement_grants WHERE payment_id = $1 FOR UPDATE`,
|
||||
[payment.id]
|
||||
);
|
||||
if (existingGrant.rows.length > 0) return null;
|
||||
|
||||
const reason = `single_game_recovered:${payment.id}`;
|
||||
const dup = await client.query(
|
||||
`SELECT 1 FROM credit_transactions WHERE user_id = $1 AND reason = $2 LIMIT 1`,
|
||||
[userId, reason]
|
||||
);
|
||||
if (dup.rows.length > 0) {
|
||||
// Ledger rows written before migration 035 carry only the reason key. Treat
|
||||
// that key as authoritative: materialize the canonical grant marker for it
|
||||
// without granting credits again.
|
||||
await client.query(`
|
||||
INSERT INTO payment_entitlement_grants (
|
||||
payment_id, user_id, source, credits_granted, metadata
|
||||
) VALUES ($1, $2, 'single_game_recovery', $3, $4::jsonb)
|
||||
ON CONFLICT (payment_id) DO NOTHING
|
||||
`, [payment.id, userId, creditsPerGame, JSON.stringify({ legacyReason: reason })]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// This unique payment-scoped marker is the grant claim. It is inserted before
|
||||
// the balance mutation in the same transaction, so concurrent recovery and a
|
||||
// later direct-game start cannot both consume the invoice.
|
||||
const grant = await client.query(`
|
||||
INSERT INTO payment_entitlement_grants (
|
||||
payment_id, user_id, source, credits_granted, metadata
|
||||
) VALUES ($1, $2, 'single_game_recovery', $3, $4::jsonb)
|
||||
ON CONFLICT (payment_id) DO NOTHING
|
||||
RETURNING payment_id
|
||||
`, [payment.id, userId, creditsPerGame, JSON.stringify({ reason })]);
|
||||
if (grant.rowCount !== 1) return null;
|
||||
|
||||
const upd = await client.query(
|
||||
`UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`,
|
||||
[creditsPerGame, userId]
|
||||
);
|
||||
const newBalance = upd.rows[0]?.credits ?? 0;
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (
|
||||
user_id, amount, reason, balance_after, transaction_type, payment_id
|
||||
) VALUES ($1, $2, $3, $4, 'recovery', $5)
|
||||
`, [userId, creditsPerGame, reason, newBalance, payment.id]);
|
||||
return { creditsToAdd: creditsPerGame };
|
||||
});
|
||||
|
||||
if (recovered) {
|
||||
result.creditsRecovered += recovered.creditsToAdd;
|
||||
result.paymentsProcessed++;
|
||||
console.log(`💰 [PaymentRecovery] Recovered unconsumed single_game payment ${payment.id} as ${recovered.creditsToAdd} credit(s) for user ${userId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[PaymentRecovery] Failed to recover single_game payment ${payment.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PaymentRecovery] single_game recovery error:', e.message);
|
||||
}
|
||||
|
||||
if (result.creditsRecovered > 0) {
|
||||
console.log(`💰 [PaymentRecovery] Total recovered for user ${userId}: ${result.creditsRecovered} credits from ${result.paymentsProcessed} payments`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PaymentRecovery] Error recovering payments:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
// Only disposable anonymous users are deleted: long-idle, zero credits, no payout
|
||||
// address, and no history in any table referencing users through a RESTRICT/NO ACTION
|
||||
// foreign key. The NOT EXISTS guards keep the DELETE from aborting on an FK violation
|
||||
// and keep users with payments, payouts, or games on record.
|
||||
const result = await this.db.query(
|
||||
`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`,
|
||||
[]
|
||||
);
|
||||
|
||||
if (result.rowCount > 0) {
|
||||
console.log(`[SessionManager] Cleaned up ${result.rowCount} expired sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to cleanup expired sessions');
|
||||
console.error('[SessionManager] Error cleaning up sessions:', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize games left at status='active' by a server kill. On a fresh boot no game can
|
||||
* legitimately still be running, so every such row is an orphan: restore an exact,
|
||||
* durably recorded PAID_CREDITS debit and then settle the game's status. A mode label,
|
||||
* payment link, or current entry price is not debit evidence; direct-payment refunds go
|
||||
* through the explicit refund workflow. Repeat runs are safe: each game is locked FOR
|
||||
* UPDATE and skipped unless still 'active', and each restoration has a per-game ledger
|
||||
* key. index.js calls this at startup right after recoverPendingPayments.
|
||||
*/
|
||||
async recoverOrphanedGames() {
|
||||
const summary = {
|
||||
ok: true,
|
||||
scanned: 0,
|
||||
finalized: 0,
|
||||
refunded: 0,
|
||||
creditsRefunded: 0,
|
||||
unresolved: []
|
||||
};
|
||||
let orphans;
|
||||
try {
|
||||
orphans = await this.db.query(
|
||||
`SELECT g.id
|
||||
FROM games g
|
||||
WHERE g.status = 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM solo_restart_snapshots s WHERE s.game_id = g.id
|
||||
)
|
||||
ORDER BY g.id ASC`,
|
||||
[]
|
||||
);
|
||||
} catch (error) {
|
||||
summary.ok = false;
|
||||
throw createFinancialRecoveryError('orphaned_solo_games', {
|
||||
...summary,
|
||||
scanFailed: true,
|
||||
resolved: 0
|
||||
}, error);
|
||||
}
|
||||
|
||||
const rows = orphans.rows || [];
|
||||
summary.scanned = rows.length;
|
||||
for (const orphan of rows) {
|
||||
try {
|
||||
const outcome = await this.db.withTransaction(async (client) => {
|
||||
// Re-check under the lock: another instance or a prior run may already have
|
||||
// finalized this game.
|
||||
const lock = await client.query(
|
||||
`SELECT g.id, g.user_id, g.game_mode, g.payment_id, g.status,
|
||||
g.entry_consumed_at, g.entry_credits_spent
|
||||
FROM games g
|
||||
WHERE g.id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM solo_restart_snapshots s WHERE s.game_id = g.id
|
||||
)
|
||||
FOR UPDATE`,
|
||||
[orphan.id]
|
||||
);
|
||||
const game = lock.rows[0];
|
||||
if (!game || game.status !== 'active') return null;
|
||||
|
||||
const reason = `orphan_game_refunded:${game.id}`;
|
||||
let refunded = false;
|
||||
let creditsRefunded = 0;
|
||||
|
||||
if (game.user_id) {
|
||||
// The per-game reason key makes the refund idempotent.
|
||||
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) {
|
||||
// Refund only against durable debit evidence. An active row labelled
|
||||
// PAID_CREDITS is not proof a credit was taken: a start can persist the game
|
||||
// and then fail before its transaction commits. The current configured cost
|
||||
// is not used either, since it may have changed since entry was accepted.
|
||||
const spent = Number(game.entry_credits_spent || 0);
|
||||
const doRefund = game.game_mode === 'PAID_CREDITS'
|
||||
&& game.entry_consumed_at != null
|
||||
&& Number.isSafeInteger(spent)
|
||||
&& spent > 0;
|
||||
|
||||
if (doRefund) {
|
||||
const upd = await client.query(
|
||||
`UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`,
|
||||
[spent, 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, spent, reason, newBalance]
|
||||
);
|
||||
refunded = true;
|
||||
creditsRefunded = spent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, creditsRefunded };
|
||||
});
|
||||
|
||||
if (outcome) {
|
||||
summary.finalized++;
|
||||
if (outcome.refunded) {
|
||||
summary.refunded++;
|
||||
summary.creditsRefunded += outcome.creditsRefunded;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[OrphanRecovery] Failed to recover game ${orphan.id}:`, e.message);
|
||||
summary.unresolved.push({ type: 'game', id: orphan.id });
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.unresolved.length > 0) {
|
||||
summary.ok = false;
|
||||
throw createFinancialRecoveryError('orphaned_solo_games', {
|
||||
...summary,
|
||||
resolved: summary.finalized
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.finalized > 0) {
|
||||
console.log(`[OrphanRecovery] Finalized ${summary.finalized} orphaned game(s); refunded ${summary.refunded} (${summary.creditsRefunded} credits).`);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
generateSecureToken() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict and disconnect cached sockets belonging to one or more stable users. Extra socket ids
|
||||
* cover sockets not present in the cache. Token rotation and wallet-account adoption call this
|
||||
* so an old live socket cannot keep exercising paid actions from a stale in-memory session.
|
||||
*/
|
||||
disconnectUserSessions(userIds, extraSocketIds = [], { exceptSocketId = null } = {}) {
|
||||
const targetUsers = new Set((Array.isArray(userIds) ? userIds : [userIds])
|
||||
.filter(id => id != null)
|
||||
.map(String));
|
||||
const socketIds = new Set((Array.isArray(extraSocketIds) ? extraSocketIds : [extraSocketIds])
|
||||
.filter(Boolean));
|
||||
|
||||
for (const [cachedSocketId, cachedUser] of this.sessions) {
|
||||
if (targetUsers.has(String(cachedUser?.id))) socketIds.add(cachedSocketId);
|
||||
}
|
||||
|
||||
for (const staleSocketId of socketIds) {
|
||||
if (!staleSocketId || staleSocketId === exceptSocketId) continue;
|
||||
this.sessions.delete(staleSocketId);
|
||||
try {
|
||||
const socket = this.io?.sockets?.sockets?.get?.(staleSocketId);
|
||||
if (socket) socket.disconnect(true);
|
||||
} catch (_) {
|
||||
// DB token revocation is authoritative; socket teardown is best-effort defense in depth.
|
||||
}
|
||||
}
|
||||
return Array.from(socketIds).filter(id => id !== exceptSocketId);
|
||||
}
|
||||
|
||||
async rotateToken(userId, presentedToken) {
|
||||
if (!presentedToken) {
|
||||
const missingToken = new Error('A presented token is required for rotation');
|
||||
missingToken.code = 'SESSION_TOKEN_REPLAY';
|
||||
throw missingToken;
|
||||
}
|
||||
const newToken = this.generateSecureToken();
|
||||
|
||||
const rotated = await this.db.query(
|
||||
`UPDATE users SET anon_token = $1
|
||||
WHERE id = $2 AND anon_token = $3
|
||||
RETURNING id`,
|
||||
[newToken, userId, presentedToken]
|
||||
);
|
||||
if (rotated.rows.length !== 1) {
|
||||
const replayError = new Error('Session token was already consumed by another rotation');
|
||||
replayError.code = 'SESSION_TOKEN_REPLAY';
|
||||
throw replayError;
|
||||
}
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict a socket's cached session row. Called on disconnect to bound the `sessions` map
|
||||
* and to keep stale rows from being served on reconnect. A later getBySocket re-reads
|
||||
* from the database.
|
||||
*/
|
||||
removeSocket(socketId) {
|
||||
this.sessions.delete(socketId);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SessionManager;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
const HTTP_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
|
||||
function normalizedConfiguredOrigin(hostedBy) {
|
||||
if (typeof hostedBy !== 'string' || hostedBy.trim() === '') return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(hostedBy.trim());
|
||||
if (!HTTP_PROTOCOLS.has(parsed.protocol) || parsed.username || parsed.password) return null;
|
||||
return parsed.origin;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedRequestOrigin(origin) {
|
||||
if (typeof origin !== 'string' || origin.trim() === '') return null;
|
||||
|
||||
try {
|
||||
const raw = origin.trim();
|
||||
// URL normalizes dot-segments, so validate the Origin header's shape first. A browser
|
||||
// Origin is only scheme://authority (an optional trailing slash is tolerated).
|
||||
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+\/?$/.test(raw)) return null;
|
||||
const parsed = new URL(raw);
|
||||
if (!HTTP_PROTOCOLS.has(parsed.protocol)
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.pathname !== '/'
|
||||
|| parsed.search
|
||||
|| parsed.hash) {
|
||||
return null;
|
||||
}
|
||||
return parsed.origin;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Socket.IO's allowRequest hook from an immutable environment snapshot.
|
||||
*
|
||||
* Development keeps the existing permissive behavior. Production requires a valid public
|
||||
* HOSTED_BY origin before any handshake is accepted. Once configured, requests without an
|
||||
* Origin header remain available to health probes and non-browser clients; browser handshakes
|
||||
* must present the exact normalized HTTP(S) scheme + host (including any non-default port).
|
||||
*/
|
||||
function createSocketOriginAllowRequest(env = process.env) {
|
||||
const production = String(env.NODE_ENV || '').trim().toLowerCase() === 'production';
|
||||
const expectedOrigin = production ? normalizedConfiguredOrigin(env.HOSTED_BY) : null;
|
||||
|
||||
return (req, callback) => {
|
||||
if (!production) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Missing or malformed production HOSTED_BY is configuration-invalid. Reject every
|
||||
// handshake rather than silently degrading to wildcard browser access.
|
||||
if (!expectedOrigin) {
|
||||
callback(null, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = req && req.headers;
|
||||
const hasOrigin = !!headers && Object.prototype.hasOwnProperty.call(headers, 'origin');
|
||||
if (!hasOrigin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestOrigin = normalizedRequestOrigin(headers.origin);
|
||||
callback(null, requestOrigin !== null && requestOrigin === expectedOrigin);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSocketOriginAllowRequest,
|
||||
normalizedConfiguredOrigin,
|
||||
normalizedRequestOrigin
|
||||
};
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Spectator Manager
|
||||
* Manages spectator connections and game broadcasts
|
||||
*
|
||||
* Design considerations for scalability:
|
||||
* - Efficient tracking of spectators per game
|
||||
* - Periodic game list broadcasts instead of per-update
|
||||
* - Room-based broadcasting using Socket.IO rooms
|
||||
* - Memory-efficient cleanup of stale spectators
|
||||
*/
|
||||
|
||||
class SpectatorManager {
|
||||
constructor({ io, activeGames, broadcastManager, debugManager, queueManager = null }) {
|
||||
this.io = io;
|
||||
this.activeGames = activeGames;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.queueManager = queueManager; // For pending games list
|
||||
|
||||
// Map of gameId -> Set of spectator socket IDs
|
||||
this._spectatorsByGame = new Map();
|
||||
|
||||
// Map of socketId -> gameId (for quick lookup of what a spectator is watching)
|
||||
this._spectatorWatching = new Map();
|
||||
|
||||
// Pending game subscriptions: Map of playerId -> Set of spectator socket IDs
|
||||
// Spectators waiting for a pending game to start
|
||||
this._pendingSubscriptions = new Map();
|
||||
|
||||
// Cached game list for efficient broadcasting
|
||||
this._cachedGameList = [];
|
||||
this._gameListLastUpdate = 0;
|
||||
this._gameListCacheTimeout = 2000; // 2 second cache
|
||||
|
||||
// Periodic game list broadcast interval
|
||||
this._broadcastInterval = null;
|
||||
this._broadcastIntervalMs = 3000; // Broadcast every 3 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Set queue manager (for late binding)
|
||||
*/
|
||||
setQueueManager(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager - start periodic broadcasts
|
||||
*/
|
||||
initialize() {
|
||||
this._broadcastInterval = setInterval(() => {
|
||||
this._broadcastGameListToLobby();
|
||||
}, this._broadcastIntervalMs);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log('👁️ SpectatorManager initialized');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active games with spectator-safe info
|
||||
* @param {Object} options - Filtering/pagination options
|
||||
* @returns {Object} Object containing active games and pending games
|
||||
*/
|
||||
getActiveGamesList(options = {}) {
|
||||
const { page = 1, pageSize = 20, sortBy = 'newest' } = options;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this._gameListLastUpdate < this._gameListCacheTimeout && this._cachedGameList.length > 0) {
|
||||
return this._applyPagination(this._cachedGameList, page, pageSize);
|
||||
}
|
||||
|
||||
const games = [];
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
if (game && game.gameState === 'active') {
|
||||
games.push(this._buildGameSummary(socketId, game));
|
||||
}
|
||||
}
|
||||
|
||||
this._sortGames(games, sortBy);
|
||||
|
||||
this._cachedGameList = games;
|
||||
this._gameListLastUpdate = now;
|
||||
|
||||
return this._applyPagination(games, page, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of pending games (players waiting in queue)
|
||||
* @returns {Array} List of pending games
|
||||
*/
|
||||
getPendingGamesList() {
|
||||
if (!this.queueManager || typeof this.queueManager.getPendingGamesList !== 'function') {
|
||||
return [];
|
||||
}
|
||||
return this.queueManager.getPendingGamesList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a spectator-safe game summary
|
||||
* @private
|
||||
*/
|
||||
_buildGameSummary(socketId, game) {
|
||||
const spectatorCount = this._spectatorsByGame.get(game.id)?.size || 0;
|
||||
|
||||
return {
|
||||
gameId: game.id,
|
||||
playerId: socketId.substring(0, 6), // Anonymized player ID
|
||||
startedAt: game.startedAt,
|
||||
moveCount: game.moveCount || 0,
|
||||
hasTreasure: game.isComplete ? (game.player?.hasTreasure || false) : undefined,
|
||||
spectatorCount: spectatorCount,
|
||||
// Exact positions are withheld here; spectators receive them on joining a game.
|
||||
dungeonSize: {
|
||||
width: game.width || 25,
|
||||
height: game.height || 19
|
||||
},
|
||||
difficulty: game.difficultyConfig?.presetName || 'normal',
|
||||
durationSeconds: game.startedAt ? Math.floor((Date.now() - game.startedAt) / 1000) : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort games array by criteria
|
||||
* @private
|
||||
*/
|
||||
_sortGames(games, sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
games.sort((a, b) => b.startedAt - a.startedAt);
|
||||
break;
|
||||
case 'oldest':
|
||||
games.sort((a, b) => a.startedAt - b.startedAt);
|
||||
break;
|
||||
case 'spectators':
|
||||
games.sort((a, b) => b.spectatorCount - a.spectatorCount);
|
||||
break;
|
||||
case 'moves':
|
||||
games.sort((a, b) => b.moveCount - a.moveCount);
|
||||
break;
|
||||
default:
|
||||
games.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pagination to games list
|
||||
* @private
|
||||
*/
|
||||
_applyPagination(games, page, pageSize) {
|
||||
const totalGames = games.length;
|
||||
const totalPages = Math.ceil(totalGames / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
|
||||
return {
|
||||
games: games.slice(startIndex, endIndex),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
totalGames,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a spectator to a game
|
||||
* @param {string} spectatorSocketId - Socket ID of the spectator
|
||||
* @param {string} gameId - ID of the game to spectate
|
||||
* @returns {Object} Result with success status and initial game state
|
||||
*/
|
||||
addSpectator(spectatorSocketId, gameId) {
|
||||
let targetGame = null;
|
||||
let playerSocketId = null;
|
||||
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
if (game && game.id === gameId) {
|
||||
targetGame = game;
|
||||
playerSocketId = socketId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetGame) {
|
||||
return { success: false, reason: 'Game not found or has ended' };
|
||||
}
|
||||
|
||||
if (targetGame.gameState !== 'active') {
|
||||
return { success: false, reason: 'Game is no longer active' };
|
||||
}
|
||||
|
||||
const currentlyWatching = this._spectatorWatching.get(spectatorSocketId);
|
||||
if (currentlyWatching === gameId) {
|
||||
return { success: false, reason: 'Already spectating this game' };
|
||||
}
|
||||
|
||||
// If spectating another game, leave it first
|
||||
if (currentlyWatching) {
|
||||
this.removeSpectator(spectatorSocketId);
|
||||
}
|
||||
|
||||
if (!this._spectatorsByGame.has(gameId)) {
|
||||
this._spectatorsByGame.set(gameId, new Set());
|
||||
}
|
||||
this._spectatorsByGame.get(gameId).add(spectatorSocketId);
|
||||
|
||||
this._spectatorWatching.set(spectatorSocketId, gameId);
|
||||
|
||||
const spectatorSocket = this._getSocket(spectatorSocketId);
|
||||
if (spectatorSocket) {
|
||||
spectatorSocket.join(`spectate:${gameId}`);
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Spectator ${spectatorSocketId.substring(0,6)} joined game ${gameId.substring(0,8)}`);
|
||||
}
|
||||
|
||||
const initialState = this._getSpectatorGameState(targetGame, playerSocketId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
gameId: gameId,
|
||||
playerSocketId: playerSocketId,
|
||||
initialState: initialState,
|
||||
spectatorCount: this._spectatorsByGame.get(gameId).size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a spectator from their current game
|
||||
* @param {string} spectatorSocketId - Socket ID of the spectator
|
||||
* @returns {boolean} Whether removal was successful
|
||||
*/
|
||||
removeSpectator(spectatorSocketId) {
|
||||
const gameId = this._spectatorWatching.get(spectatorSocketId);
|
||||
if (!gameId) return false;
|
||||
|
||||
const spectators = this._spectatorsByGame.get(gameId);
|
||||
if (spectators) {
|
||||
spectators.delete(spectatorSocketId);
|
||||
if (spectators.size === 0) {
|
||||
this._spectatorsByGame.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
this._spectatorWatching.delete(spectatorSocketId);
|
||||
|
||||
const spectatorSocket = this._getSocket(spectatorSocketId);
|
||||
if (spectatorSocket) {
|
||||
spectatorSocket.leave(`spectate:${gameId}`);
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👁️ Spectator ${spectatorSocketId.substring(0,6)} left game ${gameId.substring(0,8)}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get what game a socket is currently spectating
|
||||
* @param {string} socketId - Socket ID to check
|
||||
* @returns {string|null} Game ID or null
|
||||
*/
|
||||
getSpectatingGame(socketId) {
|
||||
return this._spectatorWatching.get(socketId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a socket is currently spectating any game
|
||||
* @param {string} socketId - Socket ID to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSpectating(socketId) {
|
||||
return this._spectatorWatching.has(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spectator count for a game
|
||||
* @param {string} gameId - Game ID
|
||||
* @returns {number}
|
||||
*/
|
||||
getSpectatorCount(gameId) {
|
||||
return this._spectatorsByGame.get(gameId)?.size || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game update to all spectators of a game
|
||||
* @param {string} gameId - Game ID
|
||||
* @param {Object} gameState - Updated game state
|
||||
*/
|
||||
broadcastToSpectators(gameId, gameState) {
|
||||
const spectators = this._spectatorsByGame.get(gameId);
|
||||
if (!spectators || spectators.size === 0) return;
|
||||
|
||||
// Use Socket.IO room for efficient broadcasting
|
||||
this.io.to(`spectate:${gameId}`).emit('spectator_update', {
|
||||
gameId: gameId,
|
||||
gameState: gameState,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify spectators that a game has ended and release them.
|
||||
*
|
||||
* Must be called for every way a game can leave `activeGames`, not just committed settlement.
|
||||
* Spectators of a game that vanishes any other way stay in the `spectate:<gameId>` room,
|
||||
* still tracked and already out of the lobby room, so they receive neither further updates
|
||||
* nor `spectate_ended` and are stuck on a dead view.
|
||||
*
|
||||
* @param {string} gameId - Game ID
|
||||
* @param {Object} gameOverData - Game over information
|
||||
* @param {string} [reason] - Why the view ended ('game_over' | 'player_disconnected' | ...)
|
||||
*/
|
||||
notifyGameEnded(gameId, gameOverData, reason = 'game_over') {
|
||||
const spectators = this._spectatorsByGame.get(gameId);
|
||||
|
||||
if (spectators && spectators.size > 0) {
|
||||
this.io.to(`spectate:${gameId}`).emit('spectate_ended', {
|
||||
gameId: gameId,
|
||||
reason,
|
||||
gameOverData: gameOverData
|
||||
});
|
||||
|
||||
for (const spectatorId of spectators) {
|
||||
this._spectatorWatching.delete(spectatorId);
|
||||
const sock = this._getSocket(spectatorId);
|
||||
if (sock) sock.leave(`spectate:${gameId}`);
|
||||
}
|
||||
this._spectatorsByGame.delete(gameId);
|
||||
}
|
||||
|
||||
// Zeroing the timestamp invalidates the cache so the lobby broadcast rebuilds the list.
|
||||
this._gameListLastUpdate = 0;
|
||||
this._broadcastGameListToLobby();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spectator-safe game state
|
||||
* @private
|
||||
*/
|
||||
_getSpectatorGameState(game, playerSocketId) {
|
||||
if (typeof game.getState === 'function') {
|
||||
const state = game.getState();
|
||||
return {
|
||||
...state,
|
||||
playerId: playerSocketId.substring(0, 6),
|
||||
isSpectating: true
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for legacy game structure
|
||||
return {
|
||||
player: game.player ? {
|
||||
x: game.player.x,
|
||||
y: game.player.y,
|
||||
hasTreasure: undefined // Hidden from spectators during live games
|
||||
} : null,
|
||||
monster: game.monster ? {
|
||||
x: game.monster.x,
|
||||
y: game.monster.y
|
||||
} : null,
|
||||
visibleTiles: game.visibleTiles,
|
||||
lighting: game.lightingAndFov?.getLightingData ? game.lightingAndFov.getLightingData() : null,
|
||||
torches: game.dungeon?.torches || [],
|
||||
playerId: playerSocketId.substring(0, 6),
|
||||
isSpectating: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game list to users in the lobby (not in a game, not spectating)
|
||||
* @private
|
||||
*/
|
||||
_broadcastGameListToLobby() {
|
||||
const gameListData = this.getActiveGamesList({ page: 1, pageSize: 50 });
|
||||
const pendingGames = this.getPendingGamesList();
|
||||
gameListData.pendingGames = pendingGames;
|
||||
|
||||
// Always broadcast, even when empty: clients need to clear their lists
|
||||
this.io.to('lobby').emit('active_games', gameListData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a socket to the lobby room
|
||||
* @param {string} socketId - Socket ID
|
||||
*/
|
||||
joinLobby(socketId) {
|
||||
const sock = this._getSocket(socketId);
|
||||
if (sock) {
|
||||
sock.join('lobby');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a socket from the lobby room
|
||||
* @param {string} socketId - Socket ID
|
||||
*/
|
||||
leaveLobby(socketId) {
|
||||
const sock = this._getSocket(socketId);
|
||||
if (sock) {
|
||||
sock.leave('lobby');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a socket by ID
|
||||
* @private
|
||||
*/
|
||||
_getSocket(socketId) {
|
||||
return this.io.sockets.sockets.get(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about spectator system
|
||||
* @returns {Object}
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
totalSpectators: this._spectatorWatching.size,
|
||||
gamesBeingWatched: this._spectatorsByGame.size,
|
||||
cachedGameListSize: this._cachedGameList.length,
|
||||
spectatorsByGame: Object.fromEntries(
|
||||
Array.from(this._spectatorsByGame.entries()).map(([gameId, set]) => [
|
||||
gameId.substring(0, 8),
|
||||
set.size
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when a socket disconnects
|
||||
* @param {string} socketId - Socket ID that disconnected
|
||||
*/
|
||||
handleDisconnect(socketId) {
|
||||
this.removeSpectator(socketId);
|
||||
// The lobby room is left automatically on disconnect, so only spectator state needs clearing.
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the manager
|
||||
*/
|
||||
shutdown() {
|
||||
if (this._broadcastInterval) {
|
||||
clearInterval(this._broadcastInterval);
|
||||
this._broadcastInterval = null;
|
||||
}
|
||||
this._spectatorsByGame.clear();
|
||||
this._spectatorWatching.clear();
|
||||
this._cachedGameList = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SpectatorManager;
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Suspended Game Manager
|
||||
* Handles preservation and restoration of game states when users disconnect and reconnect.
|
||||
*
|
||||
* This allows users who disconnect during:
|
||||
* - Active gameplay in the dungeon
|
||||
* - Waiting in queue for next block
|
||||
* - Awaiting payment confirmation
|
||||
*
|
||||
* to seamlessly resume their session when they reconnect.
|
||||
*/
|
||||
|
||||
class SuspendedGameManager {
|
||||
constructor({ debugManager, activeGames, cleanupTimeoutMs = 300000 }) {
|
||||
this.debugManager = debugManager;
|
||||
this.activeGames = activeGames;
|
||||
|
||||
// Map of DB userId -> suspended game state
|
||||
this.suspendedGames = new Map();
|
||||
|
||||
// Map of DB userId -> cleanup timeout handle
|
||||
this.cleanupTimers = new Map();
|
||||
|
||||
// How long to keep suspended games before cleanup (default 5 minutes)
|
||||
this.cleanupTimeoutMs = cleanupTimeoutMs;
|
||||
|
||||
// Statistics
|
||||
this.stats = {
|
||||
gamesSuspended: 0,
|
||||
gamesRestored: 0,
|
||||
gamesExpired: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a game when user disconnects
|
||||
* @param {number} dbUserId - Database user ID
|
||||
* @param {string} oldSocketId - The disconnecting socket ID
|
||||
* @param {Object} game - The game instance
|
||||
* @param {Object} additionalState - Additional state to preserve
|
||||
* @returns {boolean} True if game was suspended
|
||||
*/
|
||||
suspendGame(dbUserId, oldSocketId, game, additionalState = {}) {
|
||||
if (!dbUserId || !game) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const suspendedState = {
|
||||
game: game,
|
||||
originalSocketId: oldSocketId,
|
||||
suspendedAt: Date.now(),
|
||||
gameId: game.id,
|
||||
playerPosition: game.player ? { x: game.player.x, y: game.player.y } : null,
|
||||
hasTreasure: game.player?.hasTreasure || false,
|
||||
moveCount: game.moveCount || 0,
|
||||
blockHeight: game.blockHeight || null,
|
||||
// Entry block height, used to keep the reconnect block-timeout continuous.
|
||||
// Prefers an explicit value from the caller, then the value captured on the
|
||||
// game object at creation. A missing value stays null so the timeout logic
|
||||
// treats it as "not yet observed" rather than as an immediate expiry.
|
||||
blockRec: (additionalState.blockRec !== undefined && additionalState.blockRec !== null)
|
||||
? additionalState.blockRec
|
||||
: (game.blockRec ?? game.blockHeight ?? null),
|
||||
...additionalState
|
||||
};
|
||||
|
||||
this.suspendedGames.set(dbUserId, suspendedState);
|
||||
this.stats.gamesSuspended++;
|
||||
|
||||
// Ordinary network disconnects get a bounded in-memory grace period. A durable restart
|
||||
// snapshot does not: PostgreSQL is the authority, and the reconstructed object must stay
|
||||
// available until its owner reconnects or an operator resolves the active row. Expiring
|
||||
// the Map entry alone would strand the durable active game as unresumable.
|
||||
if (additionalState.durableRestartSnapshot !== true) {
|
||||
this._scheduleCleanup(dbUserId);
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🔄 [SuspendedGameManager] Suspended game ${game.id} for user ${dbUserId}`);
|
||||
console.log(` Position: (${suspendedState.playerPosition?.x}, ${suspendedState.playerPosition?.y})`);
|
||||
console.log(` Has treasure: ${suspendedState.hasTreasure}`);
|
||||
console.log(` Moves: ${suspendedState.moveCount}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a suspended game
|
||||
* @param {number} dbUserId - Database user ID
|
||||
* @returns {boolean} True if user has suspended game
|
||||
*/
|
||||
hasSuspendedGame(dbUserId) {
|
||||
return this.suspendedGames.has(dbUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suspended game state without removing it
|
||||
* @param {number} dbUserId - Database user ID
|
||||
* @returns {Object|null} Suspended game state or null
|
||||
*/
|
||||
getSuspendedState(dbUserId) {
|
||||
return this.suspendedGames.get(dbUserId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a suspended game for a reconnecting user
|
||||
* @param {number} dbUserId - Database user ID
|
||||
* @param {string} newSocketId - The new socket ID
|
||||
* @param {Object} newUser - The new User object
|
||||
* @returns {Promise<Object|null>} The restored game or null if no suspended game
|
||||
*/
|
||||
async restoreGame(dbUserId, newSocketId, newUser) {
|
||||
const suspended = this.suspendedGames.get(dbUserId);
|
||||
if (!suspended) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const game = suspended.game;
|
||||
|
||||
// A settlement retry may have committed after disconnect but before reconnect. Never
|
||||
// resurrect that terminal object as an active game; the durable result is authoritative.
|
||||
if (game?.settlementCommitted) {
|
||||
this.suspendedGames.delete(dbUserId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Claim a durable restart snapshot and realign the socket in one PostgreSQL statement
|
||||
// before exposing the game as playable. If the row is no longer exactly active for this
|
||||
// game/user/seed, no snapshot is consumed and no in-memory state is revived.
|
||||
const db = game.db || suspended.db || null;
|
||||
if (suspended.durableRestartSnapshot === true) {
|
||||
if (!db || !game.dbId || !game.id) {
|
||||
throw new Error('Durable solo restart snapshot is missing its database identity');
|
||||
}
|
||||
const claimed = await db.query(`
|
||||
WITH valid_game AS MATERIALIZED (
|
||||
SELECT id
|
||||
FROM games
|
||||
WHERE id = $1 AND user_id = $2 AND dungeon_seed = $3
|
||||
AND status = 'active' AND completed_at IS NULL
|
||||
FOR UPDATE
|
||||
), consumed AS (
|
||||
DELETE FROM solo_restart_snapshots s
|
||||
USING valid_game g
|
||||
WHERE s.game_id = g.id AND s.user_id = $2 AND s.snapshot_version = $4
|
||||
RETURNING s.game_id
|
||||
)
|
||||
UPDATE games
|
||||
SET socket_id = $5
|
||||
WHERE id = $1 AND EXISTS (SELECT 1 FROM consumed)
|
||||
RETURNING id
|
||||
`, [game.dbId, dbUserId, game.id, suspended.snapshotVersion, newSocketId]);
|
||||
if (claimed.rowCount !== 1) {
|
||||
const error = new Error('Durable solo restart snapshot could not be claimed');
|
||||
error.code = 'SOLO_RESTART_SNAPSHOT_CLAIM_FAILED';
|
||||
throw error;
|
||||
}
|
||||
} else if (db && game.id) {
|
||||
const aligned = await db.query(
|
||||
`UPDATE games SET socket_id = $1
|
||||
WHERE dungeon_seed = $2 AND status = 'active' AND completed_at IS NULL
|
||||
RETURNING id`,
|
||||
[newSocketId, game.id]
|
||||
);
|
||||
if (aligned.rowCount !== 1) {
|
||||
const error = new Error('Suspended solo game is no longer active');
|
||||
error.code = 'SUSPENDED_GAME_REALIGN_FAILED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache and game object are mutated only after the durable claim succeeds, so a transient
|
||||
// database failure leaves the suspended entry intact for a later reconnect attempt.
|
||||
this._cancelCleanup(dbUserId);
|
||||
|
||||
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; the backfill
|
||||
// covers game objects that carry only one of the two aliases.
|
||||
if (game.userId == null && game.dbUserId != null) game.userId = game.dbUserId;
|
||||
if (game.dbUserId == null && game.userId != null) game.dbUserId = game.userId;
|
||||
|
||||
if (newUser) {
|
||||
game.user = newUser;
|
||||
// A terminal-pending object is restored only as an ownership lock while its DB
|
||||
// transaction retries. Re-counting it as a newly joined game would corrupt in-memory
|
||||
// stats and leave currentGame pointing at an ended run after settlement succeeds.
|
||||
if (!game.settlementPending && !game.settlementCommitted) newUser.joinGame(game);
|
||||
}
|
||||
|
||||
this.activeGames.set(newSocketId, game);
|
||||
this.suspendedGames.delete(dbUserId);
|
||||
this.stats.gamesRestored++;
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`✅ [SuspendedGameManager] Restored game ${game.id} for user ${dbUserId}`);
|
||||
console.log(` Old socket: ${suspended.originalSocketId} -> New socket: ${newSocketId}`);
|
||||
console.log(` Suspended for: ${Math.round((Date.now() - suspended.suspendedAt) / 1000)}s`);
|
||||
}
|
||||
|
||||
return {
|
||||
game,
|
||||
suspendedState: suspended,
|
||||
// Exposed so the reconnect path can restore memUser.blockRec and keep the
|
||||
// block-timeout logic continuous.
|
||||
blockRec: suspended.blockRec ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a suspended game without restoring it
|
||||
* @param {number} dbUserId - Database user ID
|
||||
* @returns {boolean} True if game was removed
|
||||
*/
|
||||
removeSuspendedGame(dbUserId, { countExpired = true } = {}) {
|
||||
this._cancelCleanup(dbUserId);
|
||||
const had = this.suspendedGames.delete(dbUserId);
|
||||
if (had && countExpired) {
|
||||
this.stats.gamesExpired++;
|
||||
}
|
||||
return had;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
* @returns {Object} Statistics object
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
...this.stats,
|
||||
currentSuspended: this.suspendedGames.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule cleanup for a suspended game
|
||||
* @private
|
||||
*/
|
||||
_scheduleCleanup(dbUserId) {
|
||||
this._cancelCleanup(dbUserId);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const suspended = this.suspendedGames.get(dbUserId);
|
||||
if (suspended) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`⏰ [SuspendedGameManager] Game ${suspended.gameId} expired for user ${dbUserId}`);
|
||||
}
|
||||
this.suspendedGames.delete(dbUserId);
|
||||
this.cleanupTimers.delete(dbUserId);
|
||||
this.stats.gamesExpired++;
|
||||
}
|
||||
}, this.cleanupTimeoutMs);
|
||||
|
||||
// Unref so a pending grace period never holds the process open.
|
||||
if (timer.unref) timer.unref();
|
||||
|
||||
this.cleanupTimers.set(dbUserId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel cleanup timer for a user
|
||||
* @private
|
||||
*/
|
||||
_cancelCleanup(dbUserId) {
|
||||
const timer = this.cleanupTimers.get(dbUserId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.cleanupTimers.delete(dbUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all suspended games (for graceful shutdown)
|
||||
*/
|
||||
cleanup() {
|
||||
for (const [userId, timer] of this.cleanupTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.cleanupTimers.clear();
|
||||
this.suspendedGames.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SuspendedGameManager;
|
||||
@@ -0,0 +1,293 @@
|
||||
const Room = require('../multiplayer/Room');
|
||||
const Appearance = require('../multiplayer/appearance');
|
||||
const Entitlements = require('../multiplayer/entitlements');
|
||||
const SocketChatProvider = require('./chat/SocketChatProvider');
|
||||
const { stableId, clientIp } = require('./rateLimitContext');
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* TavernManager: transport and lifecycle for the Tavern mode.
|
||||
*
|
||||
* Owns a shared Room, a server-tick timer, and Socket.IO room broadcasts. Structured like
|
||||
* SpectatorManager. Disabled unless TAVERN_ENABLED=true: when disabled, no timer runs and
|
||||
* join requests are refused, so this is inert on instances that don't opt in.
|
||||
*
|
||||
* Movement is server-authoritative. Clients send a direction; the Room validates it. State
|
||||
* is broadcast to occupants once per server tick (clients render from these snapshots).
|
||||
*/
|
||||
class TavernManager {
|
||||
constructor({ io, debugManager, roomId = 'main', tickMs = null, roomData = null, roomUrl = null, entitlementProvider = null, globalChatProvider = null, chatModeration = null } = {}) {
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.enabled = process.env.TAVERN_ENABLED === 'true';
|
||||
this.roomId = roomId;
|
||||
|
||||
// Snapshot broadcast cadence. Default 100ms (10 Hz); override with TAVERN_TICK_MS.
|
||||
const envTick = parseInt(process.env.TAVERN_TICK_MS, 10);
|
||||
this.tickMs = tickMs || (Number.isFinite(envTick) && envTick > 0 ? envTick : 100);
|
||||
|
||||
this.moveCooldownMs = 60; // per-occupant flood guard
|
||||
this._lastMoveAt = new Map(); // socketId -> timestamp
|
||||
this.chatCooldownMs = 900; // per-occupant chat flood guard
|
||||
this._lastChatAt = new Map(); // socketId -> timestamp
|
||||
this._tickInterval = null;
|
||||
this.entitlementProvider = entitlementProvider;
|
||||
|
||||
this.room = new Room({ id: this.roomId, type: 'tavern', roomData: roomData, roomUrl: roomUrl });
|
||||
|
||||
// Chat prefers the shared global provider (persistent history plus cross-page/relay
|
||||
// fan-out) so the tavern participates in one global chat with backlog. Without one
|
||||
// injected, the ephemeral tavern-scoped provider is used instead.
|
||||
this.globalChatProvider = globalChatProvider;
|
||||
this.chatProvider = new SocketChatProvider({ io: this.io, debugManager: this.debugManager });
|
||||
// Guards applied before anything reaches global chat (ban list, shared rate limiter,
|
||||
// user_id attribution). Optional so a tavern-only/ephemeral instance still works.
|
||||
this.chatModeration = chatModeration;
|
||||
}
|
||||
|
||||
get channel() {
|
||||
return `tavern:${this.roomId}`;
|
||||
}
|
||||
|
||||
/** Start the server tick. No-op (and no timer) unless enabled. */
|
||||
initialize() {
|
||||
if (!this.enabled) return;
|
||||
this._tickInterval = setInterval(() => this._tick(), this.tickMs);
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`Tavern enabled (room '${this.roomId}', tick ${this.tickMs}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
_tick() {
|
||||
this.room.tick();
|
||||
if (this.room.size > 0) {
|
||||
this.io.to(this.channel).emit('tavern_update', this.room.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
_sanitizeName(name) {
|
||||
if (typeof name !== 'string') return null;
|
||||
// Keep letters/numbers/space and a few separators; cap length.
|
||||
const cleaned = name.replace(/[^\p{L}\p{N} _.-]/gu, '').trim().slice(0, 16);
|
||||
return cleaned.length ? cleaned : null;
|
||||
}
|
||||
|
||||
_validateAvatar(avatar) {
|
||||
return Appearance.isValidAvatar(avatar) ? avatar : 'default';
|
||||
}
|
||||
|
||||
async _entitlementsFor(socket) {
|
||||
if (typeof this.entitlementProvider !== 'function') return { premium: false, totalCreditsPurchased: 0 };
|
||||
try {
|
||||
return await this.entitlementProvider(socket);
|
||||
} catch (_) {
|
||||
return { premium: false, totalCreditsPurchased: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeAppearance(data = {}, entitlements = {}) {
|
||||
const raw = data.appearance || { avatar: data.avatar };
|
||||
return Entitlements.normalizeAppearance(raw, entitlements);
|
||||
}
|
||||
|
||||
_normalizeDir(data) {
|
||||
if (data && typeof data.dir === 'string') {
|
||||
switch (data.dir) {
|
||||
case 'up': return { dx: 0, dy: -1 };
|
||||
case 'down': return { dx: 0, dy: 1 };
|
||||
case 'left': return { dx: -1, dy: 0 };
|
||||
case 'right': return { dx: 1, dy: 0 };
|
||||
default: return { dx: 0, dy: 0 };
|
||||
}
|
||||
}
|
||||
return {
|
||||
dx: Math.sign(Number(data && data.dx) || 0),
|
||||
dy: Math.sign(Number(data && data.dy) || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async join(socket, data = {}) {
|
||||
if (!this.enabled) {
|
||||
socket.emit('tavern_error', { message: 'Tavern is not enabled on this server.' });
|
||||
return { success: false };
|
||||
}
|
||||
const name = this._sanitizeName(data.name);
|
||||
const entitlements = await this._entitlementsFor(socket);
|
||||
const appearance = this._normalizeAppearance(data, entitlements);
|
||||
const avatar = appearance.avatar;
|
||||
const occ = this.room.addOccupant(socket.id, { name, avatar, appearance });
|
||||
if (!occ) {
|
||||
socket.emit('tavern_error', { message: 'The tavern is full.' });
|
||||
return { success: false };
|
||||
}
|
||||
socket.join(this.channel);
|
||||
// Full state (map + occupants) to the joiner; the next tick shows the arrival to others.
|
||||
socket.emit('tavern_joined', { you: socket.id, state: this.room.fullState() });
|
||||
|
||||
// Recent global chat backlog so the tavern shows history on arrival.
|
||||
if (this.globalChatProvider && typeof this.globalChatProvider.getHistory === 'function') {
|
||||
this.globalChatProvider.getHistory({ scope: 'global', limit: 50 })
|
||||
.then(messages => { if (messages && messages.length) socket.emit('chat_history', { messages }); })
|
||||
.catch(err => { if (this.debugManager?.CONSOLE_LOGGING) console.error('[Tavern] history load failed:', err.message); });
|
||||
}
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[Tavern] +${String(socket.id).slice(0, 6)} (${this.room.size} present)`);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
move(socket, data = {}) {
|
||||
if (!this.enabled) return;
|
||||
if (!this.room.getOccupant(socket.id)) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - (this._lastMoveAt.get(socket.id) || 0) < this.moveCooldownMs) return;
|
||||
this._lastMoveAt.set(socket.id, now);
|
||||
|
||||
const { dx, dy } = this._normalizeDir(data);
|
||||
if (dx === 0 && dy === 0) return;
|
||||
this.room.moveOccupant(socket.id, dx, dy); // the next tick broadcasts the result
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a tavern message.
|
||||
*
|
||||
* When a global chat provider is injected this reaches every connected client and the persisted
|
||||
* history, so it must clear the same bar as the lobby path: chat ban, the reconnect-proof rate
|
||||
* limiter, and user_id attribution. Async: callers are wrapped by the socket safe-dispatch, and
|
||||
* a moderation lookup failure must never publish.
|
||||
*/
|
||||
async chat(socket, data = {}) {
|
||||
if (!this.enabled) return;
|
||||
const occ = this.room.getOccupant(socket.id);
|
||||
if (!occ) return; // must be in the tavern to talk
|
||||
|
||||
let text = (data && typeof data.text === 'string') ? data.text.trim() : '';
|
||||
if (!text) return;
|
||||
if (text.length > TavernManager.MAX_CHAT_LENGTH) text = text.slice(0, TavernManager.MAX_CHAT_LENGTH);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - (this._lastChatAt.get(socket.id) || 0) < this.chatCooldownMs) {
|
||||
// Tell the sender instead of silently dropping: a silent drop with a cleared
|
||||
// input reads as "my message disappeared".
|
||||
this._notice(socket, 'Easy, wait a moment before your next message.');
|
||||
return;
|
||||
}
|
||||
|
||||
const moderation = await this._moderateChat(socket);
|
||||
if (!moderation.allowed) {
|
||||
this._notice(socket, moderation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastChatAt.set(socket.id, now);
|
||||
|
||||
const username = occ.name || String(socket.id).slice(0, 6);
|
||||
// Escape here: delivery is trusted-escaped and rendered as HTML on the client. Routing to
|
||||
// global chat (persisted, broadcast to everyone, relayed over nostr when enabled) keeps the
|
||||
// tavern in one global conversation; tavern-scoped is used when no global provider exists.
|
||||
const provider = this.globalChatProvider || this.chatProvider;
|
||||
const scope = this.globalChatProvider ? 'global' : this.channel;
|
||||
provider.publish({
|
||||
scope,
|
||||
username,
|
||||
text: escapeHtml(text),
|
||||
ts: now,
|
||||
socketId: socket.id,
|
||||
userId: moderation.userId
|
||||
});
|
||||
}
|
||||
|
||||
_notice(socket, message) {
|
||||
if (socket && typeof socket.emit === 'function') {
|
||||
socket.emit('tavern_notice', { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared-chat guards. Returns { allowed, message, userId }.
|
||||
*
|
||||
* Only enforced for messages that actually enter global chat; a tavern-scoped fallback room is
|
||||
* ephemeral and stays governed by the per-occupant cooldown alone.
|
||||
*/
|
||||
async _moderateChat(socket) {
|
||||
const mod = this.chatModeration;
|
||||
if (!mod || !this.globalChatProvider) return { allowed: true, userId: null };
|
||||
|
||||
if (mod.rateLimiter) {
|
||||
const rlId = stableId(socket, mod.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const verdict = await mod.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
|
||||
if (!verdict.allowed) {
|
||||
return { allowed: false, message: 'Please slow down before your next message.', userId: null };
|
||||
}
|
||||
await mod.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
|
||||
}
|
||||
|
||||
let userId = null;
|
||||
try {
|
||||
const row = mod.getOrCreateUser ? await mod.getOrCreateUser(socket.id) : null;
|
||||
userId = row?.id || null;
|
||||
} catch (e) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('[Tavern] chat user lookup failed:', e.message);
|
||||
}
|
||||
|
||||
if (userId && mod.chatHistory?.isUserChatBanned) {
|
||||
let banned = false;
|
||||
try {
|
||||
banned = await mod.chatHistory.isUserChatBanned(userId);
|
||||
} catch (e) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('[Tavern] ban lookup failed:', e.message);
|
||||
}
|
||||
if (banned) return { allowed: false, message: 'You have been banned from chat.', userId };
|
||||
}
|
||||
|
||||
return { allowed: true, userId };
|
||||
}
|
||||
|
||||
leave(socket) {
|
||||
this.handleDisconnect(socket.id, socket);
|
||||
}
|
||||
|
||||
handleDisconnect(socketId, socket = null) {
|
||||
const existed = this.room.removeOccupant(socketId);
|
||||
this._lastMoveAt.delete(socketId);
|
||||
this._lastChatAt.delete(socketId);
|
||||
if (existed) {
|
||||
const sock = socket || (this.io.sockets?.sockets?.get?.(socketId));
|
||||
if (sock) sock.leave(this.channel);
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[Tavern] -${String(socketId).slice(0, 6)} (${this.room.size} present)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return { enabled: this.enabled, roomId: this.roomId, occupants: this.room.size };
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this._tickInterval) {
|
||||
clearInterval(this._tickInterval);
|
||||
this._tickInterval = null;
|
||||
}
|
||||
this._lastMoveAt.clear();
|
||||
this._lastChatAt.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Cosmetic avatar ids the server accepts. Availability and unlocks are enforced by
|
||||
// the Entitlements policy during join.
|
||||
TavernManager.AVATARS = Appearance.avatarIds();
|
||||
TavernManager.PREMIUM_AVATARS = Appearance.premiumAvatarIds();
|
||||
TavernManager.MAX_CHAT_LENGTH = 200;
|
||||
|
||||
module.exports = TavernManager;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* TavernMatchBridge: connects active match rooms to tavern spectators.
|
||||
*
|
||||
* Subscribes to MatchManager match events and forwards a lightweight, public-only
|
||||
* snapshot of each active race to the tavern Socket.IO room. Tavern clients can then
|
||||
* list active races and watch one without leaving the tavern.
|
||||
*
|
||||
* No game secrets (seed, player input) are forwarded; only public state (positions,
|
||||
* standings, winnerId, economy) is broadcast.
|
||||
*/
|
||||
|
||||
class TavernMatchBridge {
|
||||
constructor({ matchManager, tavernManager, io, debugManager = null } = {}) {
|
||||
this.matchManager = matchManager;
|
||||
this.tavernManager = tavernManager;
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.enabled = process.env.TAVERN_ENABLED === 'true' && process.env.MATCH_ENABLED === 'true';
|
||||
}
|
||||
|
||||
_log(...args) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.log(...args);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (!this.enabled || !this.matchManager) return;
|
||||
// Wrap MatchManager's broadcast method so every match_tick also emits a public
|
||||
// summary to the tavern channel.
|
||||
const originalOnTick = this.matchManager.onTick.bind(this.matchManager);
|
||||
this.matchManager.onTick = (matchId, result) => {
|
||||
originalOnTick(matchId, result);
|
||||
this._forwardTick(matchId);
|
||||
};
|
||||
|
||||
const originalOnFinish = this.matchManager.onFinish.bind(this.matchManager);
|
||||
this.matchManager.onFinish = async (room) => {
|
||||
await originalOnFinish(room);
|
||||
this._forwardEnd(room);
|
||||
};
|
||||
|
||||
this._log('[TavernMatchBridge] initialized');
|
||||
}
|
||||
|
||||
_forwardTick(matchId) {
|
||||
const room = this.matchManager.rooms.get(matchId);
|
||||
if (!room) return;
|
||||
const payload = this._publicSnapshot(room);
|
||||
this.io?.to('tavern:main').emit('tavern_match_tick', payload);
|
||||
}
|
||||
|
||||
_forwardEnd(room) {
|
||||
const payload = {
|
||||
matchId: room.id,
|
||||
economy: room.economy,
|
||||
status: room.status,
|
||||
winnerId: room.winnerId,
|
||||
endReason: room.endReason,
|
||||
players: Array.from(room.playerStates.entries()).map(([id, state]) => ({
|
||||
id,
|
||||
placement: state.placement,
|
||||
escaped: state.escaped,
|
||||
hasTreasure: state.hasTreasure,
|
||||
score: state.score
|
||||
}))
|
||||
};
|
||||
this.io?.to('tavern:main').emit('tavern_match_end', payload);
|
||||
}
|
||||
|
||||
_publicSnapshot(room) {
|
||||
// Use the same renderer contract as a player match tick, but copy only explicitly public
|
||||
// fields. In particular, the committed seed hash is public while the unrevealed seed and
|
||||
// internal event/input state never cross into the tavern feed.
|
||||
const renderState = typeof room.toGameState === 'function' ? room.toGameState() : null;
|
||||
const fallbackMap = room.dungeon?.map || [];
|
||||
const players = renderState?.players || Array.from(room.occupants.values()).map(o => {
|
||||
const s = room.playerStates.get(o.id);
|
||||
return {
|
||||
id: o.id,
|
||||
x: o.x,
|
||||
y: o.y,
|
||||
name: o.name || null,
|
||||
avatar: o.avatar,
|
||||
appearance: o.appearance,
|
||||
facing: o.facing,
|
||||
alive: s ? s.alive : true,
|
||||
finished: s ? s.finished : false,
|
||||
escaped: s ? s.escaped : false,
|
||||
hasTreasure: s ? s.hasTreasure : false,
|
||||
placement: s ? s.placement : null
|
||||
};
|
||||
});
|
||||
return {
|
||||
matchId: room.id,
|
||||
economy: room.economy,
|
||||
variant: room.variant || renderState?.variant || 'race',
|
||||
rulesetId: room.ruleset?.id || room.variant || 'race',
|
||||
ruleset: renderState?.ruleset || (room._rulesetSummary?.() || null),
|
||||
status: room.status,
|
||||
tick: room.tickCount,
|
||||
visibleTiles: renderState?.visibleTiles || fallbackMap,
|
||||
exploredTiles: renderState?.exploredTiles || fallbackMap,
|
||||
lighting: renderState?.lighting || {},
|
||||
dungeonRows: renderState?.dungeonRows || room.rows || fallbackMap.length,
|
||||
dungeonCols: renderState?.dungeonCols || room.cols || fallbackMap[0]?.length || 0,
|
||||
players,
|
||||
monster: renderState?.monster || (room.monster ? { x: room.monster.x, y: room.monster.y } : null),
|
||||
treasure: renderState?.treasure || room.treasure,
|
||||
entrance: renderState?.entrance || room.dungeon?.entrance || null,
|
||||
exit: renderState?.exit || room.dungeon?.exit || null,
|
||||
winnerId: room.winnerId,
|
||||
endReason: room.endReason || null,
|
||||
seedHash: room.seedHash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of currently active matches for the tavern game-list UI.
|
||||
*/
|
||||
getActiveMatches() {
|
||||
if (!this.matchManager) return [];
|
||||
const list = [];
|
||||
for (const [id, room] of this.matchManager.rooms.entries()) {
|
||||
list.push(this._publicSnapshot(room));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TavernMatchBridge;
|
||||
Reference in New Issue
Block a user