fix multimode payments and get credits working again

This commit is contained in:
jwinterm
2025-12-21 14:08:20 -05:00
parent be2ba473ff
commit aa419689c8
11 changed files with 989 additions and 115 deletions
+21
View File
@@ -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.
+104
View File
@@ -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
+26
View File
@@ -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`:
+7 -2
View File
@@ -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).
+19 -2
View File
@@ -12,13 +12,28 @@
<script>
(function(){
let opts = {};
window._localStorageAvailable = false;
try {
// Test localStorage availability (fails in private browsing on some browsers)
localStorage.setItem('_test', '1');
localStorage.removeItem('_test');
window._localStorageAvailable = true;
const existing = localStorage.getItem('wownerogue_token');
if (existing) {
opts.query = { resumeToken: existing };
}
} catch(_) {}
} catch(_) {
window._localStorageAvailable = false;
console.warn('localStorage unavailable - session will not persist');
}
window.socket = io(opts);
// Show warning if localStorage unavailable (after DOM ready)
document.addEventListener('DOMContentLoaded', function() {
if (!window._localStorageAvailable) {
const warn = document.getElementById('sessionWarning');
if (warn) warn.style.display = 'inline';
}
});
})();
</script>
@@ -70,7 +85,8 @@
<label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
<input type="checkbox" id="audioAlertsToggle" style="cursor:pointer;"> Audio Alerts
</label>
<button id="manageAddressButton" style="margin-left:auto; background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;">⚙️ Manage Payout Address</button>
<button id="manageAddressButton" style="margin-left:auto; background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
<span id="sessionWarning" style="display:none; color:#f59e0b; font-size:10px; margin-left:6px;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
</div>
</div>
@@ -149,6 +165,7 @@
<button type="button" class="modal-close" id="addressModalClose" aria-label="Close">✕</button>
<h2 id="addressModalTitle">Manage Payout Address</h2>
<p class="modal-description">Enter your Wownero (WOW) or Monero (XMR) address below. This will be used for payouts when you escape.</p>
<p class="modal-description" style="font-size:11px; color:#888; margin-top:8px; padding:6px; background:rgba(255,200,0,0.1); border-radius:4px;">💡 <strong>Session Info:</strong> Your address and credits are tied to a browser session token stored locally. They will persist across page refreshes but will be lost if you clear cookies, use incognito mode, or switch browsers.</p>
<form id="addressModalForm">
<label for="addressModalInput" class="modal-label">Payout Address</label>
<textarea id="addressModalInput" autocomplete="off" spellcheck="false" rows="3" placeholder="Paste your payout address here"></textarea>
+108 -36
View File
@@ -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(`<strong>💰 PAID SINGLE</strong> - ${price} ${currency} per game<br><span style="font-size:0.8em;color:#aaa;">Payouts enabled for winners</span>`);
$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(`<strong>🎫 CREDITS PACKAGE</strong> - ${price} ${currency}<br><span style="font-size:0.8em;color:#aaa;">Bulk discount, no payouts</span>`);
$el.show();
} else {
$('.mode-option[data-mode="PAID_CREDITS"]').hide();
$container.append(`
<div class="credits-balance" style="text-align:center;margin-bottom:10px;padding:8px;background:#1a1a2e;border-radius:4px;">
<span style="color:#888;">Your Credits:</span>
<strong id="user-credits-display" style="color:#4ade80;font-size:1.2em;">${this.userCredits}</strong>
</div>
`);
}
// 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
? '<span style="color:#4ade80;">💰 Payouts enabled</span>'
: '<span style="color:#f59e0b;">⚡ Fast play • No payouts</span>';
$container.append(`
<button class="mode-option recommended" data-mode="PAID_CREDITS" data-action="use_credit" style="border:2px solid #4ade80;">
<strong>🎮 Use 1 Credit</strong> (${this.userCredits} available)<br>
<span style="font-size:0.8em;">Start game immediately • ${payoutNote}</span>
</button>
`);
}
// 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
? '<span style="color:#4ade80;">💰 2x payout on escape, 3x with treasure</span>'
: '<span style="color:#f59e0b;">⚡ Play mode • No payouts</span>';
$container.append(`
<button class="mode-option ${recommended}" data-mode="PAID_SINGLE" data-action="pay_direct">
<strong>💰 Pay Per Game</strong> - ${price} ${currency}<br>
<span style="font-size:0.8em;">${payoutInfo}</span>
</button>
`);
}
// 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(`
<button class="mode-option" data-mode="PAID_CREDITS" data-action="buy_credits">
<strong>🎫 Buy ${pkgCredits} Credits</strong> - ${price} ${currency}<br>
<span style="font-size:0.8em;color:#aaa;">Bulk discount • Credits never expire</span>
</button>
`);
}
// 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(`
<button class="mode-option" data-mode="FREE" data-action="free">
<strong>🆓 Free Play</strong><br>
<span style="font-size:0.8em;color:#aaa;">No payments required</span>
</button>
`);
}
},
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('<span style="color:#ff0">♻️ Using existing pending payment</span>');
} else {
$('#payment-status').text('Waiting for payment...');
const statusText = isCreditsPurchase
? 'Send payment to receive credits...'
: 'Waiting for payment...';
$('#payment-status').text(statusText);
}
},
+354 -48
View File
@@ -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)
*/
+23
View File
@@ -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);
+44 -10
View File
@@ -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}`);
}
}
}
}
+23 -17
View File
@@ -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`);
+260
View File
@@ -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');
});
});
});