mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 23:28:32 +02:00
fix game entry and payout bugs
This commit is contained in:
+5
-5
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user