Files
wownerogue/src/game/gameManager.js
T
jwinterm a62eefb47b fix: resolve security/correctness review + AAA graphics pass
Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater.

Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests.

Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles.

Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
2026-07-09 17:46:58 -04:00

389 lines
16 KiB
JavaScript

/**
* Game Manager Module
* Handles game creation, game over scenarios, and game lifecycle management
*/
const Game = require('../game/game');
class GameManager {
constructor({ activeGames, io, broadcastManager, debugManager, gameModeManager, spectatorManager = null }) {
this.activeGames = activeGames;
this.io = io;
this.broadcastManager = broadcastManager;
this.debugManager = debugManager;
this.gameModeManager = gameModeManager;
this.spectatorManager = spectatorManager;
}
/**
* Set the spectator manager (allows late binding after initialization)
* @param {SpectatorManager} spectatorManager
*/
setSpectatorManager(spectatorManager) {
this.spectatorManager = spectatorManager;
}
/**
* Create a new game for a user
* @param {Object} user - User object
* @param {string} gameType - Type of game ('standard', 'legacy')
* @param {Object} options - Additional game options
* @returns {Object} Created game instance
*/
async createGameForUser(user, gameType = 'standard', options = {}) {
let game;
if (gameType === 'legacy') {
game = Game.createLegacyGame(user.id, user, options);
} else {
game = Game.createStandardGame(user.id, user, options);
}
user.joinGame(game);
this.activeGames.set(user.id, game);
// Insert DB record and wait for it (needed for processGameStart to find the row)
await this._insertGameRecord(game, user);
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
}
return game;
}
/**
* Handle game over scenarios with comprehensive cleanup and payouts
* @param {Object} socket - Socket instance (or fake socket with just id)
* @param {Object} game - Game instance
* @param {string} status - Game status ('won', 'lost')
* @param {string} reason - Reason for game ending ('escaped', 'monster', 'timeout')
* @param {string} message - Message to display to user
* @param {number} score - Game score
*/
async handleGameOver(socket, game, status, reason, message, score = 0) {
try {
const socketId = socket.id || socket;
// IMMEDIATELY remove from active games to prevent double-processing.
// If escape + block timeout fire in the same event loop tick, the second
// call would find the game still here during the first call's await.
this.activeGames.delete(socketId);
game.gameState = status;
const moves = game.moveCount || 0;
const durationSeconds = game.startedAt ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) : null;
const finalScore = score > 0 ? score : this._calculateScore(game, status, reason);
const gameStats = {
score: finalScore,
reason: reason,
treasuresFound: game.player.hasTreasure ? 1 : 0,
moves,
durationSeconds
};
game.endGame(status, gameStats);
// Process game completion with payment system
let payoutInfo = null;
if (this.gameModeManager) {
try {
payoutInfo = await this.gameModeManager.completeGame(
socketId,
game.id,
status === 'won',
game.player.hasTreasure || false,
{ moves, durationSeconds, score: finalScore }
);
if (this.debugManager.CONSOLE_LOGGING && payoutInfo) {
console.log(`💰 Payout processed for ${socketId}:`, payoutInfo);
}
} catch (error) {
console.error('Error processing game completion:', error);
}
}
// Emit game over event with provably fair reveal
const proofReveal = game.getProofReveal ? game.getProofReveal() : null;
this.io.to(socketId).emit('game_over', {
status: status,
reason: reason,
message: message,
score: finalScore,
moves,
durationSeconds,
payout: payoutInfo,
treasure: game.player.hasTreasure || false,
proof: proofReveal
});
// Persist completion details if DB available
await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds);
// Notify spectators that the game has ended and broadcast updated list
if (this.spectatorManager) {
this.spectatorManager.notifyGameEnded(game.id, {
status,
reason,
message,
score: finalScore,
moves,
durationSeconds,
treasure: game.player.hasTreasure || false
});
}
// Broadcast leaderboard update if score > 0
if (finalScore > 0) {
try {
let displayName = null;
if (this.gameModeManager?.db) {
const userRow = await this.gameModeManager.db.query(
`SELECT COALESCE(display_name,
CASE WHEN payout_address IS NOT NULL
THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4)
ELSE 'Anon#' || id
END) as name
FROM users WHERE socket_id = $1`, [socketId]);
displayName = userRow.rows[0]?.name || 'Unknown';
}
this.io.emit('leaderboard_update', {
name: displayName || 'Unknown',
score: finalScore,
treasure: game.player.hasTreasure || false
});
// Global "win feed": announce actual escapes to everyone so the room feels
// alive (the leaderboard_update above also fires for lost-with-treasure runs;
// the feed should only celebrate real escapes). Payout amount is intentionally
// omitted — it's processed asynchronously and not reliably known yet here.
if (status === 'won') {
this.io.emit('win_feed', {
name: displayName || 'Someone',
treasure: game.player.hasTreasure || false,
score: finalScore,
paid: !!(payoutInfo && payoutInfo.amount)
});
}
} catch (lbErr) {
// Non-critical, don't block game over
if (this.debugManager.CONSOLE_LOGGING) {
console.warn('Leaderboard broadcast failed:', lbErr.message);
}
}
}
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason}), score: ${finalScore}`);
}
} catch (error) {
console.error('GameManager.handleGameOver error:', error);
// Still try to clean up even if there was an error
const socketId = socket?.id || socket;
if (socketId) {
this.activeGames.delete(socketId);
}
}
}
/**
* Check if monster caught player
* @param {Object} player - Player object
* @param {Object} monster - Monster object
* @returns {boolean} True if monster caught player
*/
checkMonsterKill(player, monster) {
return monster.x === player.x && monster.y === player.y;
}
/**
* Log game update debug information
* @param {string} socketId - Socket ID
* @param {Object} gameState - Game state object
*/
logGameUpdate(socketId, gameState) {
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🔍 GAME UPDATE DEBUG for ${socketId}:`);
console.log(` - Player position: (${gameState.player?.x}, ${gameState.player?.y})`);
console.log(` - Visible tiles keys: ${Object.keys(gameState.visibleTiles || {}).length} rows`);
console.log(` - Lighting data included: ${!!gameState.lighting}`);
if (gameState.lighting) {
const lightingTileCount = Object.keys(gameState.lighting).reduce((acc, yKey) =>
acc + Object.keys(gameState.lighting[yKey] || {}).length, 0);
console.log(` - Lighting tiles count: ${lightingTileCount}`);
}
console.log(` - Torch data included: ${!!gameState.torches}`);
if (gameState.torches) {
console.log(` - Torch count: ${gameState.torches.length}`);
}
console.log(`Sending game_update to ${socketId} after player move.`);
}
}
/**
* Get statistics about active games
* @returns {Object} Game statistics
*/
getStats() {
const gameTypes = new Map();
const gameStates = new Map();
for (const [socketId, game] of this.activeGames.entries()) {
// Count by game type (if available)
const type = game.type || 'unknown';
gameTypes.set(type, (gameTypes.get(type) || 0) + 1);
// Count by game state
const state = game.gameState || 'active';
gameStates.set(state, (gameStates.get(state) || 0) + 1);
}
return {
totalActive: this.activeGames.size,
byType: Object.fromEntries(gameTypes),
byState: Object.fromEntries(gameStates)
};
}
/**
* Force cleanup of stale games (emergency cleanup)
* @param {Function} isStaleGame - Function that takes (socketId, game) and returns true if game should be cleaned
* @returns {number} Number of games cleaned up
*/
cleanupStaleGames(isStaleGame) {
let cleaned = 0;
const toDelete = [];
for (const [socketId, game] of this.activeGames.entries()) {
if (isStaleGame(socketId, game)) {
toDelete.push(socketId);
}
}
for (const socketId of toDelete) {
if (this.activeGames.delete(socketId)) {
cleaned++;
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🧹 Cleaned up stale game for ${socketId}`);
}
}
}
return cleaned;
}
// Private helper methods
_calculateScore(game, status, reason) {
if (!game) return 0;
const moves = game.moveCount || 0;
const duration = game.startedAt
? Math.max(1, Math.round((Date.now() - game.startedAt) / 1000))
: 120;
if (status === 'won' && reason === 'escaped') {
let score = 100; // base escape bonus
// Treasure bonus
if (game.player?.hasTreasure) {
score += 200;
}
// Speed bonus: max 300, loses 5 points per second after 20s
score += Math.max(0, 300 - Math.max(duration - 20, 0) * 5);
// Efficiency bonus: max 200, loses 3 points per move after 30 moves
score += Math.max(0, 200 - Math.max(moves - 30, 0) * 3);
return Math.round(score);
} else if (game.player?.hasTreasure) {
return 50; // found treasure but died
}
return 0;
}
/**
* Insert a database record for the game
* @param {Object} game - Game instance
* @param {Object} user - User object
*/
async _insertGameRecord(game, user) {
if (this.gameModeManager && this.gameModeManager.db) {
const db = this.gameModeManager.db;
const gameMode = this.gameModeManager.gameMode || 'FREE';
const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
const socketId = user.id; // user.id is the socket id string
try {
const result = await db.query(`
INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at)
VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW())
RETURNING id, user_id
`, [socketId, socketId, gameMode, blockHeight, game.id]);
game.dbId = result.rows[0]?.id || null;
// Stamp the stable DB users.id onto the in-memory game object so
// suspend/restore and completeGame can key on it WITHOUT any socket_id
// lookup (socket ids are volatile across reconnects). game.userId is the
// canonical field; game.dbUserId is kept as an alias for the disconnect/
// suspend path that already reads it.
const stableUserId = result.rows[0]?.user_id ?? null;
if (stableUserId != null) {
game.userId = stableUserId;
if (game.dbUserId == null) game.dbUserId = stableUserId;
}
// Entry block height captured at creation so the suspend path can persist
// it (blockRec) and the reconnect timeout logic stays consistent.
game.blockRec = blockHeight;
// Reference to the DB pool so restoreGame can keep games.socket_id aligned
// to the reconnecting socket without needing its own DB handle.
game.db = db;
} catch (err) {
console.error('Game insert failed:', err.message);
game.dbId = null;
}
}
}
/**
* Update game record with completion details
* @param {Object} game - Game instance
* @param {string} socketId - Socket ID
* @param {string} status - Final game status
* @param {string} reason - Reason for ending
*/
async _updateGameRecord(game, socketId, status, reason, moves = 0, durationSeconds = null) {
if (this.gameModeManager && this.gameModeManager.db) {
const db = this.gameModeManager.db;
const outcome = reason === 'escaped' ? 'escaped' : (reason === 'monster' ? 'caught_by_monster' : reason);
const score = this._calculateScore(game, status, reason);
try {
await db.query(`
UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4,
duration_seconds = $5, score = $8, completed_at = NOW()
WHERE dungeon_seed = $6 AND socket_id = $7
`, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]);
// Update user's high score if this is a new personal best
if (score > 0) {
await db.query(`
UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1)
WHERE socket_id = $2
`, [score, socketId]);
}
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`✅ Updated game record for ${socketId}: ${status} (${outcome}), score: ${score}`);
}
} catch (err) {
console.error('Game completion update failed:', err.message);
}
}
}
}
module.exports = GameManager;