fix game entry and payout bugs

This commit is contained in:
jwinterm
2026-02-18 06:35:32 -05:00
parent d56ad7599b
commit bf5938c366
5 changed files with 93 additions and 215 deletions
+5 -5
View File
@@ -983,12 +983,12 @@
return Math.floor(seconds / 86400) + 'd ago';
}
// Auto-refresh every 30 seconds
// Auto-refresh every 15 seconds for active tabs
setInterval(() => {
if (apiKey && currentTab === 'overview') {
loadDashboard();
}
}, 30000);
if (!apiKey) return;
if (currentTab === 'overview') loadDashboard();
else if (currentTab === 'queue') loadQueue();
}, 15000);
</script>
</body>
</html>
+37 -29
View File
@@ -30,23 +30,23 @@ class GameManager {
* @param {Object} options - Additional game options
* @returns {Object} Created game instance
*/
createGameForUser(user, gameType = 'standard', options = {}) {
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 a DB record for this game (best effort)
this._insertGameRecord(game, user);
// 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}`);
console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
}
return game;
}
@@ -64,6 +64,11 @@ class GameManager {
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;
@@ -76,19 +81,19 @@ class GameManager {
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',
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);
}
@@ -96,10 +101,10 @@ class GameManager {
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,
@@ -114,10 +119,6 @@ class GameManager {
// Persist completion details if DB available
await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds);
// Clean up game from active games BEFORE notifying spectators
// This ensures the game list broadcast won't include this game
this.activeGames.delete(socketId);
// Notify spectators that the game has ended and broadcast updated list
if (this.spectatorManager) {
@@ -131,16 +132,17 @@ class GameManager {
treasure: game.player.hasTreasure || false
});
}
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason})`);
}
} catch (error) {
console.error('GameManager.handleGameOver error:', error);
// Still try to clean up even if there was an error
if (socket && socket.id) {
this.activeGames.delete(socket.id);
const socketId = socket?.id || socket;
if (socketId) {
this.activeGames.delete(socketId);
}
}
}
@@ -256,18 +258,24 @@ class GameManager {
* @param {Object} game - Game instance
* @param {Object} user - User object
*/
_insertGameRecord(game, user) {
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
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())
`, [socketId, socketId, gameMode, blockHeight, game.id])
.catch(err => console.error('Game insert failed:', err.message));
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
`, [socketId, socketId, gameMode, blockHeight, game.id]);
game.dbId = result.rows[0]?.id || null;
} catch (err) {
console.error('Game insert failed:', err.message);
game.dbId = null;
}
}
}
+18 -175
View File
@@ -747,8 +747,9 @@ class GameModeManager {
const remainingCredits = updateRes.rows[0].credits;
await client.query(`
UPDATE games SET payment_mode = 'credits' WHERE id = $1
`, [gameId]);
UPDATE games SET game_mode = 'PAID_CREDITS', payout_address = $2
WHERE dungeon_seed = $1
`, [gameId, user.payout_address || null]);
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
@@ -781,9 +782,9 @@ class GameModeManager {
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE games
SET payment_id = $1
WHERE id = $2
`, [payment.id, gameId]);
SET payment_id = $1, game_mode = 'PAID_SINGLE', payout_address = $3
WHERE dungeon_seed = $2
`, [payment.id, gameId, user.payout_address || null]);
await client.query(`
UPDATE users
@@ -801,166 +802,8 @@ class GameModeManager {
};
}
/**
* Process game completion (handle payouts)
*/
async processGameCompletion(gameId, outcome, treasureFound = false) {
try {
// Get game details
const gameResult = await this.db.query(`
SELECT g.*, u.payout_address, u.id as user_id
FROM games g
JOIN users u ON g.user_id = u.id
WHERE g.id = $1
`, [gameId]);
if (gameResult.rows.length === 0) {
return { success: false, reason: 'Game not found' };
}
const game = gameResult.rows[0];
// Update game status
await this.db.query(`
UPDATE games
SET status = $1,
outcome = $2,
treasure_found = $3,
completed_at = NOW()
WHERE id = $4
`, [outcome === 'escaped' ? 'won' : 'lost', outcome, treasureFound, gameId]);
const recordedMode = (game.payment_mode || game.game_mode || this.gameMode || 'FREE').toUpperCase();
const payoutEligibleMode = (recordedMode === 'PAID_SINGLE') || (recordedMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
if (payoutAmount > 0) {
const reason = treasureFound ? 'escape_with_treasure' : 'escape';
let payoutId = null;
try {
// Step 1: Create pending payout record FIRST (ensures we have a record even if RPC succeeds but server crashes)
const insertResult = await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())
RETURNING id
`, [
game.user_id,
gameId,
game.payout_address,
payoutAmount,
multiplier,
reason
]);
payoutId = insertResult.rows[0].id;
// Step 2: Attempt wallet RPC transfer
const payoutResult = await this.walletService.processPayout({
userId: game.user_id,
gameId,
address: game.payout_address,
amount: payoutAmount,
multiplier,
description: `Game ${gameId} payout`
});
// Step 3: IMMEDIATELY store tx_hash if RPC succeeded (BEFORE transaction)
// This ensures we never lose the tx_hash even if subsequent DB operations fail
// The retry service can then check blockchain status using this hash
if (payoutResult.success && payoutResult.txHash) {
await this.db.query(`
UPDATE payouts
SET tx_hash = $1, fee = $2
WHERE id = $3
`, [payoutResult.txHash, payoutResult.fee || null, payoutId]);
}
// Step 4: Update status and user stats (within transaction for atomicity)
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE payouts
SET status = $1, processed_at = NOW()
WHERE id = $2
`, [
payoutResult.success ? 'completed' : 'failed',
payoutId
]);
// Update user's total_amount_won if successful
if (payoutResult.success) {
await client.query(`
UPDATE users
SET total_amount_won = COALESCE(total_amount_won, 0) + $1,
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
WHERE id = $2
`, [payoutAmount, game.user_id]);
}
});
console.log(`💸 Payout ${payoutResult.success ? 'sent' : 'failed'}: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
} catch (payoutError) {
console.error(`❌ Payout processing failed for game ${gameId}:`, payoutError.message);
// Update payout record with failure (if it exists)
if (payoutId) {
try {
await this.db.query(`
UPDATE payouts
SET status = 'failed',
error_message = $1,
retry_count = COALESCE(retry_count, 0),
last_error = $1,
last_retry_at = NOW()
WHERE id = $2
`, [payoutError.message, payoutId]);
} catch (dbErr) {
console.error('Failed to update payout error:', dbErr.message);
}
} else {
// Payout record wasn't created, insert failed record
try {
await this.db.query(`
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, error_message, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'failed', $7, NOW())
`, [
game.user_id,
gameId,
game.payout_address,
payoutAmount,
multiplier,
reason,
payoutError.message
]);
} catch (dbErr) {
console.error('Failed to record payout error:', dbErr.message);
}
}
}
}
}
// Update user statistics
if (outcome === 'escaped') {
await this.db.query(`
UPDATE users
SET total_games_won = total_games_won + 1,
updated_at = NOW()
WHERE id = $1
`, [game.user_id]);
}
return {
success: true,
outcome,
treasureFound,
payoutCreated: payoutEligibleMode && outcome === 'escaped'
};
} catch (error) {
const normalized = normalizeError(error, 'Failed to process game completion');
console.error('❌ Error processing game completion:', normalized.message);
return { success: false, reason: normalized.safeMessage };
}
}
// processGameCompletion() removed — dead code, never called.
// Only completeGame() (below) is used for game completion/payouts.
/**
* Create payment request
@@ -1233,18 +1076,17 @@ class GameModeManager {
// Retrieve the game record to get the actual payment_mode used at game start
let recordedPaymentMode = this.gameMode; // fallback to global
let gameDbId = null;
let lockedPayoutAddress = null; // address locked at game start
try {
const gameRecord = await this.db.query(`
SELECT id, payment_mode FROM games
SELECT id, game_mode, payout_address FROM games
WHERE dungeon_seed = $1 AND socket_id = $2
LIMIT 1
`, [gameId, socketId]);
if (gameRecord.rows.length > 0) {
gameDbId = gameRecord.rows[0].id;
recordedPaymentMode = (gameRecord.rows[0].payment_mode || this.gameMode).toUpperCase();
// Normalize to expected values
if (recordedPaymentMode === 'DIRECT') recordedPaymentMode = 'PAID_SINGLE';
if (recordedPaymentMode === 'CREDITS') recordedPaymentMode = 'PAID_CREDITS';
recordedPaymentMode = (gameRecord.rows[0].game_mode || this.gameMode).toUpperCase();
lockedPayoutAddress = gameRecord.rows[0].payout_address || null;
}
} catch (e) {
// Non-fatal, use fallback
@@ -1289,10 +1131,11 @@ class GameModeManager {
}
}
// Look up user record for payout address
// Use payout address locked at game start, fall back to current user address
const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]);
const userRow = userResult.rows[0];
if (userRow && userRow.payout_address) {
const payoutAddress = lockedPayoutAddress || (userRow && userRow.payout_address);
if (userRow && payoutAddress) {
// SECURITY: Use safe payout pattern - create pending record FIRST, then RPC, then store tx_hash
let payoutId = null;
try {
@@ -1304,18 +1147,18 @@ class GameModeManager {
`, [
userRow.id,
gameDbId, // Use DB id if available
userRow.payout_address,
payoutAddress,
payoutAmount,
multiplier,
treasureFound ? 'escape_with_treasure' : 'escape'
]);
payoutId = insertResult.rows[0].id;
// Step 2: Attempt wallet RPC transfer
// Step 2: Attempt wallet RPC transfer (using locked address)
const payoutResult = await this.walletService.processPayout({
userId: userRow.id,
gameId,
address: userRow.payout_address,
address: payoutAddress,
amount: payoutAmount,
multiplier,
description: `Game ${gameId} payout`
+11
View File
@@ -0,0 +1,11 @@
-- Migration 012: Payout safety constraints
-- Prevents double payouts and locks payout address at game start
-- Only one active (pending/completed) payout per game
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_game
ON payouts(game_id)
WHERE game_id IS NOT NULL AND status IN ('pending', 'completed');
-- Store the payout address at game start so it can't be changed mid-game
ALTER TABLE games
ADD COLUMN IF NOT EXISTS payout_address VARCHAR(200);
+22 -6
View File
@@ -109,7 +109,7 @@ class QueueManager {
console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`);
}
try {
const game = this.createGameForUser(currentUser, 'standard');
const game = await this.createGameForUser(currentUser, 'standard');
const gameState = game.getState();
gameState.blockHeight = blockHeight;
// Include provably fair commitment
@@ -121,8 +121,9 @@ class QueueManager {
if (this.gameModeManager) {
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
if (!startRes.success) {
// Abort game
// Abort game + clean up orphaned DB record
if (this.activeGames) this.activeGames.delete(serverId);
this._cleanupOrphanedGame(game);
this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed'));
continue;
}
@@ -178,7 +179,7 @@ class QueueManager {
if (!currentUser) return false;
currentUser.blockRec = blockHeight; // lifetime until next block
try {
const game = this.createGameForUser(currentUser, 'standard');
const game = await this.createGameForUser(currentUser, 'standard');
const gameState = game.getState();
gameState.blockHeight = blockHeight;
// Include provably fair commitment
@@ -190,8 +191,9 @@ class QueueManager {
if (this.gameModeManager) {
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
if (!startRes.success) {
// Abort game
// Abort game + clean up orphaned DB record
if (this.activeGames) this.activeGames.delete(serverId);
this._cleanupOrphanedGame(game);
this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed'));
return false;
}
@@ -235,7 +237,7 @@ class QueueManager {
currentUser.isEarlyEntry = true; // Mark as early entry for potential special handling
try {
const game = this.createGameForUser(currentUser, 'standard', { earlyEntry: true });
const game = await this.createGameForUser(currentUser, 'standard', { earlyEntry: true });
const gameState = game.getState();
gameState.blockHeight = blockHeight;
gameState.isEarlyEntry = true;
@@ -250,8 +252,9 @@ class QueueManager {
if (this.gameModeManager) {
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
if (!startRes.success) {
// Abort game
// Abort game + clean up orphaned DB record
if (this.activeGames) this.activeGames.delete(serverId);
this._cleanupOrphanedGame(game);
return { success: false, reason: startRes.reason || 'Payment processing failed' };
}
// Emit credits_update if credits were spent
@@ -275,6 +278,19 @@ class QueueManager {
}
}
/**
* Clean up a game record from the DB when processGameStart fails.
* Prevents orphaned "active" records that never complete.
*/
_cleanupOrphanedGame(game) {
if (game && game.dbId && this.gameModeManager && this.gameModeManager.db) {
this.gameModeManager.db.query(
`UPDATE games SET status = 'expired', outcome = 'aborted', completed_at = NOW() WHERE id = $1`,
[game.dbId]
).catch(err => console.error('[QueueManager] Failed to clean up orphaned game:', err.message));
}
}
/**
* Get list of pending games (players waiting in queue)
* Used by spectator system to show upcoming games