mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 15:18:33 +02:00
Continue working on payment logic
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-2
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user