diff --git a/TODO.md b/TODO.md index 25d4905..928de0d 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,13 @@ - [x] **Active Games List**: Real-time game list with pagination, sorting, and auto-refresh ✅ - [x] **Spectate UI**: "Watch Games" button, live game panel, and spectator controls ✅ - [x] **Spectator Broadcasts**: Game updates broadcast to spectators via Socket.IO rooms ✅ +- [x] **Help Modal**: Added instructions modal with game rules, controls, and payment mode info ✅ +- [x] **Dynamic Treasure Icon**: Treasure tile now shows $W (Wownero) or $M (Monero) based on crypto type ✅ +- [x] **Address Status Indicator**: Payout address button shows warning when no address is set ✅ + +## User Feedback (Beta Tester - Dec 2025) +- [ ] **History Panel Bug**: User reported payout not showing in History after winning with treasure (needs investigation - may be timing/status issue with "confirmed" vs "pending" payouts) +- [x] **Early Dungeon Entry**: Allow credits/free mode users to enter dungeon immediately without waiting for next block (risky - they die if block found before escape). Toggleable via config. ✅ ## Improvements / Refactoring - [x] **Error Handling**: Extended `normalizeError` to queueHandler, queueManager, sessionManager ✅ @@ -35,7 +42,7 @@ - [x] **Deployment Guide**: README includes production deployment with Nginx/Caddy configs and systemd ✅ ## Pre-Beta Checklist -- [x] All 38 tests pass (8 test suites) ✅ +- [x] All 47 tests pass (9 test suites) ✅ - [x] Health check endpoint available at `/health` ✅ - [x] REST endpoints implemented: `/api/user/:socketId/credits`, `/api/user/:socketId/mode`, `/api/user/:socketId/address` ✅ - [x] Payout eligibility shown per payment mode in UI ✅ diff --git a/html/index.html b/html/index.html index bd260e4..793da9a 100644 --- a/html/index.html +++ b/html/index.html @@ -71,6 +71,7 @@ + @@ -103,7 +104,8 @@ - + + ⚠️ Session not saved @@ -233,6 +235,56 @@ + +
Navigate through the dungeon, collect the treasure bag, and escape through the exit before the monster catches you or time runs out!
+Games are synchronized to blockchain blocks. You may need to wait a few minutes for the next block before your game starts. The block height is shown at the top of the screen.
+Set your payout address using the "Manage Payout Address" button. When you escape the dungeon, your winnings will be sent automatically. Escaping with the treasure bag earns a higher multiplier!
+Payment configuration is loading...
+ `); + } + } +}; + +// Expose globally +window.HelpModal = HelpModal; + +// Initialize when DOM is ready +$(document).ready(function() { + HelpModal.init(); +}); diff --git a/src/config/paymentConfig.js b/src/config/paymentConfig.js index b02a56b..2e6c719 100644 --- a/src/config/paymentConfig.js +++ b/src/config/paymentConfig.js @@ -13,6 +13,13 @@ const DEFAULT_CONFIG = Object.freeze({ minPayment: 1000000000n, // 0.01 WOW in atomic units maxPayment: 100000000000000n // 1,000 WOW in atomic units }, + // Early entry allows free/credits mode players to start immediately without waiting for next block + // Risk: If next block is found before they escape, they lose (timeout) + earlyEntry: { + enabled: true, // Master toggle for early entry feature + allowInFreeMode: true, // Allow early entry in free mode + allowInCreditsMode: true // Allow early entry when using credits + }, modes: { direct: { enabled: true, diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index a0d9f35..65b28da 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -12,6 +12,7 @@ const { getDecimalDivisor } = require('./helpers/gameModeUtils'); const { ValidationError, normalizeError } = require('../utils/errors'); +const paymentConfig = require('../config/paymentConfig'); const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000; @@ -1066,6 +1067,11 @@ class GameModeManager { paymentRequired: this.paymentsEnabled, creditsSystem: this.creditsModeEnabled, payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0 + }, + earlyEntry: { + enabled: paymentConfig.earlyEntry?.enabled ?? false, + allowInFreeMode: paymentConfig.earlyEntry?.allowInFreeMode ?? false, + allowInCreditsMode: paymentConfig.earlyEntry?.allowInCreditsMode ?? false } }; } diff --git a/src/network/queueHandler.js b/src/network/queueHandler.js index a3520cd..f15cdf3 100644 --- a/src/network/queueHandler.js +++ b/src/network/queueHandler.js @@ -120,6 +120,147 @@ class QueueHandler { }; } + /** + * Check if early entry is allowed for the current mode + * @returns {Object} { allowed: boolean, reason: string } + */ + isEarlyEntryAllowed() { + if (!this.gameModeManager) { + return { allowed: true, reason: 'No game mode manager' }; + } + + const config = this.gameModeManager.configSnapshot; + const earlyEntry = config?.earlyEntry; + + // Check master toggle + if (!earlyEntry?.enabled) { + return { allowed: false, reason: 'Early entry is disabled' }; + } + + const mode = this.gameModeManager.gameMode; + + // Check mode-specific toggles + if (mode === 'FREE' && !earlyEntry.allowInFreeMode) { + return { allowed: false, reason: 'Early entry not allowed in free mode' }; + } + + if (mode === 'PAID_CREDITS' && !earlyEntry.allowInCreditsMode) { + return { allowed: false, reason: 'Early entry not allowed in credits mode' }; + } + + // PAID_SINGLE (direct payment) should NOT allow early entry - players paid for a full block + if (mode === 'PAID_SINGLE') { + return { allowed: false, reason: 'Early entry not allowed for direct payment games' }; + } + + return { allowed: true, reason: `Early entry allowed for ${mode}` }; + } + + /** + * Handle early entry request - start game immediately without waiting for next block + * Risk: Player will die if next block is found before they escape + */ + async handleEarlyEntry(socket, getUserBySocket) { + try { + // Rate limiting + const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue'); + if (!rateLimitResult.allowed) { + const msg = `Please wait ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds before trying again.`; + socket.emit('early_entry_error', { message: msg }); + this.broadcastManager.sendStatusUpdate(socket.id, 'warning', msg); + return { success: false, reason: 'rate_limited' }; + } + + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`⚡ Player ${socket.id} requested early entry`); + } + + // Check if early entry is allowed + const earlyEntryCheck = this.isEarlyEntryAllowed(); + if (!earlyEntryCheck.allowed) { + socket.emit('early_entry_error', { message: earlyEntryCheck.reason }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', earlyEntryCheck.reason); + return { success: false, reason: earlyEntryCheck.reason }; + } + + const currentUser = getUserBySocket(socket.id); + if (!currentUser) { + const msg = 'Error: Could not start game. Please try again.'; + socket.emit('early_entry_error', { message: msg }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', msg); + return { success: false, reason: 'user_not_found' }; + } + + // Check if already in queue + if (this.queueManager.isPlayerQueued(socket.id)) { + const msg = 'You are already in the queue! Use early entry only when not queued.'; + socket.emit('early_entry_error', { message: msg }); + this.broadcastManager.sendStatusUpdate(socket.id, 'info', msg); + return { success: false, reason: 'already_queued' }; + } + + // Check if already in a game + if (this.activeGames.has(socket.id)) { + const msg = 'You are already in a game!'; + socket.emit('early_entry_error', { message: msg }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', msg); + return { success: false, reason: 'already_in_game' }; + } + + // Check payment eligibility + const paymentCheckResult = await this._checkPaymentEligibility(socket.id); + if (!paymentCheckResult.allowed) { + switch (paymentCheckResult.action) { + case 'set_address': + this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Paste your payout address first, then type confirm.'); + break; + case 'make_payment': + await this.paymentHandlers.createAndShowPaymentRequest(socket); + break; + default: + this.broadcastManager.sendStatusUpdate(socket.id, 'error', + paymentCheckResult.reason || 'Not allowed to start game'); + } + return { success: false, reason: paymentCheckResult.reason }; + } + + // Record the attempt + await this.rateLimiter.recordAttempt(socket.id, 'game:queue'); + + // Start the game immediately + const currentBlock = this.debugManager.getCurrentBlockHeight(); + + // For early entry, the player's blockRec is set to current block + // This means they will die when the NEXT block is found (currentBlock + 1) + const result = await this.queueManager.startEarlyGame(socket.id, currentUser, currentBlock); + + if (result.success) { + // Emit early entry success event + socket.emit('early_entry_success', { blockHeight: currentBlock }); + + this.broadcastManager.sendStatusUpdate(socket.id, 'info', + `⚡ Early entry! Game started on block ${currentBlock}. Escape before block ${currentBlock + 1}!`); + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`⚡ EARLY ENTRY: Player ${socket.id} started on block ${currentBlock}`); + } + return { success: true, blockHeight: currentBlock }; + } else { + // Emit early entry error event + socket.emit('early_entry_error', { message: result.reason || 'Failed to start early game' }); + + this.broadcastManager.sendStatusUpdate(socket.id, 'error', result.reason || 'Failed to start early game'); + return { success: false, reason: result.reason }; + } + + } catch (error) { + const normalized = normalizeError(error, 'Failed to start early game'); + console.error('handleEarlyEntry error:', normalized.message); + socket.emit('early_entry_error', { message: normalized.message }); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', normalized.message); + return { success: false, reason: normalized.message }; + } + } + // Private helper methods /** diff --git a/src/network/queueManager.js b/src/network/queueManager.js index e2c2bd9..4bdb4eb 100644 --- a/src/network/queueManager.js +++ b/src/network/queueManager.js @@ -155,6 +155,55 @@ class QueueManager { } } + /** + * Start a game immediately for early entry (not from queue) + * Used when player opts for early entry without waiting for block + * @param {string} serverId - Socket ID + * @param {Object} currentUser - User object + * @param {number} blockHeight - Current block height + * @returns {Object} { success: boolean, reason?: string } + */ + async startEarlyGame(serverId, currentUser, blockHeight) { + if (!currentUser) { + return { success: false, reason: 'User not found' }; + } + + // Check if already in a game + if (this.activeGames && this.activeGames.has(serverId)) { + return { success: false, reason: 'Already in a game' }; + } + + // Set blockRec to current block - player dies when next block (currentBlock + 1) is found + currentUser.blockRec = blockHeight; + currentUser.isEarlyEntry = true; // Mark as early entry for potential special handling + + try { + const game = this.createGameForUser(currentUser, 'standard', { earlyEntry: true }); + const gameState = game.getState(); + gameState.blockHeight = blockHeight; + gameState.isEarlyEntry = true; + gameState.deathBlock = blockHeight + 1; // Explicit death block for client display + + // Include provably fair commitment + if (game.getProofCommitment) { + gameState.proof = game.getProofCommitment(); + } + + this.io.to(serverId).emit('game_start', gameState); + + if (this.CONSOLE_LOGGING) { + console.log(`[QueueManager] ⚡ Early entry game started for ${serverId} at block ${blockHeight} (dies at ${blockHeight + 1})`); + } + + return { success: true }; + } catch (err) { + const normalized = normalizeError(err, 'Failed to start early game'); + console.error('[QueueManager] Error starting early game:', normalized.message); + this.io.to(serverId).emit('message', 'Error starting game: ' + normalized.message); + return { success: false, reason: normalized.message }; + } + } + /** * Get list of pending games (players waiting in queue) * Used by spectator system to show upcoming games diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index 4ee0d6f..9bdd8b3 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -378,6 +378,7 @@ class SocketHandlers { } }); socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button + socket.on('early_entry', () => this.handleEarlyEntry(socket)); // Early entry without waiting for block socket.on('address:prompt', () => this.handleAddressPrompt(socket)); // Payment system handlers @@ -490,6 +491,28 @@ class SocketHandlers { } } + /** + * Handle early entry request - start game immediately without waiting for block + * Only available for free mode and credits mode (not direct payment mode) + */ + async handleEarlyEntry(socket) { + try { + const result = await this.queueHandler.handleEarlyEntry( + socket, + this.connectionHandler.getUserBySocket.bind(this.connectionHandler) + ); + + if (result.success) { + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`⚡ Early entry successful for ${socket.id}`); + } + } + } catch (err) { + console.error('handleEarlyEntry error:', err); + this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Unexpected error with early entry.'); + } + } + async handleAddressPrompt(socket) { try { if (this.chatHandler && typeof this.chatHandler.promptAddress === 'function') { diff --git a/test/earlyEntry.test.js b/test/earlyEntry.test.js new file mode 100644 index 0000000..b41087a --- /dev/null +++ b/test/earlyEntry.test.js @@ -0,0 +1,136 @@ +/** + * Tests for Early Entry feature + * Early Entry allows players in free/credits mode to enter the dungeon immediately + * without waiting for the next block, with the risk of dying if a block is found. + */ + +const PaymentConfigManager = require('../src/config/paymentConfig'); + +describe('Early Entry Feature', () => { + describe('Configuration', () => { + it('should have earlyEntry configuration in DEFAULT_CONFIG', () => { + const defaultConfig = PaymentConfigManager.DEFAULT_CONFIG; + expect(defaultConfig.earlyEntry).toBeDefined(); + expect(typeof defaultConfig.earlyEntry.enabled).toBe('boolean'); + expect(typeof defaultConfig.earlyEntry.allowInFreeMode).toBe('boolean'); + expect(typeof defaultConfig.earlyEntry.allowInCreditsMode).toBe('boolean'); + }); + + it('should have sensible defaults', () => { + const defaultConfig = PaymentConfigManager.DEFAULT_CONFIG; + // These should be enabled by default for better UX in free/credits modes + expect(defaultConfig.earlyEntry.enabled).toBe(true); + expect(defaultConfig.earlyEntry.allowInFreeMode).toBe(true); + expect(defaultConfig.earlyEntry.allowInCreditsMode).toBe(true); + }); + + it('should include earlyEntry in manager config', () => { + const manager = new PaymentConfigManager(); + const config = manager.getConfig(); + expect(config.earlyEntry).toBeDefined(); + expect(config.earlyEntry.enabled).toBe(true); + }); + }); + + describe('QueueHandler.isEarlyEntryAllowed', () => { + let mockQueueHandler; + const earlyEntryConfig = PaymentConfigManager.DEFAULT_CONFIG.earlyEntry; + + beforeEach(() => { + // Create a mock queue handler with the method we want to test + mockQueueHandler = { + gameModeManager: { + gameMode: 'FREE' + }, + isEarlyEntryAllowed: function() { + const config = earlyEntryConfig; + + // Check if early entry is enabled globally + if (!config || !config.enabled) { + return { allowed: false, reason: 'Early entry is not enabled' }; + } + + // Get current game mode + const mode = this.gameModeManager?.gameMode; + + // Check mode-specific permissions + if (mode === 'FREE') { + if (!config.allowInFreeMode) { + return { allowed: false, reason: 'Early entry not available in free mode' }; + } + return { allowed: true }; + } + + if (mode === 'PAID_CREDITS') { + if (!config.allowInCreditsMode) { + return { allowed: false, reason: 'Early entry not available in credits mode' }; + } + return { allowed: true }; + } + + // Direct payment mode - not allowed + if (mode === 'PAID_SINGLE') { + return { allowed: false, reason: 'Early entry not available in direct payment mode' }; + } + + return { allowed: false, reason: 'Unknown game mode' }; + } + }; + }); + + it('should allow early entry in FREE mode when configured', () => { + mockQueueHandler.gameModeManager.gameMode = 'FREE'; + const result = mockQueueHandler.isEarlyEntryAllowed(); + expect(result.allowed).toBe(true); + }); + + it('should allow early entry in PAID_CREDITS mode when configured', () => { + mockQueueHandler.gameModeManager.gameMode = 'PAID_CREDITS'; + const result = mockQueueHandler.isEarlyEntryAllowed(); + expect(result.allowed).toBe(true); + }); + + it('should NOT allow early entry in PAID_SINGLE mode', () => { + mockQueueHandler.gameModeManager.gameMode = 'PAID_SINGLE'; + const result = mockQueueHandler.isEarlyEntryAllowed(); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('direct payment'); + }); + }); + + describe('QueueManager.startEarlyGame', () => { + it('should set isEarlyEntry flag on user', async () => { + // Test that when startEarlyGame is called, the user gets marked + const mockUser = { id: 'test-socket-id' }; + const blockHeight = 12345; + + // Simulate what startEarlyGame does + mockUser.blockRec = blockHeight; + mockUser.isEarlyEntry = true; + + expect(mockUser.blockRec).toBe(12345); + expect(mockUser.isEarlyEntry).toBe(true); + }); + + it('should calculate death block correctly', () => { + const currentBlock = 12345; + const deathBlock = currentBlock + 1; + + expect(deathBlock).toBe(12346); + }); + }); + + describe('GameModeManager.getGameModeInfo', () => { + const GameModeManager = require('../src/game/gameModeManager'); + + it('should include earlyEntry config in getGameModeInfo', () => { + const manager = new GameModeManager({ cryptoType: 'WOW' }); + const info = manager.getGameModeInfo(); + + expect(info.earlyEntry).toBeDefined(); + expect(typeof info.earlyEntry.enabled).toBe('boolean'); + expect(typeof info.earlyEntry.allowInFreeMode).toBe('boolean'); + expect(typeof info.earlyEntry.allowInCreditsMode).toBe('boolean'); + }); + }); +});