From aa419689c8feb058711ff94d7302603aeb699816 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 21 Dec 2025 14:08:20 -0500 Subject: [PATCH] fix multimode payments and get credits working again --- LICENSE | 21 ++ PAYMENTS_TODO.md | 104 ++++++++ README.md | 26 ++ TODO.md | 9 +- html/index.html | 21 +- html/js/ui/paymentUI.js | 144 ++++++++--- src/game/gameModeManager.js | 402 +++++++++++++++++++++++++++---- src/index.js | 23 ++ src/network/paymentHandlers.js | 54 ++++- src/payments/moneroPayService.js | 40 +-- test/gameModeManager.test.js | 260 ++++++++++++++++++++ 11 files changed, 989 insertions(+), 115 deletions(-) create mode 100644 LICENSE create mode 100644 PAYMENTS_TODO.md create mode 100644 test/gameModeManager.test.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..555cdce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Such Software LLC, John Winter Murphy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/PAYMENTS_TODO.md b/PAYMENTS_TODO.md new file mode 100644 index 0000000..373caee --- /dev/null +++ b/PAYMENTS_TODO.md @@ -0,0 +1,104 @@ +# Payments System TODO + +This document tracks remaining work for the payment system before production release. + +## โœ… Completed + +### Mixed Mode Logic Bug - FIXED +~~The backend uses a single `gameMode` value (`PAID_SINGLE` or `PAID_CREDITS`), but the config allows both modes to be enabled simultaneously.~~ + +**Implemented:** +- Added `getEffectiveModeForUser(socketId)` - determines mode per-user based on credits balance +- Refactored `canUserStartGame()` to check credits first, then confirmed payments +- Added `getPaymentOptionsForUser(socketId)` for dynamic UI rendering +- Created `_processGameStartWithCredits()` and `_processGameStartWithPayment()` methods +- Frontend PaymentUI now dynamically shows options based on user's credits and enabled modes + +### Credits Package Amount Bug - FIXED +~~In `src/payments/moneroPayService.js#L150`, credits are hardcoded to +10~~ + +**Implemented:** +- Added `processCreditsPackageConfirmation(socketId, paymentId, packageInfo)` to `gameModeManager.js` +- Credits now parsed from package info (credits + bonus) passed from confirmation handler +- Falls back to parsing from payment description if package info missing +- Updated `paymentHandlers.js` to track `paymentType` and call confirmation method with package data + +### Payment Tests - Added +- Created `test/gameModeManager.test.js` with 17 tests covering: + - `getEffectiveModeForUser` - all mode combinations + - `calculatePayout` - direct and credits modes + - `getPaymentOptionsForUser` - credits, direct, buy options + - `processCreditsPackageConfirmation` - from package info and fallback + - `canUserStartGame` - mixed mode scenarios + +--- + +## ๐ŸŸก Important (Should Fix Before Beta) + +### Frontend UX Polish +- [x] Show user's current credit balance in mode selection modal +- [x] If user has credits, highlight "Use 1 Credit" as primary action +- [x] Add "Buy More Credits" secondary button +- [x] Show payout eligibility per mode (Direct = payouts, Credits = no payouts) + +### Session Persistence Documentation +- [x] Add info tooltip near "Manage Payout Address" explaining session persistence +- [x] Update README with session/cookie requirements +- [x] Show warning if localStorage is unavailable (private browsing) + +--- + +## ๐ŸŸข Nice to Have (Post-Beta) + +### Smart Payment Flow +- [ ] If user has credits, skip payment modal and start game directly +- [ ] "Auto-buy credits" when balance is low (opt-in) +- [ ] Show estimated games remaining based on credits + +### Payment History +- [ ] `/api/user/:socketId/payments` - List payment history +- [ ] `/api/user/:socketId/payouts` - List payout history +- [ ] Frontend "Transaction History" panel + +### Refund Flow +- [ ] Admin endpoint to refund a payment +- [ ] Partial credit refunds + +--- + +## Session Persistence - User-Facing Documentation + +Add to README and/or in-game help: + +> **How Sessions Work** +> +> Your payout address and credit balance are tied to a session token stored in your browser. +> +> - โœ… **Works:** Normal browsing, closing/reopening browser, refreshing page +> - โš ๏ธ **Lost if:** You clear cookies/localStorage, use private/incognito mode, switch browsers +> +> **Tip:** After setting your payout address, note your session token (shown once) if you want to recover your session on a new device. + +--- + +## Test Coverage Checklist + +| Area | Unit Tests | Integration Tests | +|------|------------|-------------------| +| Payment creation | โœ… | โœ… | +| Payment confirmation | โœ… | โœ… | +| Credits purchase | โœ… | โŒ | +| Credits deduction | โœ… | โŒ | +| Payout creation | โš ๏ธ Mocked | โŒ | +| Payout processing | โŒ | โŒ | +| Mixed mode selection | โœ… | โŒ | +| Session resume | โŒ | โŒ | + +--- + +## Environment Config Validation + +When both modes are enabled, ensure: +- [ ] `CREDITS_PAYOUTS_ENABLED=false` when credits mode has no payouts (prevent config mistakes) +- [ ] Warn if `DIRECT_PAYOUTS_ENABLED=true` but no wallet RPC configured +- [ ] Validate `CREDITS_PACKAGES` JSON is well-formed at startup \ No newline at end of file diff --git a/README.md b/README.md index 3c64fce..f4ec881 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,32 @@ BLOCK_SOURCE=daemon 2. Regex detection flags the message and issues a warning. 3. Player confirms; the address is persisted for payouts. +## Session Persistence + +Player sessions are tied to an anonymous token stored in the browser's localStorage. This token persists: + +- โœ… **Across page refreshes** โ€“ Reconnecting reloads your credits and payout address. +- โœ… **After closing the browser** โ€“ The token survives normal browser sessions. +- โœ… **Server restarts** โ€“ Session data is stored in PostgreSQL, not memory. + +Sessions are **lost** when: + +- โš ๏ธ Clearing cookies or localStorage +- โš ๏ธ Using private/incognito browsing mode (localStorage may be disabled or ephemeral) +- โš ๏ธ Switching browsers or devices + +**Important notes for players:** + +1. Your payout address and credit balance are tied to your session token. +2. If localStorage is unavailable (private browsing), a warning appears next to the "Manage Payout Address" button. +3. There is no account systemโ€”your session token *is* your identity. Treat it carefully. + +**For deployment:** + +- Ensure your reverse proxy preserves WebSocket connections for token handshake. +- The `wownerogue_token` key in localStorage contains the resume token. +- Session data (credits, address) is stored in the `users` table with the token in `anon_token`. + ## Testing Run the Jest suite from `src`: diff --git a/TODO.md b/TODO.md index f0e5bb1..063737d 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,9 @@ - [x] **Verify Server Startup**: Server starts cleanly with `npm run dev` โœ… - [ ] **Test Payment Flow**: Verify the full payment lifecycle (Request -> QR -> Payment -> Confirmation -> Game Start). - [ ] **Test Payouts**: Confirm payouts are triggered correctly upon winning in paid modes. -- [ ] **Address Persistence**: Verify that payout addresses persist across server restarts via session tokens. +- [x] **Address Persistence**: Verify that payout addresses persist across server restarts via session tokens. โœ… +- [x] **Mixed Mode Logic**: Per-user mode determination based on credits balance โœ… +- [x] **Credits Package Bug**: Credits correctly parsed from package info on confirmation โœ… ## Improvements / Refactoring - [ ] **Error Handling**: Extend `normalizeError` usage to remaining network modules for consistent error reporting. @@ -14,12 +16,15 @@ ## Documentation - [x] **API Documentation**: README updated with accurate API surface including health check and Socket.IO events โœ… +- [x] **Session Persistence**: README documents session token behavior and localStorage requirements โœ… - [ ] **Deployment Guide**: Create a detailed deployment guide including reverse proxy setup (Nginx/Caddy). ## Pre-Beta Checklist -- [x] All 21 tests pass โœ… +- [x] All 38 tests pass (8 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 โœ… +- [x] Session persistence tooltip and localStorage warning added โœ… - [ ] **TLS/HTTPS**: Configure reverse proxy (Nginx/Caddy) with SSL certificates before exposing to real users. - [ ] **Database Backups**: Implement automated backup strategy for PostgreSQL. - [ ] **Manual Integration Test**: Run full payment flow with real `wownero-wallet-rpc` (not mocked). diff --git a/html/index.html b/html/index.html index 994ce5a..ffe33af 100644 --- a/html/index.html +++ b/html/index.html @@ -12,13 +12,28 @@ @@ -70,7 +85,8 @@ - + + @@ -149,6 +165,7 @@

