Continue working on payment logic

This commit is contained in:
jwinterm
2025-09-27 11:21:11 -04:00
parent e3915dcde2
commit f8241dd309
18 changed files with 523 additions and 69 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -2
View File
@@ -8,9 +8,18 @@
<!-- Socket.io -->
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<!-- Initialize socket -->
<!-- Initialize socket with resume token (for persistent anonymous session) -->
<script>
window.socket = io();
(function(){
let opts = {};
try {
const existing = localStorage.getItem('wowngeon_token');
if (existing) {
opts.query = { resumeToken: existing };
}
} catch(_) {}
window.socket = io(opts);
})();
</script>
<!-- Core libraries -->
+3 -2
View File
@@ -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();
}
},
+12 -9
View File
@@ -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();
},
+10 -12
View File
@@ -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($('<li style="color: #0f0;">').text("🖱️ Game start requested..."));
UI.scrollChat();
});
+67
View File
@@ -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($('<li class="status">').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($('<li class="status">').text('Session resumed.'));
UI.scrollChat();
}
if (data && typeof data.credits === 'number') {
SocketHandlers._updateCreditsDisplay(data.credits);
}
if (data && data.payoutAddress) {
$('#messages').append($('<li class="address-confirmed" style="color:#0f0;">').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($('<li>').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('💳 <strong>Payment Required</strong>');
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)
+39 -2
View File
@@ -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) {
+5
View File
@@ -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
+21 -16
View File
@@ -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;
+2
View File
@@ -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
+6
View File
@@ -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);
+86
View File
@@ -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;
+24 -4
View File
@@ -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());
}
}
+50
View File
@@ -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;
+187 -22
View File
@@ -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);