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);