diff --git a/html/audio/Nowescapethedunge.m4a b/html/audio/Nowescapethedunge.m4a new file mode 100644 index 0000000..16d529a Binary files /dev/null and b/html/audio/Nowescapethedunge.m4a differ diff --git a/html/audio/Paymentconfirmed.m4a b/html/audio/Paymentconfirmed.m4a new file mode 100644 index 0000000..de13cfe Binary files /dev/null and b/html/audio/Paymentconfirmed.m4a differ diff --git a/html/audio/Paymentdetected.m4a b/html/audio/Paymentdetected.m4a new file mode 100644 index 0000000..4af0004 Binary files /dev/null and b/html/audio/Paymentdetected.m4a differ diff --git a/html/audio/Pleasesendcoin.m4a b/html/audio/Pleasesendcoin.m4a new file mode 100644 index 0000000..8d87788 Binary files /dev/null and b/html/audio/Pleasesendcoin.m4a differ diff --git a/html/index.html b/html/index.html index 40c25c8..50be938 100644 --- a/html/index.html +++ b/html/index.html @@ -8,9 +8,18 @@ - + diff --git a/html/js/display/screenManager.js b/html/js/display/screenManager.js index 3a32040..d3c4e3a 100644 --- a/html/js/display/screenManager.js +++ b/html/js/display/screenManager.js @@ -226,7 +226,7 @@ var ScreenManager = { this.drawCenteredText(y + 3, "Better luck next time."); }, - drawWaitingScreen: function() { + drawWaitingScreen: function(_internalLoopCall = false) { if (!DisplayManager.ensureDisplay()) return; DisplayManager.clearDisplay(); @@ -259,7 +259,8 @@ var ScreenManager = { this.showAnimationButton(); // Start animation loop if not already running - if (animationEnabled && typeof WaitingScreenAnimator !== 'undefined') { + // Only start the animator if this wasn't invoked from inside the animator loop + if (! _internalLoopCall && animationEnabled && typeof WaitingScreenAnimator !== 'undefined') { WaitingScreenAnimator.startAnimation(); } }, diff --git a/html/js/display/waitingScreenAnimator.js b/html/js/display/waitingScreenAnimator.js index 5884e27..8a2792d 100644 --- a/html/js/display/waitingScreenAnimator.js +++ b/html/js/display/waitingScreenAnimator.js @@ -2,6 +2,7 @@ var WaitingScreenAnimator = { _animationEnabled: true, _waitingAnimationInterval: null, + _animating: false, _waitingAnimation: { frame: 0, playerX: 8, @@ -354,11 +355,12 @@ var WaitingScreenAnimator = { break; case 'exit': - // Brief pause then reset - if (anim.frameCount > anim.frameCount + 20) { + if (anim.exitStartFrame === undefined) { + anim.exitStartFrame = anim.frameCount; + } + if (anim.frameCount - anim.exitStartFrame > 40) { // ~4s at 10fps this.resetAnimation(); } else { - // Monster exits if (anim.monsterX < 48) { anim.monsterX++; } @@ -432,22 +434,22 @@ var WaitingScreenAnimator = { }, startAnimation: function() { - // Clear any existing interval + if (this._animating) return; // already running + // Clear any existing interval defensively if (this._waitingAnimationInterval) { clearInterval(this._waitingAnimationInterval); } - - // Start new animation loop + this._animating = true; this._waitingAnimationInterval = setInterval(() => { if (this._animationEnabled && !GameState.isGameActive()) { - // Trigger a redraw in the main screen manager + // Draw only the animated frame WITHOUT restarting the loop (avoid recursion) if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) { - ScreenManager.drawWaitingScreen(); + ScreenManager.drawWaitingScreen(true); // pass flag to skip auto-start } } else { this.stopAnimation(); } - }, 100); // 10 FPS animation + }, 100); // 10 FPS }, stopAnimation: function() { @@ -455,6 +457,7 @@ var WaitingScreenAnimator = { clearInterval(this._waitingAnimationInterval); this._waitingAnimationInterval = null; } + this._animating = false; this.resetAnimation(); }, diff --git a/html/js/input/inputHandler.js b/html/js/input/inputHandler.js index 4a95711..9d9fe67 100644 --- a/html/js/input/inputHandler.js +++ b/html/js/input/inputHandler.js @@ -72,26 +72,27 @@ const InputHandler = { let dy = 0; let moved = false; + let direction = null; switch(e.key) { - case 'w': case 'ArrowUp': dy = -1; moved = true; break; - case 's': case 'ArrowDown': dy = 1; moved = true; break; - case 'a': case 'ArrowLeft': dx = -1; moved = true; break; - case 'd': case 'ArrowRight': dx = 1; moved = true; break; + case 'w': case 'ArrowUp': direction = 'up'; dy = -1; moved = true; break; + case 's': case 'ArrowDown': direction = 'down'; dy = 1; moved = true; break; + case 'a': case 'ArrowLeft': direction = 'left'; dx = -1; moved = true; break; + case 'd': case 'ArrowRight': direction = 'right'; dx = 1; moved = true; break; } - if (moved) { + if (moved && direction) { e.preventDefault(); // Prevent page scrolling // Implement movement throttling to prevent rapid-fire movement const now = Date.now(); if (now - self._lastMoveTime >= self._moveCooldown) { // Send move immediately if enough time has passed - socket.emit('player_move', { dx: dx, dy: dy }); + socket.emit('player_move', { direction }); self._lastMoveTime = now; self._pendingMove = null; } else { // Queue the move to be sent after cooldown - self._pendingMove = { dx: dx, dy: dy }; + self._pendingMove = { direction }; const timeToWait = self._moveCooldown - (now - self._lastMoveTime); setTimeout(() => { if (self._pendingMove) { @@ -149,15 +150,12 @@ const InputHandler = { setupClickHandlers: function() { // Add click handling for the HTML START button $('#startButton').click(function(e) { - - // Use the new auto_start event for immediate entry + // Attempt immediate start socket.emit('auto_start'); - + // We optimistically show waiting screen; if server later responds with payment requirement, audio handler will fire on payment_created if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) { ScreenManager.drawWaitingScreen(); } - - // Add visual feedback to chat $('#messages').append($('
  • ').text("šŸ–±ļø Game start requested...")); UI.scrollChat(); }); diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 39df673..7fcf9eb 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -34,6 +34,8 @@ const SocketHandlers = { // Connection handlers socket.on('connect', this.onConnect); socket.on('welcome', this.onWelcome); + socket.on('session_token', this.onSessionToken); + socket.on('session_resumed', this.onSessionResumed); socket.on('message', this.onMessage); socket.on('status_update', this.onStatusUpdate); socket.on('chat_broadcast', this.onChatBroadcast); @@ -52,6 +54,7 @@ const SocketHandlers = { socket.on('payment_created', this.onPaymentCreated); socket.on('payment_confirmed', this.onPaymentConfirmed); socket.on('payment_detected', this.onPaymentDetected); + socket.on('credits_update', this.onCreditsUpdate); // Block height handler socket.on('blockheight', this.onBlockHeight); @@ -60,6 +63,15 @@ const SocketHandlers = { onConnect: function() { if (SocketHandlers._didConnect) return; // prevent duplicate registration emission SocketHandlers._didConnect = true; + // Include stored session token in a lightweight resume emit (if server did not get it via handshake) + try { + const existing = localStorage.getItem('wowngeon_token'); + if (existing) { + // If we later decide to rely solely on query param at io() creation, this is harmless redundancy. + socket.io.opts.query = socket.io.opts.query || {}; + socket.io.opts.query.resumeToken = existing; + } + } catch (e) {} socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent @@ -68,6 +80,49 @@ const SocketHandlers = { SocketHandlers._setBannerStatus('Connected', '#0f0'); }, + onSessionToken: function(data) { + if (data && data.token) { + try { localStorage.setItem('wowngeon_token', data.token); } catch(e) {} + $('#messages').append($('
  • ').text('New session established. Token stored.')); + UI.scrollChat(); + } + }, + + onSessionResumed: function(data) { + if (data && data.token) { + try { localStorage.setItem('wowngeon_token', data.token); } catch(e) {} + $('#messages').append($('
  • ').text('Session resumed.')); + UI.scrollChat(); + } + if (data && typeof data.credits === 'number') { + SocketHandlers._updateCreditsDisplay(data.credits); + } + if (data && data.payoutAddress) { + $('#messages').append($('
  • ').text('Payout address restored.')); + UI.scrollChat(); + } + }, + + onCreditsUpdate: function(data) { + if (!data) return; + if (typeof data.balance === 'number') { + SocketHandlers._updateCreditsDisplay(data.balance); + } + }, + + _updateCreditsDisplay: function(balance) { + let el = document.getElementById('creditsDisplay'); + if (!el) { + // Create a small unobtrusive badge in header if not present + const header = document.getElementById('header') || document.body; + el = document.createElement('div'); + el.id = 'creditsDisplay'; + el.style.cssText = 'position:absolute;top:4px;right:8px;font-size:12px;font-family:monospace;color:#0af;background:#111;padding:2px 6px;border:1px solid #044;border-radius:4px;'; + header.appendChild(el); + } + el.textContent = 'Credits: ' + balance; + }, + onWelcome: function(msg) { $('#messages').append($('
  • ').text("Connected to server!")); UI.scrollChat(); @@ -96,6 +151,7 @@ const SocketHandlers = { } else if (data.type === 'payment') { // Generic payment message (more granular handlers override later) SocketHandlers._setBannerStatus('Payment', '#0af'); + if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); } } else if (data.type === 'error') { SocketHandlers._setBannerStatus('Error', '#f00'); } else if (data.type === 'success') { @@ -180,6 +236,9 @@ const SocketHandlers = { UI.scrollChat(); SocketHandlers._setBannerStatus('In Game', '#0f0'); + if (typeof AudioAlerts !== 'undefined' && AudioAlerts._enabled) { + try { AudioAlerts.playFile('game_start'); } catch(_) {} + } }, onGameUpdate: function(data) { @@ -243,6 +302,7 @@ const SocketHandlers = { onPaymentCreated: function(data) { console.log('Payment created:', data); + if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); } const parts = []; parts.push('šŸ’³ Payment Required'); parts.push('Amount: ' + data.humanAmount + ' ' + data.cryptoType); @@ -349,6 +409,9 @@ const SocketHandlers = { )); UI.scrollChat(); SocketHandlers._setBannerStatus('Confirmed', '#0f0'); + if (typeof AudioAlerts !== 'undefined' && AudioAlerts._enabled) { + try { AudioAlerts.playFile('payment_confirmed'); } catch(_) {} + } }, _mempoolShownForPayment: new Set(), @@ -369,6 +432,10 @@ const SocketHandlers = { )); UI.scrollChat(); SocketHandlers._setBannerStatus('Mempool', '#0af'); + // Fallback audio trigger if AudioAlerts patched earlier failed to wrap or user enabled after patch + if (typeof AudioAlerts !== 'undefined' && AudioAlerts._enabled) { + try { AudioAlerts.playFile('payment_detected'); } catch(_) {} + } }, // Internal: periodic cleanup of client-side payment marker sets (invoked opportunistically) diff --git a/html/js/ui/audioAlerts.js b/html/js/ui/audioAlerts.js index 3ffe7fb..7edf073 100644 --- a/html/js/ui/audioAlerts.js +++ b/html/js/ui/audioAlerts.js @@ -12,6 +12,13 @@ const AudioAlerts = { _volume: 0.4, _lastPlay: 0, _cooldownMs: 1500, + _audioTags: {}, // cache HTMLAudioElements + _fileMap: { + request_coin: 'audio/Pleasesendcoin.m4a', + payment_detected: 'audio/Paymentdetected.m4a', + payment_confirmed: 'audio/Paymentconfirmed.m4a', + game_start: 'audio/Nowescapethedunge.m4a' + }, init() { if (this._initialized) return; @@ -41,15 +48,22 @@ const AudioAlerts = { if (SocketHandlers._audioAlertsPatched) return; // idempotent SocketHandlers._audioAlertsPatched = true; const origPaymentConfirmed = SocketHandlers.onPaymentConfirmed; + const origPaymentDetected = SocketHandlers.onPaymentDetected; const origGameStart = SocketHandlers.onGameStart; + const origQueueJoined = SocketHandlers.onQueueJoined; SocketHandlers.onPaymentConfirmed = function(data) { if (origPaymentConfirmed) origPaymentConfirmed.call(SocketHandlers, data); - AudioAlerts.playPattern('payment'); + AudioAlerts.playFile('payment_confirmed'); + }; + SocketHandlers.onPaymentDetected = function(data) { + if (origPaymentDetected) origPaymentDetected.call(SocketHandlers, data); + AudioAlerts.playFile('payment_detected'); }; SocketHandlers.onGameStart = function(data) { if (origGameStart) origGameStart.call(SocketHandlers, data); - AudioAlerts.playPattern('start'); + AudioAlerts.playFile('game_start'); }; + // We'll trigger request_coin when user attempts start but is blocked for payment/credits; handled externally via public method }, _lazyInitContext() { @@ -97,6 +111,29 @@ const AudioAlerts = { t += dur * 0.5; // slight overlap for chord-like effect }); } + , + _getAudio(tag) { + if (!this._enabled) return null; + if (!this._audioTags[tag]) { + const src = this._fileMap[tag]; + if (!src) return null; + const el = new Audio(src); + el.preload = 'auto'; + this._audioTags[tag] = el; + } + return this._audioTags[tag]; + }, + playFile(tag) { + if (!this._enabled) return; + const now = Date.now(); + if (now - this._lastPlay < this._cooldownMs) return; + this._lastPlay = now; + const el = this._getAudio(tag); + if (!el) return; + try { el.currentTime = 0; el.play().catch(()=>{}); } catch(_) {} + }, + // Public helper to be invoked when server denies start due to payment/credits + playRequestCoin() { this.playFile('request_coin'); } }; if (typeof module !== 'undefined' && module.exports) { diff --git a/src/.env.example b/src/.env.example index fb47546..bb92361 100644 --- a/src/.env.example +++ b/src/.env.example @@ -59,3 +59,8 @@ BLOCK_SOURCE=daemon # Payout Configuration PAYOUT_BATCH_INTERVAL=300000 MAX_PAYOUT_BATCH_SIZE=50 + +# Credits Mode Optional Payouts +# Set to true to enable payouts (same multipliers as single mode) when using PAID_CREDITS +# Default is false meaning credits mode is purely consumptive / no rewards. +CREDITS_PAYOUT_ENABLED=false diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 54d92b3..0f5b306 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -21,14 +21,18 @@ class GameModeManager { return Math.trunc(num); }; - this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR - this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR + this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR + this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR + this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUT_ENABLED || 'false'); console.log(`šŸŽ® Game Mode Manager initialized: ${this.gameMode} mode`); console.log(`šŸ’° Currency: ${this.cryptoType}`); const toXMR = (atomic) => (atomic / 1e12).toFixed(6).replace(/0+$/,'').replace(/\.$/,''); console.log(`šŸ’µ Single game price: ${this.singleGamePrice} atomic units (~${toXMR(this.singleGamePrice)} ${this.cryptoType})`); console.log(`šŸŽ« Credits package price: ${this.creditsPackagePrice} atomic units (~${toXMR(this.creditsPackagePrice)} ${this.cryptoType})`); + if (this.creditsPayoutEnabled) { + console.log('šŸŽ Credits payout mode ENABLED (will pay rewards in PAID_CREDITS).'); + } } /** @@ -103,16 +107,19 @@ class GameModeManager { case 'PAID_CREDITS': // Deduct one credit - await this.db.query(` + const updateRes = await this.db.query(` UPDATE users SET credits = credits - 1, total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 + RETURNING credits `, [user.id]); - - console.log(`šŸŽ« Deducted 1 credit from user ${user.id}, ${user.credits - 1} remaining`); - return { success: true, creditsRemaining: user.credits - 1 }; + const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - 1); + // Emit credits update asynchronously if an io ref was injected later (pattern: attach externally) + try { this.io && this.io.to(socketId).emit('credits_update', { balance: remainingCredits }); } catch(_) {} + console.log(`šŸŽ« Deducted 1 credit from user ${user.id}, ${remainingCredits} remaining`); + return { success: true, creditsRemaining: remainingCredits }; case 'PAID_SINGLE': // Link game to payment @@ -186,15 +193,12 @@ class GameModeManager { WHERE id = $4 `, [outcome === 'escaped' ? 'won' : 'lost', outcome, treasureFound, gameId]); - // Handle payouts for PAID_SINGLE mode - if (this.gameMode === 'PAID_SINGLE' && outcome === 'escaped' && game.payout_address) { + // Handle payouts for PAID_SINGLE or (optionally) PAID_CREDITS mode + const payoutEligibleMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); + if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) { let payoutAmount; - - if (treasureFound) { - payoutAmount = this.singleGamePrice * 3; // 3x for treasure - } else { - payoutAmount = this.singleGamePrice * 2; // 2x for escape - } + const base = (this.gameMode === 'PAID_CREDITS') ? this.singleGamePrice : this.singleGamePrice; // could make distinct; reuse singleGamePrice as base + payoutAmount = treasureFound ? base * 3 : base * 2; // Create payout record await this.walletService.processPayout( @@ -221,7 +225,7 @@ class GameModeManager { success: true, outcome, treasureFound, - payoutCreated: this.gameMode === 'PAID_SINGLE' && outcome === 'escaped' + payoutCreated: payoutEligibleMode && outcome === 'escaped' }; } catch (error) { @@ -423,7 +427,8 @@ class GameModeManager { } // Handle payouts only in PAID_SINGLE mode and only on win (escaped) - if (this.gameMode === 'PAID_SINGLE' && won) { + const payoutEligibleStartMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled); + if (payoutEligibleStartMode && won) { // Derive payout multiplier const multiplier = treasureFound ? 3 : 2; const payoutAmount = this.singleGamePrice * multiplier; diff --git a/src/index.js b/src/index.js index 8d91a6c..12141f4 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,8 @@ const databaseManager = new DatabaseManager(); const rpcService = new RpcService(); const walletRPCService = new WalletRPCService(debugManager); const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager); +// Provide io reference so GameModeManager can emit events (e.g., credits_update) +gameModeManager.io = io; // Initialize remaining components const activeGames = new Map(); // Maps socketId to Game objects diff --git a/src/migrations/003_add_anon_token.sql b/src/migrations/003_add_anon_token.sql new file mode 100644 index 0000000..1bcc773 --- /dev/null +++ b/src/migrations/003_add_anon_token.sql @@ -0,0 +1,6 @@ +-- Migration 003: Add anonymous session token + last_seen +ALTER TABLE users + ADD COLUMN IF NOT EXISTS anon_token VARCHAR(64) UNIQUE, + ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +CREATE INDEX IF NOT EXISTS idx_users_anon_token ON users(anon_token); diff --git a/src/network/addressManager.js b/src/network/addressManager.js new file mode 100644 index 0000000..373a65d --- /dev/null +++ b/src/network/addressManager.js @@ -0,0 +1,86 @@ +/** + * AddressManager + * Handles detection, pending confirmation, validation, and persistence of payout addresses. + * Keeps socketHandlers slimmer by encapsulating this concern. + */ +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) + } + + detectInText(text) { + if (!text || typeof text !== 'string') return null; + const re = /((?:4|8)[0-9A-Za-z]{90,110}|Wo[0-9A-Za-z]{88,110})/; // Monero / Wownero style + const m = text.match(re); + if (!m) return null; + if (m[1].length < 80) return null; + return m[1]; + } + + async handleDetection(socketId, address) { + try { + if (this.gameModeManager) { + const userRow = await this.gameModeManager.getOrCreateUser(socketId); + if (userRow && userRow.payout_address === address) { + this.io.to(socketId).emit('address_confirmed', { address, message: 'Payout address already set.' }); + 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 confirm(socketId, accept) { + const pending = this.pending.get(socketId); + if (!pending) { + this.broadcastManager?.sendStatusUpdate(socketId, 'warning', 'No address pending. Paste an address first.'); + return; + } + 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; + } + if (this.gameModeManager) { + try { + await this.gameModeManager.setUserPayoutAddress(socketId, pending); + this.broadcastManager?.sendStatusUpdate(socketId, 'success', 'Payout address saved.'); + this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address saved.' }); + } catch (e) { + this.broadcastManager?.sendStatusUpdate(socketId, 'error', 'Failed to save address. Try again.'); + return; + } finally { + this.pending.delete(socketId); + } + } else { + this.pending.delete(socketId); + this.broadcastManager?.sendStatusUpdate(socketId, 'success', 'Payout address accepted (session only).'); + 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(_) {} + } + } + + requiresPayoutAddress() { + if (!this.gameModeManager) return false; // free mode => no requirement + const mode = this.gameModeManager.gameMode; + return (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); + } +} + +module.exports = AddressManager; \ No newline at end of file diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index a9d102f..631769c 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -62,6 +62,21 @@ class PaymentHandlers { async createAndShowPaymentRequest(socket) { if (!this.gameModeManager) return; try { + // Ensure payout address exists for modes that may payout (PAID_SINGLE or PAID_CREDITS with payouts) + const mode = this.gameModeManager.gameMode; + const needsAddress = (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); + if (needsAddress) { + try { + const userRow = await this.gameModeManager.getOrCreateUser(socket.id); + if (!userRow.payout_address) { + this.broadcastManager.sendStatusUpdate(socket.id, 'payment', 'šŸ’³ Before paying, paste your payout address (XMR/WOW) then type confirm.'); + // Do not proceed until address is set + return; + } + } catch(e) { + console.error('Address pre-check failed:', e.message); + } + } const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null; // caller (SocketHandlers) will handle user existence; keep method generic const gameMode = this.gameModeManager.gameMode; @@ -145,10 +160,15 @@ class PaymentHandlers { } else if (paymentStatus.confirmed) { this.broadcastManager.sendStatusUpdate(socketId, 'success', `šŸ’° PAYMENT CONFIRMED IN BLOCK\n\nāœ… Added to queue.\nšŸ•’ Starts at block ${nextBlock}.\nšŸ“¦ Current block: ${currentBlock}`); } - if (!this.confirmedPayments.has(paymentRequest.id)) { - this.confirmedPayments.add(paymentRequest.id); - this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock }); - this._confirmedTimestamps.set(paymentRequest.id, Date.now()); + const newBalRes = await this.db.query('SELECT credits FROM users WHERE id = $1', [currentUser.id]); + const remaining = newBalRes.rows[0] ? newBalRes.rows[0].credits : currentUser.credits; + if (this.io) { + this.io.to(socketId).emit('credits_update', { balance: remaining }); + } + if (!this.confirmedPayments.has(paymentRequest.id)) { + this.confirmedPayments.add(paymentRequest.id); + this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock }); + this._confirmedTimestamps.set(paymentRequest.id, Date.now()); } } diff --git a/src/network/sessionManager.js b/src/network/sessionManager.js new file mode 100644 index 0000000..d681079 --- /dev/null +++ b/src/network/sessionManager.js @@ -0,0 +1,50 @@ +/** + * SessionManager + * Issues and resumes anonymous session tokens to persist credits & payout address across reconnects. + */ +const { randomUUID } = require('crypto'); + +class SessionManager { + constructor({ db, debugManager, gameModeManager }) { + this.db = db; + this.debugManager = debugManager; + this.gameModeManager = gameModeManager; + } + + async resumeOrCreate({ socketId, ipAddress, resumeToken }) { + try { + if (resumeToken) { + const existing = await this.db.query(`SELECT * FROM users WHERE anon_token = $1 LIMIT 1`, [resumeToken]); + if (existing.rows.length) { + const row = existing.rows[0]; + await this.db.query(`UPDATE users SET socket_id = $1, last_seen = NOW() WHERE id = $2`, [socketId, row.id]); + if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Resumed session ${row.id} via token.`); + return { resumed: true, token: resumeToken, user: { ...row, socket_id: socketId } }; + } + } + // Create + const token = randomUUID(); + const inserted = await this.db.query(`INSERT INTO users (socket_id, ip_address, anon_token) VALUES ($1,$2,$3) RETURNING *`, [socketId, ipAddress, token]); + const row = inserted.rows[0]; + if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Created new anonymous user ${row.id}`); + return { resumed: false, token, user: row }; + } catch (e) { + console.error('SessionManager resumeOrCreate error:', e.message); + throw e; + } + } + + async getBySocket(socketId) { + const res = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]); + return res.rows[0] || null; + } + + async rotateToken(socketId) { + const token = randomUUID(); + const res = await this.db.query(`UPDATE users SET anon_token = $1, last_seen = NOW() WHERE socket_id = $2 RETURNING anon_token`, [token, socketId]); + if (res.rows.length) return res.rows[0].anon_token; + return null; + } +} + +module.exports = SessionManager; diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index f62b750..7dccdf6 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -8,6 +8,8 @@ const Game = require('../game/game'); const MovementManager = require('../game/movementManager'); const QueueManager = require('./queueManager'); const PaymentHandlers = require('./paymentHandlers'); +const AddressManager = require('./addressManager'); +const SessionManager = require('./sessionManager'); class SocketHandlers { constructor(io, activeGames, broadcastManager, debugManager, gameModeManager = null, walletService = null) { @@ -48,6 +50,31 @@ class SocketHandlers { moveCooldown: this.MOVE_COOLDOWN, postMoveHook: ({ socketId, game }) => this.afterPlayerMove(socketId, game) }); + // Address / payout handling encapsulated in AddressManager + this.addressManager = new AddressManager({ + gameModeManager: this.gameModeManager, + broadcastManager: this.broadcastManager, + io: this.io, + debugManager: this.debugManager, + onConfirmed: (socketId, accepted) => { + if (accepted && this._awaitingAddressFor && this._awaitingAddressFor.has(socketId)) { + // Automatically proceed to payment creation after address confirmation + const sock = this._getLiveSocket(socketId); + if (sock) { + this.paymentHandlers.createAndShowPaymentRequest(sock); + } + this._awaitingAddressFor.delete(socketId); + } + } + }); + // Anonymous session management + if (this.gameModeManager && this.gameModeManager.db) { + this.sessionManager = new SessionManager({ + db: this.gameModeManager.db, + debugManager: this.debugManager, + gameModeManager: this.gameModeManager + }); + } } /** @@ -137,25 +164,57 @@ class SocketHandlers { /** * Initialize socket event handlers for a new connection */ - handleConnection(socket) { + async handleConnection(socket) { if (this.debugManager.CONSOLE_LOGGING) { console.log('A user connected'); console.log(socket.client.id); console.log(socket.handshake.address); } - // Create and register user - new user.User(socket.id, socket.handshake.address); - const newUser = this.getUserBySocket(socket.id); - if (newUser) { - newUser.clientId = socket.client.id; - if (this.debugManager.CONSOLE_LOGGING) { - console.log(`User created with both socket.id (${socket.id}) and socket.client.id (${socket.client.id})`); + // Session resume / create (DB authoritative) + ephemeral in-memory user object for legacy game logic + let resumeToken = null; + try { resumeToken = socket.handshake.query?.resumeToken || null; } catch(_) {} + let sessionInfo = null; + if (this.sessionManager) { + try { + sessionInfo = await this.sessionManager.resumeOrCreate({ + socketId: socket.id, + ipAddress: socket.handshake.address, + resumeToken + }); + } catch(e) { + console.error('Session initialization failed:', e.message); } } + + // Always create an in-memory user (legacy systems rely on it) but map to socket.id + new user.User(socket.id, socket.handshake.address); + const memUser = this.getUserBySocket(socket.id); + if (memUser) memUser.clientId = socket.client.id; - // Send welcome and current status - this.io.to(socket.client.id).emit('welcome', socket.client.id); + // Emit session / welcome events + 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 + }); + } else { + this.io.to(socket.id).emit('session_token', { token: sessionInfo.token }); + } + } else { + // fallback legacy welcome + this.io.to(socket.client.id).emit('welcome', socket.client.id); + } + // If payout address already set, echo confirmation for UI convenience + if (sessionInfo && sessionInfo.user.payout_address) { + this.io.to(socket.id).emit('address_confirmed', { address: sessionInfo.user.payout_address, message: 'Payout address restored.' }); + } + // Credits convenience push + if (sessionInfo) { + this.io.to(socket.id).emit('credits_update', { balance: sessionInfo.user.credits || 0 }); + } // Send current block height const currentBlock = this.debugManager.getCurrentBlockHeight(); @@ -181,6 +240,97 @@ class SocketHandlers { socket.on('get_user_credits', () => this.handleGetUserCredits(socket)); } + /** + * Handle immediate start button (auto_start) + * Applies payment eligibility + payout address gating (for payout-eligible modes) + * If eligible, starts game immediately (bypassing block queue) and processes game start (credit deduction / payment linkage) + */ + async handleAutoStart(socket) { + try { + const existingGame = this.activeGames.get(socket.id); + if (existingGame) { + this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'You are already in a game!'); + return; + } + + const memUser = this.getUserBySocket(socket.id); + if (!memUser) { + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Could not start game (user not found). Please reconnect.'); + return; + } + + let canStart = { allowed: true, reason: 'Free mode' }; + if (this.gameModeManager) { + // Payout address gating for modes that can payout + const payoutEligible = (this.gameModeManager.gameMode === 'PAID_SINGLE') || (this.gameModeManager.gameMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); + if (payoutEligible) { + try { + const dbUser = await this.gameModeManager.getOrCreateUser(socket.id); + if (!dbUser.payout_address) { + this.broadcastManager.sendStatusUpdate(socket.id, 'payment', 'āš ļø Paste your payout address first, then type confirm. Payment request will appear automatically.'); + if (!this._awaitingAddressFor) this._awaitingAddressFor = new Set(); + this._awaitingAddressFor.add(socket.id); + return; + } + } catch (e) { + console.error('Payout address pre-check failed:', e.message); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Address check failed, try again.'); + return; + } + } + + try { + canStart = await this.gameModeManager.canUserStartGame(socket.id); + } catch (e) { + console.error('Eligibility check failed:', e.message); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Eligibility check failed.'); + return; + } + + if (!canStart.allowed) { + // Trigger payment request automatically + if (canStart.action === 'make_payment' || canStart.action === 'purchase_credits') { + await this.paymentHandlers.createAndShowPaymentRequest(socket); + return; + } + this.broadcastManager.sendStatusUpdate(socket.id, 'error', canStart.reason || 'Not allowed to start'); + return; + } + } + + // Create game immediately + const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; + memUser.blockRec = blockHeight; // keep legacy timeout logic consistent + const game = this.createGameForUser(memUser, 'standard'); + const state = game.getState(); + state.blockHeight = blockHeight; + + // Process start (credits deduction / payment link) + if (this.gameModeManager) { + const startRes = await this.gameModeManager.processGameStart(socket.id, game.id); + if (!startRes.success) { + // Abort game + this.activeGames.delete(socket.id); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', startRes.reason || 'Failed to start game.'); + return; + } + } + + this.io.to(socket.id).emit('game_start', state); + this.broadcastManager.sendStatusUpdate(socket.id, 'success', 'Game started! Escape before the next block!'); + } catch (err) { + console.error('handleAutoStart error:', err); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Unexpected error starting game.'); + } + } + + /** Return the active socket instance by id (io.sockets.sockets Map) */ + _getLiveSocket(socketId) { + try { + return this.io.sockets.sockets.get(socketId) || null; + } catch(_) { return null; } + } + /** * Handle chat messages and game commands */ @@ -191,10 +341,10 @@ class SocketHandlers { const command = msg.toLowerCase(); - // Check for XMR/WOW address in the message - const xmrAddressMatch = this.detectXMRAddress(msg); - if (xmrAddressMatch) { - this.handleAddressDetection(socket, xmrAddressMatch); + // Check for XMR/WOW payout address in the message using AddressManager + const detected = this.addressManager.detectInText(msg); + if (detected) { + this.addressManager.handleDetection(socket.id, detected); return; } @@ -211,17 +361,19 @@ class SocketHandlers { return; case 'cancel': - // Check if this is for address confirmation - if (this.pendingAddresses && this.pendingAddresses.has(socket.id)) { - this.handleAddressConfirmation(socket, false); + if (this.addressManager.pending.has(socket.id)) { + this.addressManager.confirm(socket.id, false); return; } - // Otherwise handle queue cancellation - this.handleCancelEntry(socket); + this.handleCancelEntry(socket); // queue cancellation path return; - + case 'confirm': - this.handleAddressConfirmation(socket, true); + if (this.addressManager.pending.has(socket.id)) { + this.addressManager.confirm(socket.id, true); + return; + } + this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Nothing pending confirmation.'); return; case 'address': @@ -246,6 +398,7 @@ class SocketHandlers { } } + /** * Broadcast a generic chat message to all clients. Performs minimal sanitization * and rate limiting (simple cooldown per socket) to avoid spam / abuse. @@ -334,6 +487,15 @@ class SocketHandlers { if (this.gameModeManager) { try { const eligibility = await this.gameModeManager.canUserStartGame(socket.id); + // If in payout-eligible mode and user missing payout address, require address first + const payoutEligible = (this.gameModeManager.gameMode === 'PAID_SINGLE') || (this.gameModeManager.gameMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); + if (payoutEligible) { + const u = await this.gameModeManager.getOrCreateUser(socket.id); + if (!u.payout_address) { + this.broadcastManager.sendStatusUpdate(socket.id, 'payment', 'āš ļø Paste your payout address first, then type confirm.'); + return; + } + } if (!eligibility.allowed) { if (this.debugManager.CONSOLE_LOGGING) { @@ -443,7 +605,10 @@ class SocketHandlers { } // Clean up payment monitoring - this.stopPaymentMonitoring(socket.id); + // Payment monitoring cleanup (refactored into paymentHandlers) + if (this.paymentHandlers && typeof this.paymentHandlers.stopMonitoringForSocket === 'function') { + this.paymentHandlers.stopMonitoringForSocket(socket.id); + } // Clean up movement timestamps this.playerMoveTimestamps.delete(socket.id);