Manage Payout Address

+
diff --git a/html/js/ui/paymentUI.js b/html/js/ui/paymentUI.js index 17b1884..533a4cc 100644 --- a/html/js/ui/paymentUI.js +++ b/html/js/ui/paymentUI.js @@ -4,6 +4,8 @@ */ const PaymentUI = { config: null, + userCredits: 0, + hasPayoutAddress: false, init: function() { console.log("Initializing PaymentUI..."); @@ -13,10 +15,11 @@ const PaymentUI = { $('#payment-ui').hide(); }); - // Bind mode options - $('.mode-option').on('click', function() { + // Bind mode options (use delegation for dynamic content) + $('#payment-ui').on('click', '.mode-option', function() { const mode = $(this).data('mode'); - PaymentUI.handleModeSelection(mode); + const action = $(this).data('action'); + PaymentUI.handleModeSelection(mode, action); }); // Bind check payment button @@ -56,6 +59,13 @@ const PaymentUI = { $('#shopButton').on('click', function() { PaymentUI.show(); }); + + // Listen for credits updates + if (window.socket) { + window.socket.on('credits_update', function(data) { + PaymentUI.updateCredits(data.balance); + }); + } }, updateConfig: function(config) { @@ -63,6 +73,13 @@ const PaymentUI = { this.config = config; this.render(); }, + + updateCredits: function(balance) { + this.userCredits = balance || 0; + this.render(); + // Update credit display if visible + $('#user-credits-display').text(this.userCredits); + }, render: function() { if (!this.config) return; @@ -70,51 +87,100 @@ const PaymentUI = { const currency = this.config.cryptoType || 'WOW'; const decimals = currency === 'XMR' ? 12 : 11; const divisor = Math.pow(10, decimals); - - // Update PAID_SINGLE - if (this.config.directModeEnabled) { - const price = (this.config.singleGamePrice / divisor).toFixed(decimals === 12 ? 4 : 2); - const $el = $('.mode-option[data-mode="PAID_SINGLE"]'); - $el.html(`๐Ÿ’ฐ PAID SINGLE - ${price} ${currency} per game
Payouts enabled for winners`); - $el.show(); - } else { - $('.mode-option[data-mode="PAID_SINGLE"]').hide(); - } - - // Update PAID_CREDITS + const bothEnabled = this.config.directModeEnabled && this.config.creditsModeEnabled; + const hasCredits = this.userCredits >= (this.config.creditsPerGame || 1); + + // Build dynamic options container + const $container = $('.game-modes'); + $container.empty(); + + // Add credits balance display if credits mode is enabled if (this.config.creditsModeEnabled) { - // Note: config.creditsPackagePrice is for the default package - const price = (this.config.creditsPackagePrice / divisor).toFixed(decimals === 12 ? 4 : 2); - // We assume the default package size is 10 if not specified, but let's be vague if unsure - // Ideally we'd get package info. For now, "Credits Package" is safe. - const $el = $('.mode-option[data-mode="PAID_CREDITS"]'); - $el.html(`๐ŸŽซ CREDITS PACKAGE - ${price} ${currency}
Bulk discount, no payouts`); - $el.show(); - } else { - $('.mode-option[data-mode="PAID_CREDITS"]').hide(); + $container.append(` +
+ Your Credits: + ${this.userCredits} +
+ `); } - // Update FREE + // If user has credits and credits mode is enabled, show "Use Credit" option first + if (hasCredits && this.config.creditsModeEnabled) { + const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled; + const payoutNote = creditsPayoutsEnabled + ? '๐Ÿ’ฐ Payouts enabled' + : 'โšก Fast play โ€ข No payouts'; + $container.append(` + + `); + } + + // Show direct payment option + if (this.config.directModeEnabled) { + const price = (this.config.singleGamePrice / divisor).toFixed(decimals === 12 ? 4 : 2); + const recommended = !hasCredits ? 'recommended' : ''; + const directPayoutsEnabled = this.config.directPayoutsEnabled !== false; // Default true + const payoutInfo = directPayoutsEnabled + ? '๐Ÿ’ฐ 2x payout on escape, 3x with treasure' + : 'โšก Play mode โ€ข No payouts'; + $container.append(` + + `); + } + + // Show buy credits option if credits mode is enabled + if (this.config.creditsModeEnabled) { + const price = (this.config.creditsPackagePrice / divisor).toFixed(decimals === 12 ? 4 : 2); + const pkgCredits = this.config.creditsPackageCount || 10; + $container.append(` + + `); + } + + // Show FREE option only if payments are disabled if (!this.config.paymentsEnabled) { - $('.mode-option[data-mode="FREE"]').show(); - // If payments are disabled, maybe hide the others? - // The logic above handles hiding if enabled flags are false. - } else { - // If payments are enabled, FREE is usually disabled/hidden - $('.mode-option[data-mode="FREE"]').hide(); + $container.append(` + + `); } }, - handleModeSelection: function(mode) { - console.log("Mode selected:", mode); + handleModeSelection: function(mode, action) { + console.log("Mode selected:", mode, "Action:", action); if (mode === 'FREE') { $('#payment-ui').hide(); + // Emit to start game directly + if (window.socket) { + window.socket.emit('enter_game'); + } return; } + // If using existing credits, just start the game + if (action === 'use_credit' && this.userCredits >= 1) { + $('#payment-ui').hide(); + if (window.socket) { + window.socket.emit('enter_game'); // Will use credits automatically + } + return; + } + + // Otherwise, request payment let paymentType = 'single_game'; - if (mode === 'PAID_CREDITS') { + if (action === 'buy_credits') { paymentType = 'credits_package'; } @@ -134,7 +200,10 @@ const PaymentUI = { $('#payment-ui').show(); $('#payment-details').show(); $('.game-modes').hide(); // Hide selection, show payment - $('.payment-header strong').text('๐Ÿ’ณ Payment Required'); + + const isCreditsPurchase = data.paymentType === 'credits_package'; + const headerText = isCreditsPurchase ? '๐ŸŽซ Buy Credits' : '๐Ÿ’ณ Payment Required'; + $('.payment-header strong').text(headerText); const displayAmount = data.humanAmount || data.amountFormatted || data.amount; $('#payment-amount').text(displayAmount + ' ' + (data.currency || '')); @@ -144,7 +213,10 @@ const PaymentUI = { if (data.reused) { $('#payment-status').html('โ™ป๏ธ Using existing pending payment'); } else { - $('#payment-status').text('Waiting for payment...'); + const statusText = isCreditsPurchase + ? 'Send payment to receive credits...' + : 'Waiting for payment...'; + $('#payment-status').text(statusText); } }, diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index e355aff..c218994 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -239,6 +239,79 @@ class GameModeManager { return { amount, multiplier, base }; } + /** + * Process a confirmed credits package payment - add credits to user + * @param {string} socketId - Socket ID of the user + * @param {number} paymentId - Payment record ID + * @param {object} packageInfo - Package info (credits, bonus, etc.) + * @returns {object} Result with success, creditsAdded, newBalance + */ + async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) { + try { + const user = await this.getOrCreateUser(socketId); + + // Determine credits to add from package or fallback + let creditsToAdd = 10; // Default fallback + if (packageInfo && packageInfo.credits) { + creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0); + } else { + // Try to get from payment record description + const paymentResult = await this.db.query(` + SELECT description FROM payments WHERE id = $1 + `, [paymentId]); + if (paymentResult.rows.length > 0) { + const desc = paymentResult.rows[0].description || ''; + const match = desc.match(/(\d+)\s*credits?/i); + if (match) { + creditsToAdd = parseInt(match[1], 10) || 10; + } + } + } + + // Update user credits + const updateResult = await this.db.query(` + UPDATE users + SET credits = credits + $1, + total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1, + updated_at = NOW() + WHERE id = $2 + RETURNING credits + `, [creditsToAdd, user.id]); + + const newBalance = updateResult.rows[0]?.credits ?? (user.credits + creditsToAdd); + + // Mark payment as processed + await this.db.query(` + UPDATE payments + SET status = 'confirmed', + credits_purchased = $1, + confirmed_at = NOW() + WHERE id = $2 + `, [creditsToAdd, paymentId]); + + // Record credit transaction + await this.db.query(` + INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) + VALUES ($1, $2, 'package_purchase', $3, 'purchase') + `, [user.id, creditsToAdd, newBalance]); + + console.log(`๐Ÿ’ฐ Credits package confirmed: +${creditsToAdd} credits for user ${user.id}, new balance: ${newBalance}`); + + return { + success: true, + creditsAdded: creditsToAdd, + newBalance: newBalance + }; + } catch (error) { + const normalized = normalizeError(error, 'Failed to process credits package confirmation'); + console.error('โŒ Error processing credits package:', normalized.message); + return { + success: false, + reason: normalized.safeMessage + }; + } + } + async _findReusablePayment(userId, paymentType) { if (!userId) return null; const result = await this.db.query(` @@ -285,23 +358,193 @@ class GameModeManager { console.log(`โš™๏ธ Payments enabled: ${this.paymentsEnabled}`); } + /** + * Get effective game mode for a specific user, considering: + * - Whether both modes are enabled (mixed mode) + * - User's credit balance + * - preferCreditsFirst setting + * @param {object} user - User object with credits field + * @returns {object} { mode, canUseCredits, canUseDirect, hasCredits, creditsBalance } + */ + getEffectiveModeForUser(user) { + const hasCredits = (user?.credits || 0) >= this.creditsPerGameCost; + const bothModesEnabled = this.directModeEnabled && this.creditsModeEnabled; + + // Determine available options + const canUseCredits = this.creditsModeEnabled && hasCredits; + const canUseDirect = this.directModeEnabled; + + // Determine effective mode + let effectiveMode; + if (!this.paymentsEnabled) { + effectiveMode = 'FREE'; + } else if (bothModesEnabled) { + // Mixed mode: prefer based on config and availability + if (this.preferCreditsFirst && hasCredits) { + effectiveMode = 'PAID_CREDITS'; + } else if (hasCredits) { + effectiveMode = 'PAID_CREDITS'; // Has credits, can use them + } else { + effectiveMode = 'PAID_SINGLE'; // No credits, must pay + } + } else if (this.creditsModeEnabled) { + effectiveMode = 'PAID_CREDITS'; + } else if (this.directModeEnabled) { + effectiveMode = 'PAID_SINGLE'; + } else { + effectiveMode = 'FREE'; + } + + return { + mode: effectiveMode, + canUseCredits, + canUseDirect, + hasCredits, + creditsBalance: user?.credits || 0, + creditsRequired: this.creditsPerGameCost, + bothModesEnabled, + preferCreditsFirst: this.preferCreditsFirst, + creditsPayoutsEnabled: this.creditsPayoutEnabled, + directPayoutsEnabled: this.directPayoutMultipliers.escape > 0 + }; + } + + /** + * Get available payment options for a user + * @param {string} socketId - Socket ID + * @returns {object} Available options for the user + */ + async getPaymentOptionsForUser(socketId) { + try { + const user = await this.getOrCreateUser(socketId); + const effective = this.getEffectiveModeForUser(user); + + const options = []; + + if (effective.canUseCredits) { + options.push({ + type: 'use_credit', + label: `Use 1 Credit (${effective.creditsBalance} available)`, + mode: 'PAID_CREDITS', + cost: 0, + costDisplay: '1 credit', + payoutEligible: effective.creditsPayoutsEnabled, + recommended: effective.preferCreditsFirst + }); + } + + if (effective.canUseDirect) { + options.push({ + type: 'pay_direct', + label: `Pay ${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`, + mode: 'PAID_SINGLE', + cost: this.singleGamePrice, + costDisplay: `${this.formatAtomicHuman(this.singleGamePrice, 2)} ${this.cryptoType}`, + payoutEligible: effective.directPayoutsEnabled, + recommended: !effective.preferCreditsFirst || !effective.hasCredits + }); + } + + if (this.creditsModeEnabled) { + const pkg = this.getPrimaryCreditPackage(); + options.push({ + type: 'buy_credits', + label: `Buy ${pkg.credits} Credits`, + mode: 'PURCHASE', + cost: Number(pkg.price), + costDisplay: `${this.formatAtomicHuman(pkg.price, 2)} ${this.cryptoType}`, + credits: pkg.credits + (pkg.bonus || 0), + payoutEligible: false, + recommended: false + }); + } + + return { + user: { + credits: effective.creditsBalance, + hasPayoutAddress: !!user.payout_address + }, + effective, + options + }; + } catch (error) { + const normalized = normalizeError(error, 'Failed to get payment options'); + console.error('โŒ Error getting payment options:', normalized.message); + return { + user: { credits: 0, hasPayoutAddress: false }, + effective: { mode: 'FREE' }, + options: [] + }; + } + } + /** * Check if user can start a game */ async canUserStartGame(socketId) { try { const user = await this.getOrCreateUser(socketId); + const effective = this.getEffectiveModeForUser(user); - switch (this.gameMode) { - case 'FREE': - return { allowed: true, reason: 'Free mode' }; - + // FREE mode or payments disabled + if (effective.mode === 'FREE') { + return { allowed: true, reason: 'Free mode' }; + } + + // Mixed mode: check if user can play with credits OR has confirmed payment + if (effective.bothModesEnabled) { + // Option 1: User has credits + if (effective.hasCredits) { + return { + allowed: true, + reason: `${effective.creditsBalance} credits available`, + useCredits: true, + creditsRequired: this.creditsPerGameCost, + balance: effective.creditsBalance, + effectiveMode: 'PAID_CREDITS' + }; + } + + // Option 2: User has confirmed single_game payment + const pendingPayment = await this.db.query(` + SELECT * FROM payments + WHERE socket_id = $1 AND status = 'confirmed' + AND payment_type = 'single_game' + AND NOT EXISTS ( + SELECT 1 FROM games + WHERE games.payment_id = payments.id + ) + ORDER BY confirmed_at DESC + LIMIT 1 + `, [socketId]); + + if (pendingPayment.rows.length > 0) { + return { + allowed: true, + reason: 'Payment confirmed', + paymentId: pendingPayment.rows[0].id, + effectiveMode: 'PAID_SINGLE' + }; + } + + // Neither credits nor payment available + return { + allowed: false, + reason: 'Payment or credits required', + action: 'choose_payment', + options: await this.getPaymentOptionsForUser(socketId) + }; + } + + // Single mode logic (backwards compatible) + switch (effective.mode) { case 'PAID_CREDITS': - if (user.credits >= this.creditsPerGameCost) { + if (effective.hasCredits) { return { allowed: true, - reason: `${user.credits} credits remaining`, - creditsRequired: this.creditsPerGameCost + reason: `${effective.creditsBalance} credits remaining`, + creditsRequired: this.creditsPerGameCost, + effectiveMode: 'PAID_CREDITS' }; } return { @@ -309,12 +552,11 @@ class GameModeManager { reason: 'Insufficient credits', action: 'purchase_credits', creditsRequired: this.creditsPerGameCost, - balance: user.credits + balance: effective.creditsBalance }; case 'PAID_SINGLE': - // Check for pending payment - const pendingPayment = await this.db.query(` + const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' AND payment_type = 'single_game' @@ -326,11 +568,12 @@ class GameModeManager { LIMIT 1 `, [socketId]); - if (pendingPayment.rows.length > 0) { + if (payment.rows.length > 0) { return { allowed: true, reason: 'Payment confirmed', - paymentId: pendingPayment.rows[0].id + paymentId: payment.rows[0].id, + effectiveMode: 'PAID_SINGLE' }; } @@ -356,31 +599,51 @@ class GameModeManager { async processGameStart(socketId, gameId) { try { const user = await this.getOrCreateUser(socketId); + const effective = this.getEffectiveModeForUser(user); - switch (this.gameMode) { - case 'FREE': - // No processing needed for free mode - return { success: true }; - + // FREE mode + if (effective.mode === 'FREE') { + return { success: true, effectiveMode: 'FREE' }; + } + + // Mixed mode: determine which method to use + if (effective.bothModesEnabled) { + // Prefer credits if available and preferCreditsFirst is true + if (effective.hasCredits && this.preferCreditsFirst) { + return await this._processGameStartWithCredits(user, socketId, gameId); + } + + // Check for confirmed direct payment + const payment = await this.db.query(` + SELECT * FROM payments + WHERE socket_id = $1 AND status = 'confirmed' + AND payment_type = 'single_game' + AND NOT EXISTS ( + SELECT 1 FROM games + WHERE games.payment_id = payments.id + ) + ORDER BY confirmed_at DESC + LIMIT 1 + `, [socketId]); + + if (payment.rows.length > 0) { + return await this._processGameStartWithPayment(user, payment.rows[0], gameId); + } + + // Fall back to credits if available + if (effective.hasCredits) { + return await this._processGameStartWithCredits(user, socketId, gameId); + } + + return { success: false, reason: 'No valid payment or credits found' }; + } + + // Single mode logic + switch (effective.mode) { case 'PAID_CREDITS': - // Deduct required credits for this mode - const creditsToSpend = this.creditsPerGameCost; - const updateRes = await this.db.query(` - UPDATE users - SET credits = credits - $1, - total_games_played = total_games_played + 1, - updated_at = NOW() - WHERE id = $2 - RETURNING credits - `, [creditsToSpend, user.id]); - const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend); - // 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 ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`); - return { success: true, creditsRemaining: remainingCredits, creditsSpent: creditsToSpend }; + return await this._processGameStartWithCredits(user, socketId, gameId); case 'PAID_SINGLE': - // Link game to payment const payment = await this.db.query(` SELECT * FROM payments WHERE socket_id = $1 AND status = 'confirmed' @@ -397,21 +660,7 @@ class GameModeManager { return { success: false, reason: 'No valid payment found' }; } - await this.db.query(` - UPDATE games - SET payment_id = $1 - WHERE id = $2 - `, [payment.rows[0].id, gameId]); - - await this.db.query(` - UPDATE users - SET total_games_played = total_games_played + 1, - updated_at = NOW() - WHERE id = $1 - `, [user.id]); - - console.log(`๐Ÿ’ณ Linked game ${gameId} to payment ${payment.rows[0].id}`); - return { success: true, paymentId: payment.rows[0].id }; + return await this._processGameStartWithPayment(user, payment.rows[0], gameId); default: return { success: false, reason: 'Invalid game mode' }; @@ -423,6 +672,63 @@ class GameModeManager { } } + async _processGameStartWithCredits(user, socketId, gameId) { + const creditsToSpend = this.creditsPerGameCost; + const updateRes = await this.db.query(` + UPDATE users + SET credits = credits - $1, + total_games_played = total_games_played + 1, + updated_at = NOW() + WHERE id = $2 + RETURNING credits + `, [creditsToSpend, user.id]); + const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend); + + // Record the game mode used + await this.db.query(` + UPDATE games SET payment_mode = 'credits' WHERE id = $1 + `, [gameId]); + + // Record credit transaction + await this.db.query(` + INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) + VALUES ($1, $2, 'game_entry', $3, 'spend') + `, [user.id, -creditsToSpend, remainingCredits]); + + // Emit credits update + try { this.io && this.io.to(socketId).emit('credits_update', { balance: remainingCredits }); } catch(_) {} + console.log(`๐ŸŽซ Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`); + + return { + success: true, + creditsRemaining: remainingCredits, + creditsSpent: creditsToSpend, + effectiveMode: 'PAID_CREDITS' + }; + } + + async _processGameStartWithPayment(user, payment, gameId) { + await this.db.query(` + UPDATE games + SET payment_id = $1, payment_mode = 'direct' + WHERE id = $2 + `, [payment.id, gameId]); + + await this.db.query(` + UPDATE users + SET total_games_played = total_games_played + 1, + updated_at = NOW() + WHERE id = $1 + `, [user.id]); + + console.log(`๐Ÿ’ณ Linked game ${gameId} to payment ${payment.id}`); + return { + success: true, + paymentId: payment.id, + effectiveMode: 'PAID_SINGLE' + }; + } + /** * Process game completion (handle payouts) */ diff --git a/src/index.js b/src/index.js index 9ef5300..b6bc4fc 100644 --- a/src/index.js +++ b/src/index.js @@ -200,6 +200,8 @@ app.get('/health', (req, res) => { memoryUsage: process.memoryUsage(), gameMode: gameModeManager.gameMode, paymentsEnabled: gameModeManager.paymentsEnabled, + directModeEnabled: gameModeManager.directModeEnabled, + creditsModeEnabled: gameModeManager.creditsModeEnabled, walletHealthy: walletRPCService.isHealthy, activeGames: activeGames.size, debugMode: debugManager.getDebugStatus().debugMode @@ -207,6 +209,27 @@ app.get('/health', (req, res) => { res.json(health); }); +// Get payment options for a user (mixed mode support) +app.get('/api/user/:socketId/payment-options', asyncHandler(async (req, res) => { + const { socketId } = req.params; + if (!socketId) { + throw new ValidationError('Missing socketId', { + safeMessage: 'socketId parameter is required.' + }); + } + + try { + const options = await gameModeManager.getPaymentOptionsForUser(socketId); + res.json(options); + } catch (error) { + throw new AppError('Failed to retrieve payment options', { + statusCode: 500, + safeMessage: 'Unable to retrieve payment options.', + cause: error + }); + } +})); + app.get('/api/game-modes', (req, res) => { const config = paymentConfigManager.getConfig(); const decimals = Number(config.currency?.decimals ?? 12); diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index a167917..994ba8c 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -153,7 +153,7 @@ class PaymentHandlers { // If we reused an existing request, ensure we refresh monitoring to avoid duplicate watchers this.stopMonitoringForSocket(socket.id); - this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser); + this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType); } catch (e) { const err = normalizeError(e, 'Failed to create payment request'); console.error('Error creating payment request:', err.message); @@ -161,13 +161,15 @@ class PaymentHandlers { } } - _monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser) { + _monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser, paymentType = 'single_game') { // Record mapping so we can stop later (replace existing entry) this.socketPaymentMap.set(socket.id, { address: paymentRequest.address, paymentId: paymentRequest.id, amount: paymentRequest.amount, cryptoType, + paymentType, + package: paymentRequest.package, createdAt: Date.now() }); @@ -184,15 +186,47 @@ class PaymentHandlers { } else if (status.confirmed) { if (!this.confirmedPayments.has(paymentRequest.id)) { this.confirmedPayments.add(paymentRequest.id); - socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); this._confirmedTimestamps.set(paymentRequest.id, Date.now()); - this.queueManager.markConfirmed(socket.id); - // Attempt immediate game start so user doesn't wait another full block - const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; - if (currentBlock !== null) { - const started = this.queueManager.startGameImmediately(socket.id, currentBlock); - if (!started && this.debugManager.CONSOLE_LOGGING) { - console.log(`[PaymentHandlers] Immediate start skipped (not queued or still unconfirmed) for ${socket.id}`); + + // Handle credits_package: add credits to user + const mapping = this.socketPaymentMap.get(socket.id); + if (mapping && mapping.paymentType === 'credits_package' && this.gameModeManager) { + try { + const creditsResult = await this.gameModeManager.processCreditsPackageConfirmation( + socket.id, + paymentRequest.id, + mapping.package + ); + if (creditsResult.success) { + socket.emit('credits_update', { balance: creditsResult.newBalance }); + socket.emit('payment_confirmed', { + paymentId: paymentRequest.id, + message: `Payment confirmed! Added ${creditsResult.creditsAdded} credits. New balance: ${creditsResult.newBalance}`, + creditsAdded: creditsResult.creditsAdded, + newBalance: creditsResult.newBalance, + confirmations: status.confirmations + }); + this.broadcastManager.sendStatusUpdate(socket.id, 'success', + `โœ… CREDITS PURCHASED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.\n\nType 'enter' to start a game!`); + } else { + console.error('Failed to process credits package:', creditsResult.reason); + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed but failed to add credits. Contact support.', confirmations: status.confirmations }); + } + } catch (e) { + console.error('Error processing credits package confirmation:', e.message); + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); + } + } else { + // single_game payment - standard flow + socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); + this.queueManager.markConfirmed(socket.id); + // Attempt immediate game start so user doesn't wait another full block + const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; + if (currentBlock !== null) { + const started = this.queueManager.startGameImmediately(socket.id, currentBlock); + if (!started && this.debugManager.CONSOLE_LOGGING) { + console.log(`[PaymentHandlers] Immediate start skipped (not queued or still unconfirmed) for ${socket.id}`); + } } } } diff --git a/src/payments/moneroPayService.js b/src/payments/moneroPayService.js index 8875ffa..aacec10 100644 --- a/src/payments/moneroPayService.js +++ b/src/payments/moneroPayService.js @@ -106,24 +106,23 @@ class MoneroPayService { } /** - * Process payment confirmation callback + * Process payment callback (when payment confirmed) */ - async processPaymentCallback(socketId, paymentData) { + async processPaymentCallback(paymentData) { try { - const payment = await this.db.query(` + // Find the payment record + const paymentResult = await this.db.query(` SELECT * FROM payments - WHERE socket_id = $1 AND status = 'pending' - ORDER BY created_at DESC - LIMIT 1 - `, [socketId]); + WHERE subaddress = $1 AND status = 'pending' + `, [paymentData.address]); - if (payment.rows.length === 0) { - console.warn(`โš ๏ธ No pending payment found for socket ${socketId}`); + if (paymentResult.rows.length === 0) { + console.warn('โš ๏ธ Payment callback for unknown address:', paymentData.address); return false; } - const paymentRecord = payment.rows[0]; - + const paymentRecord = paymentResult.rows[0]; + // Update payment status await this.db.query(` UPDATE payments @@ -144,16 +143,23 @@ class MoneroPayService { // Handle different payment types if (paymentRecord.payment_type === 'credits_package') { - // Add 10 credits to user + // Parse credits from description or use default + // Description format: "Wowngeon X credits package (WOW)" + let creditsToAdd = 10; // Default fallback + const descMatch = paymentRecord.description?.match(/(\d+)\s*credits/i); + if (descMatch) { + creditsToAdd = parseInt(descMatch[1], 10) || 10; + } + await this.db.query(` UPDATE users - SET credits = credits + 10, - total_amount_paid = total_amount_paid + $1, + SET credits = credits + $1, + total_amount_paid = total_amount_paid + $2, updated_at = NOW() - WHERE id = $2 - `, [paymentData.amount, paymentRecord.user_id]); + WHERE id = $3 + `, [creditsToAdd, paymentData.amount, paymentRecord.user_id]); - console.log(`๐Ÿ’ฐ Added 10 credits to user ${paymentRecord.user_id}`); + console.log(`๐Ÿ’ฐ Added ${creditsToAdd} credits to user ${paymentRecord.user_id}`); } console.log(`โœ… Payment confirmed: ${paymentData.tx_hash} for ${paymentData.amount} atomic units`); diff --git a/test/gameModeManager.test.js b/test/gameModeManager.test.js new file mode 100644 index 0000000..99f69dd --- /dev/null +++ b/test/gameModeManager.test.js @@ -0,0 +1,260 @@ +/** + * GameModeManager Tests + * Tests for mixed mode, credits, and payment logic + */ + +// Mock the database +const mockDb = { + query: jest.fn() +}; + +// Mock wallet service +const mockWalletService = { + createPaymentRequest: jest.fn(), + processPayout: jest.fn() +}; + +// Mock debug manager +const mockDebugManager = { + CONSOLE_LOGGING: false +}; + +// Mock payment config manager +const mockPaymentConfigManager = { + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { + direct: { enabled: true, price: 100000000000n }, + credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] } + }, + payouts: { + rules: { + direct: { multipliers: { escape: 2, escapeWithTreasure: 3 } }, + credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 } } + } + }, + preferences: { preferCreditsFirst: true } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: () => {} } +}; + +const GameModeManager = require('../src/game/gameModeManager'); + +describe('GameModeManager', () => { + let gmm; + + beforeEach(() => { + jest.clearAllMocks(); + mockDb.query.mockReset(); + gmm = new GameModeManager(mockDb, mockWalletService, mockDebugManager, mockPaymentConfigManager); + }); + + describe('getEffectiveModeForUser', () => { + test('returns FREE mode when payments disabled', () => { + gmm.paymentsEnabled = false; + const result = gmm.getEffectiveModeForUser({ credits: 5 }); + expect(result.mode).toBe('FREE'); + }); + + test('returns PAID_CREDITS when user has credits and preferCreditsFirst', () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.preferCreditsFirst = true; + gmm.creditsPerGameCost = 1; + + const result = gmm.getEffectiveModeForUser({ credits: 5 }); + expect(result.mode).toBe('PAID_CREDITS'); + expect(result.hasCredits).toBe(true); + expect(result.bothModesEnabled).toBe(true); + }); + + test('returns PAID_SINGLE when user has no credits in mixed mode', () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.preferCreditsFirst = true; + gmm.creditsPerGameCost = 1; + + const result = gmm.getEffectiveModeForUser({ credits: 0 }); + expect(result.mode).toBe('PAID_SINGLE'); + expect(result.hasCredits).toBe(false); + }); + + test('returns correct mode when only direct is enabled', () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = false; + + const result = gmm.getEffectiveModeForUser({ credits: 10 }); + expect(result.mode).toBe('PAID_SINGLE'); + expect(result.bothModesEnabled).toBe(false); + }); + + test('returns correct mode when only credits is enabled', () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = false; + gmm.creditsModeEnabled = true; + + const result = gmm.getEffectiveModeForUser({ credits: 5 }); + expect(result.mode).toBe('PAID_CREDITS'); + }); + }); + + describe('calculatePayout', () => { + test('calculates direct mode payout correctly', () => { + gmm.singleGamePrice = 100000000000; // 1 WOW + gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + + const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: false }); + expect(result.amount).toBe(200000000000); // 2x + expect(result.multiplier).toBe(2); + }); + + test('calculates treasure bonus correctly', () => { + gmm.singleGamePrice = 100000000000; + gmm.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 }; + + const result = gmm.calculatePayout('PAID_SINGLE', { treasureFound: true }); + expect(result.amount).toBe(300000000000); // 3x + expect(result.multiplier).toBe(3); + }); + + test('returns zero payout for credits mode when disabled', () => { + gmm.creditsPayoutBaseValue = 100000000000; + gmm.creditPayoutMultipliers = { escape: 0, escapeWithTreasure: 0 }; + + const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false }); + expect(result.amount).toBe(0); + }); + + test('calculates credits mode payout when enabled', () => { + gmm.creditsPayoutBaseValue = 50000000000; // 0.5 WOW base + gmm.creditPayoutMultipliers = { escape: 1.5, escapeWithTreasure: 2 }; + + const result = gmm.calculatePayout('PAID_CREDITS', { treasureFound: false }); + expect(result.amount).toBe(75000000000); // 0.5 * 1.5 + }); + }); + + describe('getPaymentOptionsForUser', () => { + beforeEach(() => { + mockDb.query.mockResolvedValue({ rows: [{ id: 1, credits: 5, payout_address: 'wow1addr' }] }); + }); + + test('returns use_credit option when user has credits', async () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.creditsPerGameCost = 1; + + const result = await gmm.getPaymentOptionsForUser('socket1'); + const useCreditOption = result.options.find(o => o.type === 'use_credit'); + expect(useCreditOption).toBeDefined(); + expect(useCreditOption.mode).toBe('PAID_CREDITS'); + }); + + test('returns pay_direct option', async () => { + gmm.directModeEnabled = true; + + const result = await gmm.getPaymentOptionsForUser('socket1'); + const directOption = result.options.find(o => o.type === 'pay_direct'); + expect(directOption).toBeDefined(); + expect(directOption.mode).toBe('PAID_SINGLE'); + }); + + test('returns buy_credits option when credits mode enabled', async () => { + gmm.creditsModeEnabled = true; + + const result = await gmm.getPaymentOptionsForUser('socket1'); + const buyOption = result.options.find(o => o.type === 'buy_credits'); + expect(buyOption).toBeDefined(); + }); + }); + + describe('processCreditsPackageConfirmation', () => { + test('adds correct credits from package info', async () => { + const user = { id: 1, credits: 0 }; + mockDb.query + .mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser - first lookup + .mockResolvedValueOnce({ rows: [] }) // getOrCreateUser - update last_active + .mockResolvedValueOnce({ rows: [{ credits: 15 }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm.processCreditsPackageConfirmation('socket1', 123, { credits: 10, bonus: 5 }); + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(15); // 10 + 5 bonus + }); + + test('falls back to default credits when package info missing and no description', async () => { + const user = { id: 1, credits: 0 }; + mockDb.query + .mockResolvedValueOnce({ rows: [user] }) // getOrCreateUser - first lookup + .mockResolvedValueOnce({ rows: [] }) // getOrCreateUser - update last_active + .mockResolvedValueOnce({ rows: [] }) // SELECT payment - no description + .mockResolvedValueOnce({ rows: [{ credits: 10 }] }) // UPDATE users + .mockResolvedValueOnce({ rows: [] }) // UPDATE payments + .mockResolvedValueOnce({ rows: [] }); // INSERT credit_transactions + + const result = await gmm.processCreditsPackageConfirmation('socket1', 123, null); + expect(result.success).toBe(true); + expect(result.creditsAdded).toBe(10); // Default fallback + }); + }); + + describe('canUserStartGame - mixed mode', () => { + test('allows game with credits in mixed mode', async () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.creditsPerGameCost = 1; + + mockDb.query + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 5 }] }) // getOrCreateUser lookup + .mockResolvedValueOnce({ rows: [] }); // update last_active + + const result = await gmm.canUserStartGame('socket1'); + expect(result.allowed).toBe(true); + expect(result.useCredits).toBe(true); + expect(result.effectiveMode).toBe('PAID_CREDITS'); + }); + + test('allows game with confirmed payment when user has no credits', async () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.creditsPerGameCost = 1; + + mockDb.query + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser lookup + .mockResolvedValueOnce({ rows: [] }) // update last_active + .mockResolvedValueOnce({ rows: [{ id: 999 }] }); // confirmed payment query + + const result = await gmm.canUserStartGame('socket1'); + expect(result.allowed).toBe(true); + expect(result.paymentId).toBe(999); + expect(result.effectiveMode).toBe('PAID_SINGLE'); + }); + + test('denies game when no credits and no payment in mixed mode', async () => { + gmm.paymentsEnabled = true; + gmm.directModeEnabled = true; + gmm.creditsModeEnabled = true; + gmm.creditsPerGameCost = 1; + + mockDb.query + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser lookup + .mockResolvedValueOnce({ rows: [] }) // update last_active + .mockResolvedValueOnce({ rows: [] }) // no confirmed payment + .mockResolvedValueOnce({ rows: [{ id: 1, credits: 0 }] }) // getOrCreateUser for getPaymentOptions + .mockResolvedValueOnce({ rows: [] }); // update last_active + + const result = await gmm.canUserStartGame('socket1'); + expect(result.allowed).toBe(false); + expect(result.action).toBe('choose_payment'); + }); + }); +});