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:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+175
View File
@@ -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;
+198
View File
@@ -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;
+55
View File
@@ -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;
+155
View File
@@ -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;
+65
View File
@@ -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;
+46
View File
@@ -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 };
+65
View File
@@ -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;
+61
View File
@@ -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 };
+456
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.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;
+398
View File
@@ -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;
+395
View File
@@ -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;
+160
View File
@@ -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;
+124
View File
@@ -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
};
+118
View File
@@ -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
};
+142
View File
@@ -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;
+821
View File
@@ -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;
+418
View File
@@ -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
+963
View File
@@ -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;
+964
View File
@@ -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;
+147
View File
@@ -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
};
+363
View File
@@ -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;
+328
View File
@@ -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;
+42
View File
@@ -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 };
+289
View File
@@ -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;
+635
View File
@@ -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
+81
View File
@@ -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
};
+459
View File
@@ -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;
+286
View File
@@ -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;
+293
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 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;
+129
View File
@@ -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;