mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
fix multimode payments and get credits working again
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user