UnifyPayments (megachange)

This commit is contained in:
jwinterm
2025-09-29 08:49:27 -04:00
parent 9a4911f410
commit c7cbdff021
10 changed files with 1950 additions and 338 deletions
-221
View File
@@ -1,221 +0,0 @@
# Socket Handlers Refactoring Summary
## 📊 Results Overview
### Line Count Reduction
- **Before**: 784 lines (socketHandlers.js)
- **After**: 638 lines (socketHandlers.js) + 1,483 lines (6 new modules)
- **Reduction**: 146 lines removed from main file (18.6% reduction)
- **Modularity**: Split into 7 focused files instead of 1 monolithic file
### ✅ Completed Objectives
#### 1. ✅ Rate Limiting Implementation (4 hours estimated)
- **New Module**: `src/network/rateLimiter.js` (249 lines)
- **Features**:
- Configurable rate limits per action type
- Memory-efficient with automatic cleanup
- IP-based and user-based rate limiting
- Debug mode with detailed logging
- Comprehensive usage statistics
#### 2. ✅ Memory Leak Fixes (3 hours estimated)
- **New Module**: `src/utils/memoryManager.js` (283 lines)
- **Fixes Applied**:
- Automatic cleanup of player move timestamps
- Cleanup of stale socket mappings
- Cleanup of chat timestamps
- Cleanup of rate limiter storage
- Cleanup of mempool notifications
- Periodic garbage collection of expired data
#### 3. ✅ Code Separation & Modularity
- **6 New Specialized Modules**:
1. `RateLimiter` - Rate limiting functionality
2. `ConnectionHandler` - Socket connection management
3. `ChatHandler` - Chat messages and commands
4. `QueueHandler` - Game queue management
5. `GameManager` - Game lifecycle and creation
6. `MemoryManager` - Memory cleanup coordination
## 🏗️ New Architecture
### Before (Monolithic)
```
socketHandlers.js (784 lines)
├── Connection handling
├── Chat handling
├── Queue management
├── Game creation
├── Game over handling
├── Rate limiting (basic)
├── Memory management (manual)
└── Various utility functions
```
### After (Modular)
```
socketHandlers.js (638 lines) - Main coordinator
├── rateLimiter.js (249 lines) - Rate limiting
├── connectionHandler.js (292 lines) - Connection management
├── chatHandler.js (221 lines) - Chat & commands
├── queueHandler.js (189 lines) - Queue operations
├── gameManager.js (249 lines) - Game lifecycle
└── memoryManager.js (283 lines) - Memory cleanup
```
## 🚀 New Features Added
### Rate Limiting
```javascript
// Configurable per-action limits
'payment:create': { window: 60000, max: 3 }, // 3 payments per minute
'game:start': { window: 60000, max: 15 }, // 15 game starts per minute
'game:queue': { window: 30000, max: 5 }, // 5 queue attempts per 30s
'chat:message': { window: 10000, max: 12 }, // 12 messages per 10s
'address:set': { window: 300000, max: 3 }, // 3 address changes per 5min
'connection:new': { window: 60000, max: 10 } // 10 connections per minute per IP
```
### Memory Management
```javascript
// Automatic cleanup registration
memoryManager.registerCleanup('playerMoveTimestamps', cleanupFn, 300000);
memoryManager.registerCleanup('chatTimestamps', cleanupFn, 300000);
memoryManager.registerCleanup('rateLimiterData', cleanupFn, 60000);
```
### Enhanced Error Handling
- Graceful degradation when services fail
- Comprehensive error logging
- User-friendly error messages
- Automatic retry suggestions
## 🔧 Implementation Details
### Rate Limiting Implementation
- **Storage**: In-memory Maps with cleanup (Redis-ready architecture)
- **Granularity**: Per-user and per-IP limits
- **Cleanup**: Automatic expiration of old entries
- **Flexibility**: Different limits per action type
- **Monitoring**: Detailed statistics and debugging
### Memory Leak Prevention
- **Timestamp cleanup**: Remove old movement and chat timestamps
- **Socket mapping cleanup**: Remove stale socket-to-client mappings
- **Rate limiter cleanup**: Automatic cleanup of expired limit data
- **Component cleanup**: Each component handles its own cleanup
- **Coordinated cleanup**: Central MemoryManager coordinates all cleanup
### Modular Architecture Benefits
- **Single Responsibility**: Each module has one clear purpose
- **Easy Testing**: Modules can be unit tested independently
- **Maintainability**: Changes are localized to relevant modules
- **Reusability**: Modules can be reused in other parts of the system
- **Memory Efficiency**: Better garbage collection due to clearer boundaries
## 📈 Performance Improvements
### Memory Usage
- **Before**: Unbounded growth of various Maps and Sets
- **After**: Automatic cleanup with configurable retention periods
- **Impact**: Prevents memory leaks in long-running processes
### Rate Limiting Performance
- **Efficient**: O(1) rate limit checks
- **Memory-bounded**: Automatic cleanup prevents unlimited growth
- **Scalable**: Can easily switch to Redis for distributed rate limiting
### Connection Handling
- **Cleaner disconnects**: Proper cleanup of all user data
- **Better error recovery**: Graceful handling of connection issues
- **Resource management**: Automatic cleanup of stale resources
## 🛡️ Security Enhancements
### Rate Limiting Security
- **DDoS Protection**: Connection rate limiting by IP
- **Spam Prevention**: Message rate limiting
- **Resource Protection**: Game start and queue rate limiting
- **Payment Protection**: Payment request rate limiting
### Input Validation
- **Enhanced chat filtering**: Better sanitization of chat messages
- **Address validation**: Rate-limited address changes
- **Command validation**: Rate-limited command execution
## 🔍 Monitoring & Debugging
### Statistics Available
```javascript
const stats = socketHandlers.getStats();
// Returns:
// {
// activeGames: 5,
// rateLimiter: { totalKeys: 12, userKeys: 8, ipKeys: 4 },
// memoryManager: { totalCleanups: 145, itemsCleaned: 1205 },
// connections: { clientSocketMappings: 23 },
// chat: { chatTimestamps: 15, awaitingAddress: 2 },
// games: { totalActive: 5, byType: {...}, byState: {...} },
// queue: { length: 3 }
// }
```
### Debug Features
- **Component-level logging**: Each module can log independently
- **Memory cleanup tracking**: Monitor cleanup effectiveness
- **Rate limit monitoring**: Track limit hits and usage patterns
- **Connection tracking**: Monitor connection patterns and issues
## 🚀 Production Readiness
### Scalability
- **Horizontal scaling ready**: Modules can easily use Redis for distributed state
- **Memory bounded**: No more memory leaks
- **Performance optimized**: Efficient algorithms throughout
### Monitoring
- **Comprehensive stats**: Every component provides statistics
- **Health checks**: Easy to implement health endpoints
- **Debugging tools**: Rich debugging information available
### Maintenance
- **Modular updates**: Update individual components without affecting others
- **Clear separation**: Easy to understand and modify individual features
- **Testing**: Each module can be unit tested independently
## 📋 Next Steps
### Immediate (Optional)
1. **Add Redis support** to RateLimiter for distributed deployments
2. **Add health check endpoints** using the stats from each module
3. **Add Prometheus metrics** using the existing stats structure
4. **Add unit tests** for each new module
### Future Enhancements
1. **WebSocket authentication tokens** in ConnectionHandler
2. **Circuit breakers** for external service calls
3. **Request tracing** with correlation IDs
4. **Advanced monitoring** dashboards
## ✅ Verification
All modules pass syntax checks:
- ✅ `src/network/socketHandlers.js` (main coordinator)
- ✅ `src/network/rateLimiter.js`
- ✅ `src/network/connectionHandler.js`
- ✅ `src/network/chatHandler.js`
- ✅ `src/network/queueHandler.js`
- ✅ `src/game/gameManager.js`
- ✅ `src/utils/memoryManager.js`
## 🎯 Success Metrics
- **Memory leaks**: ✅ Fixed with automatic cleanup
- **Rate limiting**: ✅ Comprehensive implementation
- **Code organization**: ✅ 784 lines → 638 lines + 6 focused modules
- **Maintainability**: ✅ Clear separation of concerns
- **Production readiness**: ✅ Much improved with monitoring and cleanup
- **No breaking changes**: ✅ All existing functionality preserved
The codebase is now much more maintainable, memory-efficient, and production-ready while maintaining all existing functionality.
+812
View File
@@ -0,0 +1,812 @@
I'll first check if we've completed #4 (database indexes), then provide a comprehensive plan for the unified payment mode.
## 📊 Checking Database Indexes Status
Let me check what indexes have been applied:
```bash
# Check the migration file we created
cat src/migrations/004_security_improvements.sql | grep -E "CREATE INDEX|ALTER TABLE.*CONSTRAINT"
```
Looking at the migration file, we have:
- ✅ Created indexes for: games(user_id), games(status), payments(status), payments(created_at), users(last_seen)
- ✅ Added foreign key constraints for referential integrity
- ✅ Added audit_log and credit_transactions tables
- ⚠️ The migration had issues with CONCURRENTLY keyword (fixed by removing it)
**Status: #4 is PARTIALLY complete** - The indexes exist in the migration but we need to verify they were actually applied to the database.
## 🎯 Unified Payment Mode - Comprehensive Design Plan
After deep review of the codebase, here's my detailed plan for merging PAID_SINGLE and PAID_CREDITS into a unified, flexible payment system:
### 📋 Current State Analysis
**PAID_SINGLE Mode:**
- User pays per game
- Payment required for each game
- Payouts: 2x for escape, 3x for escape with treasure
- No credit system
- Simple flow: Pay → Play → Win/Lose → Payout
**PAID_CREDITS Mode:**
- User buys credit packages (10 credits for X WOW)
- Each game costs 1 credit
- Currently: Optional payouts (CREDITS_PAYOUT_ENABLED flag)
- Complex flow: Buy Credits → Play Multiple Games → Optional Payouts
**Code Locations:**
- Mode logic: gameModeManager.js
- Payment handling: paymentHandlers.js
- Queue management: queueManager.js
- Configuration: `.env` file with GAME_MODE, CREDITS_PAYOUT_ENABLED
### 🏗️ Unified Payment Architecture
```javascript
// New Unified Payment Configuration Structure
const UNIFIED_PAYMENT_CONFIG = {
// Global payment system toggle
paymentsEnabled: true,
// Currency configuration
currency: {
symbol: 'WOW',
decimals: 11,
minPayment: BigInt('1e10'), // 0.01 WOW minimum
maxPayment: BigInt('1e14') // 1000 WOW maximum
},
// Payment modes (both can be enabled simultaneously)
modes: {
// Direct pay-per-game mode
direct: {
enabled: true,
price: BigInt('1e11'), // 0.1 WOW per game
requiresAddress: true,
allowGuestPlay: false
},
// Credit package mode
credits: {
enabled: true,
creditsPerGame: 1,
requiresAddress: true,
packages: [
{ id: 'small', credits: 10, price: BigInt('5e11'), bonus: 0 },
{ id: 'medium', credits: 25, price: BigInt('10e11'), bonus: 2 },
{ id: 'large', credits: 100, price: BigInt('35e11'), bonus: 15 }
],
// Allow mixing - can buy credits even in direct mode
allowMixedMode: true
}
},
// Payout configuration (applies to both modes)
payouts: {
enabled: true,
requiresKYC: false, // Future: regulatory compliance
// Payout rules by mode
rules: {
direct: {
enabled: true,
multipliers: {
escape: 2.0,
escapeWithTreasure: 3.0,
perfectRun: 5.0 // Future: no damage, fast time
},
minPayout: BigInt('1e10'), // 0.01 WOW minimum
maxPayout: BigInt('1e13') // 10 WOW maximum per game
},
credits: {
enabled: true,
// Different multipliers for credit mode (operator choice)
multipliers: {
escape: 1.5,
escapeWithTreasure: 2.0,
perfectRun: 3.0
},
// Credits mode uses base price for calculations
baseValue: BigInt('5e10'), // 0.05 WOW per credit
minPayout: BigInt('1e10'),
maxPayout: BigInt('5e12') // 5 WOW maximum per game
}
},
// Payout processing
processing: {
automatic: true,
batchingEnabled: true,
batchInterval: 300, // 5 minutes
confirmations: 1,
maxRetries: 3
}
},
// Promotional features
promotions: {
enabled: true,
freeCredits: {
onSignup: 0,
onFirstPurchase: 2,
referralBonus: 5
},
discounts: {
enabled: false,
bulkDiscount: 0.1, // 10% off large packages
happyHour: null // Future: time-based discounts
}
},
// Anti-abuse measures
limits: {
maxGamesPerHour: 60,
maxPayoutsPerDay: 100,
maxCreditPurchasePerDay: BigInt('1e14'), // 1000 WOW
cooldownBetweenGames: 5 // seconds
}
};
```
### 📁 File Structure Refactoring
```
src/
├── payments/
│ ├── UnifiedPaymentManager.js # NEW: Main payment orchestrator
│ ├── PaymentModeManager.js # NEW: Handles mode logic
│ ├── CreditManager.js # NEW: Credit operations
│ ├── PayoutManager.js # NEW: Payout processing
│ ├── PaymentValidator.js # NEW: Input validation
│ ├── moneroPayService.js # EXISTING: Keep
│ ├── qrService.js # EXISTING: Keep
│ └── walletRPCService.js # EXISTING: Keep
├── game/
│ ├── gameModeManager.js # REFACTOR: Simplify to game logic only
│ └── ...
└── config/
├── paymentConfig.js # NEW: Centralized config
└── environmentValidator.js # NEW: Validate .env settings
```
### 🔄 Migration Strategy
#### Phase 1: Database Schema Updates (Day 1)
```sql
-- New migrations/005_unified_payment_system.sql
-- Add payment mode tracking
ALTER TABLE payments ADD COLUMN payment_mode VARCHAR(20) DEFAULT 'direct';
ALTER TABLE payments ADD COLUMN credit_package_id VARCHAR(20);
ALTER TABLE payments ADD COLUMN credits_purchased INTEGER DEFAULT 0;
-- Add user preferences
ALTER TABLE users ADD COLUMN preferred_payment_mode VARCHAR(20) DEFAULT 'direct';
ALTER TABLE users ADD COLUMN total_credits_purchased INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN total_games_played INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN total_payouts_received BIGINT DEFAULT 0;
-- Add payout tracking improvements
CREATE TABLE IF NOT EXISTS payouts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
game_id INTEGER REFERENCES games(id),
amount BIGINT NOT NULL,
multiplier DECIMAL(3,1) NOT NULL,
reason VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
tx_hash VARCHAR(64),
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
error_message TEXT
);
-- Add credit transaction types
ALTER TABLE credit_transactions ADD COLUMN transaction_type VARCHAR(20);
-- Types: 'purchase', 'game_fee', 'payout', 'bonus', 'refund', 'adjustment'
-- Indexes for performance
CREATE INDEX idx_payouts_status ON payouts(status);
CREATE INDEX idx_payouts_user_id ON payouts(user_id);
CREATE INDEX idx_payments_payment_mode ON payments(payment_mode);
```
#### Phase 2: Configuration System (Day 1-2)
```javascript
// src/config/paymentConfig.js
class PaymentConfigManager {
constructor() {
this.config = this.loadConfig();
this.validateConfig();
}
loadConfig() {
// Priority: Environment > Database > Defaults
const config = { ...DEFAULT_CONFIG };
// Load from environment
if (process.env.PAYMENT_MODES) {
const modes = process.env.PAYMENT_MODES.split(',');
config.modes.direct.enabled = modes.includes('direct');
config.modes.credits.enabled = modes.includes('credits');
}
// Payouts configuration
config.payouts.enabled = process.env.PAYOUTS_ENABLED === 'true';
config.payouts.rules.direct.enabled =
process.env.DIRECT_PAYOUTS_ENABLED !== 'false';
config.payouts.rules.credits.enabled =
process.env.CREDIT_PAYOUTS_ENABLED === 'true';
// Price configuration
if (process.env.GAME_PRICE) {
config.modes.direct.price = BigInt(process.env.GAME_PRICE);
}
return config;
}
validateConfig() {
// Ensure at least one payment mode is enabled
if (!this.config.modes.direct.enabled &&
!this.config.modes.credits.enabled) {
throw new Error('At least one payment mode must be enabled');
}
// Validate payout configuration
if (this.config.payouts.enabled) {
if (!this.config.modes.direct.enabled &&
!this.config.payouts.rules.credits.enabled) {
console.warn('Payouts enabled but no payout rules active');
}
}
// Validate prices
this.validatePrices();
return true;
}
validatePrices() {
const { direct, credits } = this.config.modes;
if (direct.enabled && direct.price <= 0n) {
throw new Error('Direct game price must be positive');
}
if (credits.enabled) {
for (const pkg of credits.packages) {
if (pkg.price <= 0n) {
throw new Error(`Credit package ${pkg.id} has invalid price`);
}
if (pkg.credits <= 0) {
throw new Error(`Credit package ${pkg.id} has invalid credit count`);
}
}
}
}
// Dynamic configuration updates
async updateConfig(updates) {
// Validate updates
const newConfig = { ...this.config, ...updates };
// Test configuration
const testManager = new PaymentConfigManager();
testManager.config = newConfig;
testManager.validateConfig();
// Apply updates
this.config = newConfig;
// Persist to database for next restart
await this.persistConfig();
// Notify all connected clients
this.broadcastConfigUpdate();
}
}
```
#### Phase 3: Unified Payment Manager (Day 2-3)
```javascript
// src/payments/UnifiedPaymentManager.js
class UnifiedPaymentManager {
constructor(config, walletService, db) {
this.config = config;
this.wallet = walletService;
this.db = db;
this.creditManager = new CreditManager(db, config);
this.payoutManager = new PayoutManager(wallet, db, config);
this.validator = new PaymentValidator(config);
this.pendingPayments = new Map();
this.activeGames = new Map();
}
// Unified game start flow
async requestGameStart(userId, socketId) {
const user = await this.db.getUser(userId);
// Check if user has credits
if (user.credits > 0 && this.config.modes.credits.enabled) {
return this.startGameWithCredits(user, socketId);
}
// Check if direct payment is enabled
if (this.config.modes.direct.enabled) {
return this.startGameWithDirectPayment(user, socketId);
}
// Offer credit purchase if only credits mode
if (this.config.modes.credits.enabled) {
return {
action: 'purchase_credits',
packages: this.config.modes.credits.packages
};
}
throw new Error('No payment method available');
}
async startGameWithCredits(user, socketId) {
// Validate credit balance
if (user.credits < this.config.modes.credits.creditsPerGame) {
return {
action: 'insufficient_credits',
required: this.config.modes.credits.creditsPerGame,
balance: user.credits,
packages: this.config.modes.credits.packages
};
}
// Deduct credits
await this.creditManager.deductCredits(
user.id,
this.config.modes.credits.creditsPerGame,
'game_start'
);
// Start game
return {
action: 'game_start',
mode: 'credits',
creditsRemaining: user.credits - this.config.modes.credits.creditsPerGame
};
}
async startGameWithDirectPayment(user, socketId) {
// Check if user needs to set payout address
if (this.config.payouts.enabled && !user.payout_address) {
return {
action: 'require_payout_address',
message: 'Please set your payout address first'
};
}
// Generate payment request
const payment = await this.createPaymentRequest(
user.id,
this.config.modes.direct.price,
'direct_game'
);
return {
action: 'payment_required',
payment: {
address: payment.address,
amount: payment.amount,
qr: payment.qr,
expires: payment.expires
}
};
}
// Handle game completion and payouts
async processGameCompletion(gameId, outcome) {
const game = await this.db.getGame(gameId);
const user = await this.db.getUser(game.user_id);
// Determine if payout is due
const payoutConfig = this.getPayoutConfig(game.payment_mode);
if (!payoutConfig.enabled) {
return { payout: false };
}
// Calculate payout
const payout = this.calculatePayout(game, outcome, payoutConfig);
if (payout.amount <= 0n) {
return { payout: false };
}
// Process payout
const result = await this.payoutManager.processPayout(
user,
game,
payout
);
return {
payout: true,
amount: payout.amount,
multiplier: payout.multiplier,
status: result.status,
txHash: result.txHash
};
}
calculatePayout(game, outcome, config) {
let multiplier = 0;
let baseAmount = 0n;
// Determine base amount
if (game.payment_mode === 'direct') {
baseAmount = this.config.modes.direct.price;
} else if (game.payment_mode === 'credits') {
baseAmount = this.config.payouts.rules.credits.baseValue;
}
// Determine multiplier
if (outcome.reason === 'escaped') {
if (outcome.treasure) {
multiplier = config.multipliers.escapeWithTreasure;
} else {
multiplier = config.multipliers.escape;
}
// Future: Perfect run bonus
if (outcome.perfectRun) {
multiplier = config.multipliers.perfectRun;
}
}
// Calculate final amount
const amount = BigInt(Math.floor(Number(baseAmount) * multiplier));
// Apply limits
const minPayout = config.minPayout;
const maxPayout = config.maxPayout;
if (amount < minPayout) return { amount: 0n, multiplier: 0 };
if (amount > maxPayout) return { amount: maxPayout, multiplier };
return { amount, multiplier };
}
}
```
#### Phase 4: Credit Management System (Day 3)
```javascript
// src/payments/CreditManager.js
class CreditManager {
constructor(db, config) {
this.db = db;
this.config = config;
}
async purchaseCredits(userId, packageId, paymentId) {
const pkg = this.config.modes.credits.packages.find(p => p.id === packageId);
if (!pkg) {
throw new Error('Invalid credit package');
}
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
// Add credits to user account
const totalCredits = pkg.credits + pkg.bonus;
await client.query(
`UPDATE users
SET credits = credits + $1,
total_credits_purchased = total_credits_purchased + $2
WHERE id = $3`,
[totalCredits, pkg.credits, userId]
);
// Log transaction
await client.query(
`INSERT INTO credit_transactions
(user_id, amount, transaction_type, reason, payment_id)
VALUES ($1, $2, $3, $4, $5)`,
[userId, totalCredits, 'purchase', `Package: ${packageId}`, paymentId]
);
// Add bonus credits if applicable
if (pkg.bonus > 0) {
await client.query(
`INSERT INTO credit_transactions
(user_id, amount, transaction_type, reason)
VALUES ($1, $2, $3, $4)`,
[userId, pkg.bonus, 'bonus', `Package ${packageId} bonus`]
);
}
await client.query('COMMIT');
return {
success: true,
creditsAdded: totalCredits,
newBalance: await this.getBalance(userId)
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async deductCredits(userId, amount, reason) {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
// Check balance
const balance = await this.getBalance(userId);
if (balance < amount) {
throw new Error('Insufficient credits');
}
// Deduct credits
await client.query(
`UPDATE users
SET credits = credits - $1,
total_games_played = total_games_played + 1
WHERE id = $2`,
[amount, userId]
);
// Log transaction
await client.query(
`INSERT INTO credit_transactions
(user_id, amount, transaction_type, reason)
VALUES ($1, $2, $3, $4)`,
[userId, -amount, 'game_fee', reason]
);
await client.query('COMMIT');
return {
success: true,
newBalance: balance - amount
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async refundCredits(userId, amount, reason) {
// Similar to purchaseCredits but for refunds
}
async getBalance(userId) {
const result = await this.db.query(
'SELECT credits FROM users WHERE id = $1',
[userId]
);
return result.rows[0]?.credits || 0;
}
async getCreditHistory(userId, limit = 50) {
const result = await this.db.query(
`SELECT * FROM credit_transactions
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit]
);
return result.rows;
}
}
```
#### Phase 5: UI/UX Updates (Day 4)
```javascript
// Frontend changes needed
// html/js/network/socketHandlers.js additions
SocketHandlers.onPaymentModeSelection = function(data) {
// Show available payment options
if (data.modes.length === 1) {
// Single mode - proceed directly
this.proceedWithPaymentMode(data.modes[0]);
} else {
// Multiple modes - show selection UI
UI.showPaymentModeSelection(data.modes);
}
};
SocketHandlers.onCreditPackageSelection = function(data) {
// Show credit packages
const packages = data.packages.map(pkg => ({
id: pkg.id,
credits: pkg.credits + (pkg.bonus || 0),
price: UI.formatCurrency(pkg.price),
bonus: pkg.bonus > 0 ? `+${pkg.bonus} bonus!` : '',
savings: this.calculateSavings(pkg)
}));
UI.showCreditPackages(packages);
};
SocketHandlers.onPayoutProcessed = function(data) {
// Show payout notification
const message = `Payout processed: ${UI.formatCurrency(data.amount)} (${data.multiplier}x)`;
UI.showNotification(message, 'success');
// Play appropriate sound
if (data.multiplier >= 3) {
AudioAlerts.playFile('big_win');
} else {
AudioAlerts.playFile('payout_received');
}
};
// UI additions
UI.showPaymentModeSelection = function(modes) {
const html = `
<div class="payment-mode-selection">
<h3>Select Payment Method</h3>
${modes.map(mode => `
<div class="payment-option" data-mode="${mode.id}">
<h4>${mode.name}</h4>
<p>${mode.description}</p>
${mode.price ? `<p class="price">${UI.formatCurrency(mode.price)}</p>` : ''}
${mode.creditsAvailable ? `<p class="credits">Credits: ${mode.creditsAvailable}</p>` : ''}
<button onclick="UI.selectPaymentMode('${mode.id}')">
${mode.buttonText}
</button>
</div>
`).join('')}
</div>
`;
this.showModal(html);
};
```
### 🔧 Environment Configuration
```bash
# New .env configuration
# Payment System Configuration
PAYMENTS_ENABLED=true
# Payment Modes (comma-separated: direct,credits)
PAYMENT_MODES=direct,credits
# Direct Payment Configuration
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=100000000000 # 0.1 WOW
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
# Credit System Configuration
CREDITS_ENABLED=true
CREDITS_PER_GAME=1
CREDITS_PAYOUTS_ENABLED=false # Operator choice
CREDITS_PAYOUT_BASE=50000000000 # 0.05 WOW per credit
CREDITS_PAYOUT_ESCAPE=1.5
CREDITS_PAYOUT_TREASURE=2.0
# Credit Packages (JSON format)
CREDITS_PACKAGES='[
{"id":"small","credits":10,"price":"500000000000","bonus":0},
{"id":"medium","credits":25,"price":"1000000000000","bonus":2},
{"id":"large","credits":100,"price":"3500000000000","bonus":15}
]'
# Mixed Mode Settings
ALLOW_MIXED_MODE=true # Can use credits even in direct mode
PREFER_CREDITS_FIRST=true # Use credits before requesting payment
# Payout Processing
PAYOUT_BATCH_ENABLED=true
PAYOUT_BATCH_INTERVAL=300 # 5 minutes
PAYOUT_MIN_AMOUNT=10000000000 # 0.01 WOW minimum
PAYOUT_MAX_PER_GAME=10000000000000 # 10 WOW maximum
# Anti-Abuse Limits
MAX_GAMES_PER_HOUR=60
MAX_PAYOUTS_PER_DAY=100
GAME_COOLDOWN_SECONDS=5
```
### 📈 Migration Timeline
**Week 1:**
- Day 1: Database schema updates, configuration system
- Day 2: UnifiedPaymentManager core implementation
- Day 3: CreditManager and PayoutManager
- Day 4: Frontend UI updates
- Day 5: Integration testing
**Week 2:**
- Day 1-2: Migrate existing data
- Day 3: Staging deployment and testing
- Day 4: Performance optimization
- Day 5: Production deployment
### ✅ Testing Strategy
```javascript
// Test scenarios to cover
describe('Unified Payment System', () => {
test('Direct payment only mode', async () => {
const config = { modes: { direct: { enabled: true }, credits: { enabled: false }}};
// Test direct payment flow
});
test('Credits only mode', async () => {
const config = { modes: { direct: { enabled: false }, credits: { enabled: true }}};
// Test credit purchase and usage
});
test('Mixed mode - prefer credits', async () => {
const config = {
modes: { direct: { enabled: true }, credits: { enabled: true }},
preferCreditsFirst: true
};
// Test credits used before direct payment
});
test('Payout calculations - direct mode', async () => {
// Test 2x and 3x multipliers
});
test('Payout calculations - credits mode', async () => {
// Test different multipliers for credits
});
test('Anti-abuse limits', async () => {
// Test rate limiting and max limits
});
});
```
### 🎯 Benefits of This Approach
1. **Maximum Flexibility**: Operators can enable/disable any combination
2. **Backward Compatible**: Existing configurations continue to work
3. **Future Proof**: Easy to add new payment methods or payout rules
4. **Granular Control**: Different payout rates for different modes
5. **Player Choice**: Users can choose their preferred payment method
6. **Revenue Optimization**: Credit packages encourage bulk purchases
7. **Risk Management**: Configurable limits and anti-abuse measures
### 🚀 Implementation Priority
1. **High Priority (Days 1-3)**:
- UnifiedPaymentManager
- Configuration system
- Database migrations
- Basic mode switching
2. **Medium Priority (Days 4-5)**:
- Credit management
- Payout processing
- UI updates
- Testing
3. **Low Priority (Future)**:
- Advanced analytics
- A/B testing framework
- Dynamic pricing
- Loyalty rewards
This plan provides a robust, flexible payment system that can adapt to different business models while maintaining code simplicity and maintainability. The operator gains complete control over payment modes, pricing, and payout rules through simple configuration changes.
+59 -27
View File
@@ -1,25 +1,66 @@
# Wowgue Game Configuration
# Game Mode Selection (required)
# PAID_SINGLE: Users pay per game, get 2x for escape, 3x with treasure
# PAID_CREDITS: Users buy 10 credits, play 10 times, no payouts
# FREE: Free play mode, no payments required
GAME_MODE=PAID_SINGLE
# -----------------------------------------------------------------------------
# Unified Payment System (recommended configuration)
# -----------------------------------------------------------------------------
PAYMENTS_ENABLED=true
# Cryptocurrency Configuration
# Enable one or both modes (comma separated list: direct,credits)
PAYMENT_MODES=direct,credits
# Direct mode settings
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units
DIRECT_REQUIRES_ADDRESS=true
DIRECT_ALLOW_GUEST_PLAY=false
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
# Credits mode settings
CREDITS_ENABLED=true
CREDITS_PER_GAME=1
CREDITS_REQUIRES_ADDRESS=true
ALLOW_MIXED_MODE=true
PREFER_CREDITS_FIRST=true
CREDITS_PAYOUTS_ENABLED=false
CREDITS_PAYOUT_BASE=50000000000 # 0.5 WOW per credit payout baseline
CREDITS_PAYOUT_ESCAPE=1.5
CREDITS_PAYOUT_TREASURE=2.0
CREDITS_PACKAGES='[
{"id":"small","credits":10,"price":"500000000000","bonus":0},
{"id":"medium","credits":25,"price":"1000000000000","bonus":2},
{"id":"large","credits":100,"price":"3500000000000","bonus":15}
]'
# Global payouts / limits
PAYOUTS_ENABLED=true
PAYOUT_MIN_AMOUNT=10000000000 # 0.1 WOW minimum payout
PAYOUT_MAX_PER_GAME=10000000000000
PAYOUT_BATCH_INTERVAL=300 # seconds (5 minutes)
PAYOUT_MAX_RETRIES=3
MAX_PAYOUT_BATCH_SIZE=50
# Anti-abuse limits
MAX_GAMES_PER_HOUR=60
MAX_PAYOUTS_PER_DAY=100
MAX_CREDIT_PURCHASE_PER_DAY=100000000000000
GAME_COOLDOWN_SECONDS=5
# -----------------------------------------------------------------------------
# Legacy overrides (optional). These mirror the old configuration style and
# are primarily kept for backwards compatibility or quick testing.
# -----------------------------------------------------------------------------
# GAME_MODE accepts FREE, PAID_SINGLE, PAID_CREDITS. When set it overrides
# PAYMENT_MODES. Leave unset to rely on unified config above.
GAME_MODE=
# Legacy per-mode prices (still read as fallbacks if PAYMENT_MODES unset)
CRYPTO_TYPE=WOW
# Payment amounts in atomic units (piconeros for XMR, tacoshis for WOW)
# XMR: 0.005 XMR = 5000000000 piconeros
# WOW: 1 WOW = 100000000000 tacoshis
# You can use plain integers, scientific notation (e.g. 5e9), or underscores (e.g. 5_000_000_000)
# The parser will coerce to an integer atomic unit amount.
SINGLE_GAME_PRICE=1e11 # 1 WOW
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (was 5000000000)
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (was 50000000000)
# Alternative readable style:
# SINGLE_GAME_PRICE=5_000_000_000
# CREDITS_PACKAGE_PRICE=50_000_000_000
SINGLE_GAME_PRICE=1e11 # 1 WOW (atomic units)
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW (atomic units)
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (5000000000)
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (50000000000)
# Wallet RPC Configuration
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
@@ -55,12 +96,3 @@ NODE_ENV=production
BLOCK_SOURCE=daemon
# SIMULATED_BLOCKS=false
# FORCE_SIMULATED_BLOCKS=false
# Payout Configuration
PAYOUT_BATCH_INTERVAL=300000
MAX_PAYOUT_BATCH_SIZE=50
# Credits Mode Optional Payouts
# Set to true to enable payouts (same multipliers as single mode) when using PAID_CREDITS
# Default is false meaning credits mode is purely consumptive / no rewards.
CREDITS_PAYOUT_ENABLED=false
+55
View File
@@ -0,0 +1,55 @@
/**
* Environment Validator
* Emits friendly warnings when payment related environment variables look suspicious.
*/
class EnvironmentValidator {
constructor(options = {}) {
this.logger = options.logger || console;
}
validate(config) {
const warnings = [];
const errors = [];
if (process.env.GAME_MODE && process.env.PAYMENT_MODES) {
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
}
if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('paymentsEnabled is true but PRIMARY_WALLET_ENDPOINT is not set. Wallet RPC may fail.');
}
if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) {
warnings.push('paymentsEnabled is true but PRIMARY_RPC_ENDPOINT is missing. Blockchain RPC is required.');
}
if (config.modes.direct.enabled && config.modes.direct.price <= 0n) {
errors.push('Direct payment mode is enabled but direct price is not positive.');
}
if (config.modes.credits.enabled && !config.modes.credits.packages.length) {
errors.push('Credits mode is enabled but no credit packages are defined.');
}
if (config.modes.credits.enabled && !process.env.CREDITS_PACKAGES) {
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Consider defining CREDITS_PACKAGES for production.');
}
if (!config.paymentsEnabled) {
this.logger.info?.('ℹ️ Payments are currently disabled. Server will run in FREE mode.');
}
if (warnings.length) {
warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`));
}
if (errors.length) {
errors.forEach(msg => this.logger.error?.(`❌ ${msg}`));
}
return { warnings, errors };
}
}
module.exports = EnvironmentValidator;
+429
View File
@@ -0,0 +1,429 @@
/**
* Payment Configuration Manager
* Loads unified payment settings from defaults, environment variables, and (future) database overrides.
*/
const { EventEmitter } = require('events');
const DEFAULT_CONFIG = Object.freeze({
paymentsEnabled: true,
currency: {
symbol: 'WOW',
decimals: 11,
minPayment: 1000000000n, // 0.01 WOW in atomic units
maxPayment: 100000000000000n // 1,000 WOW in atomic units
},
modes: {
direct: {
enabled: true,
price: 100000000000n, // 1 WOW per game
requiresAddress: true,
allowGuestPlay: false
},
credits: {
enabled: false,
creditsPerGame: 1,
requiresAddress: true,
allowMixedMode: true,
packages: [
{ id: 'small', credits: 10, price: 500000000000n, bonus: 0 },
{ id: 'medium', credits: 25, price: 1000000000000n, bonus: 2 },
{ id: 'large', credits: 100, price: 3500000000000n, bonus: 15 }
]
}
},
payouts: {
enabled: true,
requiresKYC: false,
rules: {
direct: {
enabled: true,
multipliers: {
escape: 2.0,
escapeWithTreasure: 3.0,
perfectRun: 5.0
},
minPayout: 1000000000n,
maxPayout: 10000000000000n
},
credits: {
enabled: false,
multipliers: {
escape: 1.5,
escapeWithTreasure: 2.0,
perfectRun: 3.0
},
baseValue: 50000000000n,
minPayout: 1000000000n,
maxPayout: 5000000000000n
}
},
processing: {
automatic: true,
batchingEnabled: true,
batchInterval: 300,
confirmations: 1,
maxRetries: 3
}
},
promotions: {
enabled: false,
freeCredits: {
onSignup: 0,
onFirstPurchase: 0,
referralBonus: 0
},
discounts: {
enabled: false,
bulkDiscount: 0,
happyHour: null
}
},
limits: {
maxGamesPerHour: 60,
maxPayoutsPerDay: 100,
maxCreditPurchasePerDay: 100000000000000n,
cooldownBetweenGames: 5
},
preferences: {
allowMixedMode: true,
preferCreditsFirst: true
}
});
function cloneConfig(value) {
if (Array.isArray(value)) {
return value.map(cloneConfig);
}
if (value && typeof value === 'object' && !(value instanceof EventEmitter)) {
return Object.entries(value).reduce((acc, [key, val]) => {
acc[key] = cloneConfig(val);
return acc;
}, {});
}
return value;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'boolean') {
return value;
}
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
return fallback;
}
function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (Number.isInteger(value)) {
return value;
}
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
if (normalized === '') {
return fallback;
}
const num = Number(normalized);
if (!Number.isFinite(num)) {
return fallback;
}
return Math.trunc(num);
}
function parseAtomicValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'bigint') {
return value;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
return fallback;
}
return BigInt(Math.round(value));
}
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
if (normalized === '') {
return fallback;
}
if (normalized.startsWith('0x')) {
try {
return BigInt(normalized);
} catch (error) {
return fallback;
}
}
const numeric = Number(normalized);
if (Number.isFinite(numeric)) {
return BigInt(Math.round(numeric));
}
try {
return BigInt(normalized);
} catch (error) {
return fallback;
}
}
function parseFloatValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'number') {
return value;
}
const normalized = String(value).trim();
const num = Number(normalized);
return Number.isFinite(num) ? num : fallback;
}
function safeParseJson(value, fallback) {
if (!value) {
return fallback;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
class PaymentConfigManager {
constructor(options = {}) {
this.logger = options.logger || console;
this.eventBus = options.eventBus || new EventEmitter();
this.persistenceAdapter = options.persistenceAdapter || null;
this.config = this.loadConfig();
this.validateConfig();
this.legacyOverride = process.env.GAME_MODE || null;
}
loadConfig() {
const config = cloneConfig(DEFAULT_CONFIG);
config.paymentsEnabled = parseBoolean(process.env.PAYMENTS_ENABLED, config.paymentsEnabled);
const gameModeOverride = process.env.GAME_MODE ? process.env.GAME_MODE.toUpperCase() : null;
if (gameModeOverride === 'FREE') {
config.paymentsEnabled = false;
config.modes.direct.enabled = false;
config.modes.credits.enabled = false;
} else if (gameModeOverride === 'PAID_SINGLE') {
config.paymentsEnabled = true;
config.modes.direct.enabled = true;
config.modes.credits.enabled = false;
} else if (gameModeOverride === 'PAID_CREDITS') {
config.paymentsEnabled = true;
config.modes.direct.enabled = false;
config.modes.credits.enabled = true;
}
if (process.env.PAYMENT_MODES) {
const modes = process.env.PAYMENT_MODES.split(',').map(m => m.trim().toLowerCase()).filter(Boolean);
config.modes.direct.enabled = modes.includes('direct');
config.modes.credits.enabled = modes.includes('credits');
config.paymentsEnabled = config.modes.direct.enabled || config.modes.credits.enabled;
}
config.currency.symbol = process.env.CRYPTO_TYPE || config.currency.symbol;
config.currency.minPayment = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.currency.minPayment);
config.currency.maxPayment = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.currency.maxPayment);
const directPriceEnv = process.env.DIRECT_GAME_PRICE ?? process.env.SINGLE_GAME_PRICE;
config.modes.direct.price = parseAtomicValue(directPriceEnv, config.modes.direct.price);
config.modes.direct.enabled = parseBoolean(process.env.DIRECT_PAYMENT_ENABLED, config.modes.direct.enabled);
config.modes.direct.requiresAddress = parseBoolean(process.env.DIRECT_REQUIRES_ADDRESS, config.modes.direct.requiresAddress);
config.modes.direct.allowGuestPlay = parseBoolean(process.env.DIRECT_ALLOW_GUEST_PLAY, config.modes.direct.allowGuestPlay);
const creditsEnabled = parseBoolean(process.env.CREDITS_ENABLED, undefined);
if (creditsEnabled !== undefined) {
config.modes.credits.enabled = creditsEnabled;
}
config.modes.credits.creditsPerGame = parseInteger(process.env.CREDITS_PER_GAME, config.modes.credits.creditsPerGame);
config.modes.credits.requiresAddress = parseBoolean(process.env.CREDITS_REQUIRES_ADDRESS, config.modes.credits.requiresAddress);
config.modes.credits.allowMixedMode = parseBoolean(process.env.ALLOW_MIXED_MODE, config.modes.credits.allowMixedMode);
const packageJson = safeParseJson(process.env.CREDITS_PACKAGES, null);
if (Array.isArray(packageJson) && packageJson.length > 0) {
config.modes.credits.packages = packageJson.map(pkg => ({
id: pkg.id,
credits: parseInteger(pkg.credits, 0),
price: parseAtomicValue(pkg.price, 0n),
bonus: parseInteger(pkg.bonus || 0, 0)
})).filter(pkg => pkg.id);
} else if (process.env.CREDITS_PACKAGE_PRICE) {
const overridePrice = parseAtomicValue(process.env.CREDITS_PACKAGE_PRICE, null);
if (overridePrice !== null) {
config.modes.credits.packages = config.modes.credits.packages.map((pkg, index) =>
index === 0 ? { ...pkg, price: overridePrice } : pkg
);
}
}
config.payouts.enabled = parseBoolean(process.env.PAYOUTS_ENABLED, config.payouts.enabled);
config.payouts.rules.direct.enabled = parseBoolean(process.env.DIRECT_PAYOUTS_ENABLED, config.payouts.rules.direct.enabled);
config.payouts.rules.direct.multipliers.escape = parseFloatValue(process.env.DIRECT_PAYOUT_ESCAPE, config.payouts.rules.direct.multipliers.escape);
config.payouts.rules.direct.multipliers.escapeWithTreasure = parseFloatValue(process.env.DIRECT_PAYOUT_TREASURE, config.payouts.rules.direct.multipliers.escapeWithTreasure);
config.payouts.rules.direct.minPayout = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.payouts.rules.direct.minPayout);
config.payouts.rules.direct.maxPayout = parseAtomicValue(process.env.PAYOUT_MAX_PER_GAME, config.payouts.rules.direct.maxPayout);
const creditsPayoutEnabled = parseBoolean(process.env.CREDITS_PAYOUTS_ENABLED ?? process.env.CREDITS_PAYOUT_ENABLED, undefined);
if (creditsPayoutEnabled !== undefined) {
config.payouts.rules.credits.enabled = creditsPayoutEnabled;
}
config.payouts.rules.credits.multipliers.escape = parseFloatValue(process.env.CREDITS_PAYOUT_ESCAPE, config.payouts.rules.credits.multipliers.escape);
config.payouts.rules.credits.multipliers.escapeWithTreasure = parseFloatValue(process.env.CREDITS_PAYOUT_TREASURE, config.payouts.rules.credits.multipliers.escapeWithTreasure);
config.payouts.rules.credits.baseValue = parseAtomicValue(process.env.CREDITS_PAYOUT_BASE, config.payouts.rules.credits.baseValue);
config.payouts.processing.batchInterval = parseInteger(process.env.PAYOUT_BATCH_INTERVAL, config.payouts.processing.batchInterval);
config.payouts.processing.maxRetries = parseInteger(process.env.PAYOUT_MAX_RETRIES, config.payouts.processing.maxRetries);
config.limits.maxGamesPerHour = parseInteger(process.env.MAX_GAMES_PER_HOUR, config.limits.maxGamesPerHour);
config.limits.maxPayoutsPerDay = parseInteger(process.env.MAX_PAYOUTS_PER_DAY, config.limits.maxPayoutsPerDay);
config.limits.maxCreditPurchasePerDay = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.limits.maxCreditPurchasePerDay);
config.limits.cooldownBetweenGames = parseInteger(process.env.GAME_COOLDOWN_SECONDS, config.limits.cooldownBetweenGames);
config.preferences.allowMixedMode = config.modes.credits.allowMixedMode;
config.preferences.preferCreditsFirst = parseBoolean(process.env.PREFER_CREDITS_FIRST, config.preferences.preferCreditsFirst);
return config;
}
getConfig() {
return cloneConfig(this.config);
}
getLegacyGameMode() {
if (!this.config.paymentsEnabled) {
return 'FREE';
}
if (this.legacyOverride) {
return this.legacyOverride.toUpperCase();
}
const directEnabled = !!this.config.modes.direct.enabled;
const creditsEnabled = !!this.config.modes.credits.enabled;
if (directEnabled && !creditsEnabled) {
return 'PAID_SINGLE';
}
if (!directEnabled && creditsEnabled) {
return 'PAID_CREDITS';
}
if (!directEnabled && !creditsEnabled) {
return 'FREE';
}
return this.config.preferences.preferCreditsFirst ? 'PAID_CREDITS' : 'PAID_SINGLE';
}
isModeEnabled(mode) {
return Boolean(this.config.modes[mode]?.enabled);
}
validateConfig() {
const directEnabled = this.config.modes.direct.enabled;
const creditsEnabled = this.config.modes.credits.enabled;
if (!this.config.paymentsEnabled && (directEnabled || creditsEnabled)) {
this.logger.warn?.('⚠️ paymentsEnabled is false but some payment modes are enabled. Payments will remain disabled.');
}
if (!directEnabled && !creditsEnabled && this.config.paymentsEnabled) {
this.logger.warn?.('⚠️ Payments enabled but no payment modes active. Falling back to FREE mode.');
}
this.validatePrices();
return true;
}
validatePrices() {
const { direct, credits } = this.config.modes;
if (direct.enabled && direct.price <= 0n) {
throw new Error('Direct game price must be a positive value');
}
if (credits.enabled) {
for (const pkg of credits.packages) {
if (!pkg.id) {
throw new Error('Each credit package requires an id');
}
if (pkg.price <= 0n) {
throw new Error(`Credit package ${pkg.id} has invalid price`);
}
if (pkg.credits <= 0) {
throw new Error(`Credit package ${pkg.id} has invalid credit count`);
}
}
}
}
async updateConfig(updates) {
const newConfig = cloneConfig(this.config);
Object.keys(updates || {}).forEach(key => {
newConfig[key] = updates[key];
});
const testManager = new PaymentConfigManager({
logger: this.logger,
eventBus: this.eventBus,
persistenceAdapter: this.persistenceAdapter
});
testManager.config = cloneConfig(newConfig);
testManager.validateConfig();
this.config = newConfig;
if (this.persistenceAdapter && typeof this.persistenceAdapter.save === 'function') {
await this.persistenceAdapter.save(newConfig);
}
this.broadcastConfigUpdate();
return this.getConfig();
}
refresh() {
this.config = this.loadConfig();
this.validateConfig();
this.broadcastConfigUpdate();
return this.getConfig();
}
broadcastConfigUpdate() {
if (this.eventBus && typeof this.eventBus.emit === 'function') {
this.eventBus.emit('paymentConfig:update', this.getConfig());
}
}
setPersistenceAdapter(adapter) {
this.persistenceAdapter = adapter;
}
setEventBus(eventBus) {
this.eventBus = eventBus;
}
summarize() {
return {
paymentsEnabled: this.config.paymentsEnabled,
legacyMode: this.getLegacyGameMode(),
directEnabled: this.config.modes.direct.enabled,
creditsEnabled: this.config.modes.credits.enabled,
directPrice: this.config.modes.direct.price,
creditPackages: this.config.modes.credits.packages.length
};
}
}
PaymentConfigManager.DEFAULT_CONFIG = DEFAULT_CONFIG;
module.exports = PaymentConfigManager;
+317 -63
View File
@@ -3,36 +3,276 @@
* Handles different game modes (FREE, PAID_SINGLE, PAID_CREDITS)
*/
const DEFAULT_SINGLE_GAME_PRICE = 5000000000; // 0.005 XMR or 0.05 WOW depending on currency decimals
const DEFAULT_CREDITS_PACKAGE_PRICE = 50000000000;
const DEFAULT_DECIMALS = 12;
const parseAtomicEnvValue = (val, fallback) => {
if (val === undefined || val === null || val === '') return fallback;
if (typeof val !== 'string') {
const numVal = Number(val);
return Number.isFinite(numVal) ? Math.trunc(numVal) : fallback;
}
const cleaned = val.replace(/_/g, '').trim();
if (!cleaned) {
return fallback;
}
const num = Number(cleaned);
if (!Number.isFinite(num) || num < 0) {
return fallback;
}
return Math.trunc(num);
};
class GameModeManager {
constructor(databaseManager, walletRPCService, debugManager) {
constructor(databaseManager, walletRPCService, debugManager, paymentConfigManager = null) {
this.db = databaseManager;
this.walletService = walletRPCService; // Changed from moneroPayService
this.debugManager = debugManager;
this.gameMode = process.env.GAME_MODE || 'FREE';
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
// Flexible parsing: supports plain integers, scientific notation (e.g. 5e9), and underscores (e.g. 5_000_000_000)
const parseAtomic = (val, fallback) => {
if (val === undefined || val === null || val === '') return fallback;
if (typeof val !== 'string') return Math.trunc(Number(val)) || fallback;
const cleaned = val.replace(/_/g, '').trim();
const num = Number(cleaned);
if (!Number.isFinite(num) || num < 0) return fallback;
// Ensure integer atomic units
return Math.trunc(num);
};
this.paymentConfigManager = paymentConfigManager || null;
this.singleGamePrice = parseAtomic(process.env.SINGLE_GAME_PRICE, 5000000000); // default 0.005 XMR
this.creditsPackagePrice = parseAtomic(process.env.CREDITS_PACKAGE_PRICE, 50000000000); // default 0.05 XMR
this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUT_ENABLED || 'false');
console.log(`🎮 Game Mode Manager initialized: ${this.gameMode} mode`);
console.log(`💰 Currency: ${this.cryptoType}`);
const toXMR = (atomic) => (atomic / 1e12).toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${toXMR(this.singleGamePrice)} ${this.cryptoType})`);
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${toXMR(this.creditsPackagePrice)} ${this.cryptoType})`);
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE;
this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE;
this.creditsPayoutEnabled = false;
this.creditsPayoutBaseValue = DEFAULT_SINGLE_GAME_PRICE;
this.directPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
this.creditPayoutMultipliers = { escape: 2, escapeWithTreasure: 3 };
this.creditsPerGameCost = 1;
this.preferCreditsFirst = true;
this.paymentsEnabled = false;
this.directModeEnabled = false;
this.creditsModeEnabled = false;
this.configSnapshot = null;
this.applyLegacyEnvConfig();
if (this.paymentConfigManager) {
const config = this.paymentConfigManager.getConfig();
this.applyConfigSnapshot(config, { emitLog: false });
this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode());
if (this.paymentConfigManager.eventBus && typeof this.paymentConfigManager.eventBus.on === 'function') {
this.paymentConfigManager.eventBus.on('paymentConfig:update', (updatedConfig) => {
this.applyConfigSnapshot(updatedConfig, { emitLog: true, context: 'updated' });
this.setLegacyGameMode(this.paymentConfigManager.getLegacyGameMode());
});
}
}
this.logConfiguration('initialized');
}
inferCurrencyDecimals(symbol) {
if (!symbol) return DEFAULT_DECIMALS;
const normalized = symbol.toUpperCase();
if (normalized === 'WOW') return 11;
if (normalized === 'XMR') return 12;
return DEFAULT_DECIMALS;
}
applyLegacyEnvConfig() {
this.setLegacyGameMode(process.env.GAME_MODE || 'FREE');
this.cryptoType = process.env.CRYPTO_TYPE || this.cryptoType;
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
this.singleGamePrice = parseAtomicEnvValue(process.env.SINGLE_GAME_PRICE, this.singleGamePrice);
this.creditsPackagePrice = parseAtomicEnvValue(process.env.CREDITS_PACKAGE_PRICE, this.creditsPackagePrice);
this.creditsPerGameCost = parseAtomicEnvValue(process.env.CREDITS_PER_GAME, 1) || 1;
this.creditsPayoutEnabled = /^true$/i.test(process.env.CREDITS_PAYOUT_ENABLED || 'false');
this.creditsPayoutBaseValue = this.singleGamePrice;
process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost);
const directEscape = Number(process.env.DIRECT_PAYOUT_ESCAPE);
if (Number.isFinite(directEscape) && directEscape > 0) {
this.directPayoutMultipliers.escape = directEscape;
}
const directTreasure = Number(process.env.DIRECT_PAYOUT_TREASURE);
if (Number.isFinite(directTreasure) && directTreasure > 0) {
this.directPayoutMultipliers.escapeWithTreasure = directTreasure;
}
const creditsEscape = Number(process.env.CREDITS_PAYOUT_ESCAPE);
if (Number.isFinite(creditsEscape) && creditsEscape > 0) {
this.creditPayoutMultipliers.escape = creditsEscape;
}
const creditsTreasure = Number(process.env.CREDITS_PAYOUT_TREASURE);
if (Number.isFinite(creditsTreasure) && creditsTreasure > 0) {
this.creditPayoutMultipliers.escapeWithTreasure = creditsTreasure;
}
if (process.env.PREFER_CREDITS_FIRST) {
this.preferCreditsFirst = /^true$/i.test(process.env.PREFER_CREDITS_FIRST);
}
this.paymentsEnabled = this.gameMode !== 'FREE';
this.directModeEnabled = this.gameMode === 'PAID_SINGLE';
this.creditsModeEnabled = this.gameMode === 'PAID_CREDITS';
}
applyConfigSnapshot(config, options = {}) {
if (!config || typeof config !== 'object') {
return;
}
this.configSnapshot = config;
this.paymentsEnabled = !!config.paymentsEnabled;
if (config.currency) {
if (config.currency.symbol) {
this.cryptoType = config.currency.symbol;
}
if (config.currency.decimals !== undefined) {
this.currencyDecimals = Number(config.currency.decimals);
} else {
this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType);
}
}
if (config.modes && config.modes.direct) {
const { price, enabled } = config.modes.direct;
if (price !== undefined && price !== null) {
this.singleGamePrice = Number(price);
}
this.directModeEnabled = !!enabled;
}
if (config.modes && config.modes.credits) {
const creditsMode = config.modes.credits;
if (creditsMode.packages && creditsMode.packages.length > 0) {
const primaryPackage = creditsMode.packages[0];
if (primaryPackage.price !== undefined && primaryPackage.price !== null) {
this.creditsPackagePrice = Number(primaryPackage.price);
}
if (primaryPackage.credits) {
process.env.CREDITS_PER_PACKAGE = String(primaryPackage.credits);
}
}
if (creditsMode.creditsPerGame !== undefined) {
this.creditsPerGameCost = Number(creditsMode.creditsPerGame) || 1;
}
this.creditsModeEnabled = !!creditsMode.enabled;
}
if (config.payouts && config.payouts.rules) {
const directRule = config.payouts.rules.direct || {};
const creditsRule = config.payouts.rules.credits || {};
if (directRule.multipliers) {
if (directRule.multipliers.escape !== undefined) {
this.directPayoutMultipliers.escape = Number(directRule.multipliers.escape);
}
if (directRule.multipliers.escapeWithTreasure !== undefined) {
this.directPayoutMultipliers.escapeWithTreasure = Number(directRule.multipliers.escapeWithTreasure);
}
}
if (creditsRule.multipliers) {
if (creditsRule.multipliers.escape !== undefined) {
this.creditPayoutMultipliers.escape = Number(creditsRule.multipliers.escape);
}
if (creditsRule.multipliers.escapeWithTreasure !== undefined) {
this.creditPayoutMultipliers.escapeWithTreasure = Number(creditsRule.multipliers.escapeWithTreasure);
}
}
if (creditsRule.baseValue !== undefined) {
this.creditsPayoutBaseValue = Number(creditsRule.baseValue);
}
if (creditsRule.enabled !== undefined) {
this.creditsPayoutEnabled = !!creditsRule.enabled;
}
}
if (config.preferences) {
if (config.preferences.preferCreditsFirst !== undefined) {
this.preferCreditsFirst = !!config.preferences.preferCreditsFirst;
}
}
process.env.SINGLE_GAME_PRICE = String(this.singleGamePrice);
process.env.CREDITS_PACKAGE_PRICE = String(this.creditsPackagePrice);
process.env.CREDITS_PER_GAME = String(this.creditsPerGameCost);
process.env.CREDITS_PAYOUT_ENABLED = this.creditsPayoutEnabled ? 'true' : 'false';
process.env.CREDITS_PAYOUTS_ENABLED = process.env.CREDITS_PAYOUT_ENABLED;
if (options.emitLog) {
this.logConfiguration(options.context || 'updated');
}
}
setLegacyGameMode(mode) {
this.gameMode = (mode || 'FREE').toUpperCase();
process.env.GAME_MODE = this.gameMode;
}
formatAtomic(value) {
if (value === undefined || value === null) {
return '0';
}
const decimals = Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS;
const divisor = Math.pow(10, decimals);
const quotient = Number(value) / divisor;
return Number.isFinite(quotient)
? quotient.toFixed(6).replace(/0+$/, '').replace(/\.$/, '')
: value.toString();
}
getDecimalDivisor() {
const decimals = Number.isFinite(this.currencyDecimals) ? this.currencyDecimals : DEFAULT_DECIMALS;
return Math.pow(10, decimals);
}
formatAtomicHuman(value, digits = 3) {
if (value === undefined || value === null) {
return '0';
}
const divisor = this.getDecimalDivisor();
const quotient = Number(value) / divisor;
if (!Number.isFinite(quotient)) {
return value.toString();
}
return quotient.toFixed(digits);
}
getPrimaryCreditPackage() {
const packages = this.configSnapshot?.modes?.credits?.packages;
if (Array.isArray(packages) && packages.length > 0) {
return packages[0];
}
return {
id: 'default',
credits: 10,
price: this.creditsPackagePrice,
bonus: 0
};
}
calculatePayout(mode, { treasureFound = false } = {}) {
const normalizedMode = (mode || this.gameMode || 'FREE').toUpperCase();
const usingCredits = normalizedMode === 'PAID_CREDITS';
const base = usingCredits ? (this.creditsPayoutBaseValue || this.singleGamePrice) : this.singleGamePrice;
const multipliers = usingCredits ? this.creditPayoutMultipliers : this.directPayoutMultipliers;
const multiplier = treasureFound
? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0)
: (multipliers.escape ?? 0);
const amount = Math.round(base * multiplier);
return { amount, multiplier, base };
}
logConfiguration(context = 'initialized') {
console.log(`🎮 Game Mode Manager ${context}: ${this.gameMode} mode`);
console.log(`💰 Currency: ${this.cryptoType} (decimals: ${this.currencyDecimals})`);
console.log(`💵 Single game price: ${this.singleGamePrice} atomic units (~${this.formatAtomic(this.singleGamePrice)} ${this.cryptoType})`);
console.log(`🎫 Credits package price: ${this.creditsPackagePrice} atomic units (~${this.formatAtomic(this.creditsPackagePrice)} ${this.cryptoType})`);
console.log(`🎯 Credits per game cost: ${this.creditsPerGameCost}`);
console.log(`🧮 Payout multipliers - direct: ${JSON.stringify(this.directPayoutMultipliers)}, credits: ${JSON.stringify(this.creditPayoutMultipliers)}`);
console.log(`🔁 Mode availability - direct: ${this.directModeEnabled}, credits: ${this.creditsModeEnabled}, preferCreditsFirst: ${this.preferCreditsFirst}`);
if (this.creditsPayoutEnabled) {
console.log('🎁 Credits payout mode ENABLED (will pay rewards in PAID_CREDITS).');
}
console.log(`⚙️ Payments enabled: ${this.paymentsEnabled}`);
}
/**
@@ -47,13 +287,19 @@ class GameModeManager {
return { allowed: true, reason: 'Free mode' };
case 'PAID_CREDITS':
if (user.credits > 0) {
return { allowed: true, reason: `${user.credits} credits remaining` };
if (user.credits >= this.creditsPerGameCost) {
return {
allowed: true,
reason: `${user.credits} credits remaining`,
creditsRequired: this.creditsPerGameCost
};
}
return {
allowed: false,
reason: 'No credits remaining',
action: 'purchase_credits'
reason: 'Insufficient credits',
action: 'purchase_credits',
creditsRequired: this.creditsPerGameCost,
balance: user.credits
};
case 'PAID_SINGLE':
@@ -106,20 +352,21 @@ class GameModeManager {
return { success: true };
case 'PAID_CREDITS':
// Deduct one credit
// Deduct required credits for this mode
const creditsToSpend = this.creditsPerGameCost;
const updateRes = await this.db.query(`
UPDATE users
SET credits = credits - 1,
SET credits = credits - $1,
total_games_played = total_games_played + 1,
updated_at = NOW()
WHERE id = $1
WHERE id = $2
RETURNING credits
`, [user.id]);
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - 1);
`, [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 1 credit from user ${user.id}, ${remainingCredits} remaining`);
return { success: true, creditsRemaining: remainingCredits };
console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`);
return { success: true, creditsRemaining: remainingCredits, creditsSpent: creditsToSpend };
case 'PAID_SINGLE':
// Link game to payment
@@ -193,22 +440,20 @@ class GameModeManager {
WHERE id = $4
`, [outcome === 'escaped' ? 'won' : 'lost', outcome, treasureFound, gameId]);
// Handle payouts for PAID_SINGLE or (optionally) PAID_CREDITS mode
const payoutEligibleMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
const recordedMode = (game.payment_mode || game.game_mode || this.gameMode || 'FREE').toUpperCase();
const payoutEligibleMode = (recordedMode === 'PAID_SINGLE') || (recordedMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
let payoutAmount;
const base = (this.gameMode === 'PAID_CREDITS') ? this.singleGamePrice : this.singleGamePrice; // could make distinct; reuse singleGamePrice as base
payoutAmount = treasureFound ? base * 3 : base * 2;
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
if (payoutAmount > 0) {
await this.walletService.processPayout(
game.user_id,
gameId,
game.payout_address,
payoutAmount
);
// Create payout record
await this.walletService.processPayout(
game.user_id,
gameId,
game.payout_address,
payoutAmount
);
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId}`);
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
}
}
// Update user statistics
@@ -241,19 +486,25 @@ class GameModeManager {
try {
const user = await this.getOrCreateUser(socketId);
let amount, description;
let amount;
let description;
let packageInfo = null;
switch (paymentType) {
case 'single_game':
case 'single_game': {
amount = this.singleGamePrice;
description = `Wowngeon single game entry (${this.cryptoType})`;
break;
case 'credits_package':
amount = this.creditsPackagePrice;
description = `Wowngeon 10 credits package (${this.cryptoType})`;
}
case 'credits_package': {
const primaryPackage = this.getPrimaryCreditPackage();
const packagePrice = primaryPackage?.price ?? this.creditsPackagePrice;
amount = Number(packagePrice);
packageInfo = primaryPackage;
const creditCount = primaryPackage?.credits ?? 10;
description = `Wowngeon ${creditCount} credits package (${this.cryptoType})`;
break;
}
default:
throw new Error('Invalid payment type');
}
@@ -280,9 +531,10 @@ class GameModeManager {
id: paymentResult.id,
address: paymentResult.address,
amount: amount,
amountFormatted: (amount / 1e12).toFixed(4), // Convert from atomic units
amountFormatted: this.formatAtomicHuman(amount, 4),
currency: this.cryptoType,
expiresAt: paymentResult.expiresAt
expiresAt: paymentResult.expiresAt,
package: packageInfo
};
} catch (error) {
@@ -389,10 +641,14 @@ class GameModeManager {
cryptoType: this.cryptoType,
singleGamePrice: this.singleGamePrice,
creditsPackagePrice: this.creditsPackagePrice,
creditsPerGame: this.creditsPerGameCost,
paymentsEnabled: this.paymentsEnabled,
directModeEnabled: this.directModeEnabled,
creditsModeEnabled: this.creditsModeEnabled,
features: {
paymentRequired: this.gameMode !== 'FREE',
creditsSystem: this.gameMode === 'PAID_CREDITS',
payouts: this.gameMode === 'PAID_SINGLE'
paymentRequired: this.paymentsEnabled,
creditsSystem: this.creditsModeEnabled,
payouts: this.directPayoutMultipliers.escape > 0 || this.creditPayoutMultipliers.escape > 0
}
};
}
@@ -429,9 +685,7 @@ class GameModeManager {
// Handle payouts only in PAID_SINGLE mode and only on win (escaped)
const payoutEligibleStartMode = (this.gameMode === 'PAID_SINGLE') || (this.gameMode === 'PAID_CREDITS' && this.creditsPayoutEnabled);
if (payoutEligibleStartMode && won) {
// Derive payout multiplier
const multiplier = treasureFound ? 3 : 2;
const payoutAmount = this.singleGamePrice * multiplier;
const { amount: payoutAmount, multiplier } = this.calculatePayout(this.gameMode, { treasureFound });
// Look up user record for payout address
const userResult = await this.db.query(`SELECT * FROM users WHERE socket_id = $1 LIMIT 1`, [socketId]);
+67 -14
View File
@@ -13,12 +13,20 @@ const DatabaseManager = require('./db/databaseManager');
const WalletRPCService = require('./payments/walletRPCService');
const GameModeManager = require('./game/gameModeManager');
const RpcService = require('./rpc/rpcService');
const PaymentConfigManager = require('./config/paymentConfig');
const EnvironmentValidator = require('./config/environmentValidator');
// Import modular components
const BroadcastManager = require('./network/broadcastManager');
const DebugManager = require('./debug/debugManager');
const SocketHandlers = require('./network/socketHandlers');
// Initialize payment configuration
const paymentConfigManager = new PaymentConfigManager({ logger: console });
const environmentValidator = new EnvironmentValidator({ logger: console });
environmentValidator.validate(paymentConfigManager.getConfig());
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
// Initialize modular components first
const broadcastManager = new BroadcastManager(io);
const debugManager = new DebugManager(broadcastManager);
@@ -27,7 +35,7 @@ const debugManager = new DebugManager(broadcastManager);
const databaseManager = new DatabaseManager();
const rpcService = new RpcService();
const walletRPCService = new WalletRPCService(debugManager);
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager);
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
gameModeManager.io = io;
@@ -99,18 +107,41 @@ app.get('/api/user/:userId/credits', async (req, res) => {
});
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
const toDisplay = (value) => {
if (value === null || value === undefined) {
return 0;
}
const numeric = typeof value === 'bigint' ? Number(value) : Number(value);
return Number.isFinite(numeric) ? numeric / divisor : 0;
};
const directMode = config.modes.direct;
const creditsMode = config.modes.credits;
res.json({
FREE: { name: 'Free Play', cost: 0, payoutMultiplier: 0 },
PAID_SINGLE: {
name: 'Paid Single Game',
cost: parseFloat(process.env.SINGLE_GAME_COST || '0.005'),
payoutMultiplier: { escape: 2, treasure: 3 }
FREE: {
name: 'Free Play',
cost: 0,
payoutMultiplier: 0,
enabled: !config.paymentsEnabled
},
PAID_CREDITS: {
name: 'Credits Package',
cost: parseFloat(process.env.CREDITS_PACKAGE_COST || '0.03'),
credits: parseInt(process.env.CREDITS_PER_PACKAGE || '10'),
payoutMultiplier: 0
PAID_SINGLE: {
name: 'Paid Single Game',
cost: toDisplay(directMode.price),
enabled: !!directMode.enabled,
payoutMultiplier: config.payouts.rules.direct.multipliers
},
PAID_CREDITS: {
name: 'Credits Package',
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
enabled: !!creditsMode.enabled,
payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled
? config.payouts.rules.credits.multipliers
: 0
}
});
});
@@ -131,6 +162,11 @@ async function initializePaymentSystem() {
if (!walletInitialized) {
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
process.env.GAME_MODE = 'FREE';
paymentConfigManager.refresh();
gameModeManager.setLegacyGameMode('FREE');
gameModeManager.paymentsEnabled = false;
gameModeManager.directModeEnabled = false;
gameModeManager.creditsModeEnabled = false;
}
// Initialize database connection and run migrations
@@ -186,18 +222,35 @@ async function startServer() {
console.log('🚀 Wowngeon server listening on *:3000');
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
console.log(`🎮 Available game modes: ${paymentSystemReady ? 'FREE, PAID_SINGLE, PAID_CREDITS' : 'FREE ONLY'}`);
const summary = paymentConfigManager.summarize();
const enabledModes = [];
if (!summary.paymentsEnabled || !paymentSystemReady) {
enabledModes.push('FREE');
}
if (summary.directEnabled && paymentSystemReady) {
enabledModes.push('PAID_SINGLE');
}
if (summary.creditsEnabled && paymentSystemReady) {
enabledModes.push('PAID_CREDITS');
}
if (enabledModes.length === 0) {
enabledModes.push(summary.legacyMode || 'FREE');
}
console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`);
});
// Start batch payout processing if payment system is ready
if (paymentSystemReady) {
if (paymentSystemReady) {
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
const payoutIntervalMs = payoutIntervalSeconds * 1000;
setInterval(async () => {
try {
await walletRPCService.processBatchPayouts();
} catch (error) {
console.error('Error in batch payout processing:', error);
}
}, 300000); // Every 5 minutes
}, payoutIntervalMs);
}
} catch (error) {
@@ -0,0 +1,149 @@
-- Migration 005: Unified payment system foundation
-- Extend payments table to track unified payment metadata
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20);
ALTER TABLE payments
ALTER COLUMN payment_mode SET DEFAULT 'direct';
UPDATE payments
SET payment_mode = 'direct'
WHERE payment_mode IS NULL;
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS credit_package_id VARCHAR(20);
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS credits_purchased INTEGER DEFAULT 0;
UPDATE payments
SET credits_purchased = 0
WHERE credits_purchased IS NULL;
-- Extend user profile with payment preferences and stats
ALTER TABLE users
ADD COLUMN IF NOT EXISTS preferred_payment_mode VARCHAR(20) DEFAULT 'direct';
UPDATE users
SET preferred_payment_mode = 'direct'
WHERE preferred_payment_mode IS NULL;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS total_credits_purchased INTEGER DEFAULT 0;
UPDATE users
SET total_credits_purchased = 0
WHERE total_credits_purchased IS NULL;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS total_payouts_received BIGINT DEFAULT 0;
UPDATE users
SET total_payouts_received = 0
WHERE total_payouts_received IS NULL;
-- Ensure total_games_played always has a sensible default
ALTER TABLE users
ALTER COLUMN total_games_played SET DEFAULT 0;
UPDATE users
SET total_games_played = 0
WHERE total_games_played IS NULL;
-- Ensure credit_transactions table exists for installs that missed migration 004
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'credit_transactions'
) THEN
CREATE TABLE credit_transactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
reason VARCHAR(100) NOT NULL,
balance_after INTEGER NOT NULL,
transaction_type VARCHAR(20) DEFAULT 'legacy',
created_at TIMESTAMP DEFAULT NOW(),
metadata JSONB
);
END IF;
END $$;
-- Tag credit transactions with a transaction type
ALTER TABLE credit_transactions
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20);
UPDATE credit_transactions
SET transaction_type = COALESCE(transaction_type, 'legacy');
ALTER TABLE credit_transactions
ALTER COLUMN transaction_type SET DEFAULT 'legacy';
CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id);
CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON credit_transactions(created_at);
-- Create payouts table if it was dropped in the past (fresh installs)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'payouts'
) THEN
CREATE TABLE payouts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
payout_address VARCHAR(110) NOT NULL,
amount BIGINT NOT NULL,
multiplier DECIMAL(3,1) DEFAULT 0,
reason VARCHAR(50) DEFAULT 'legacy' NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
batch_id VARCHAR(50),
tx_hash VARCHAR(64),
fee BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
error_message TEXT
);
END IF;
END $$;
-- Add new payout tracking columns for existing installs
ALTER TABLE payouts
ADD COLUMN IF NOT EXISTS multiplier DECIMAL(3,1) DEFAULT 0;
UPDATE payouts
SET multiplier = 0
WHERE multiplier IS NULL;
ALTER TABLE payouts
ALTER COLUMN multiplier SET NOT NULL;
ALTER TABLE payouts
ADD COLUMN IF NOT EXISTS reason VARCHAR(50) DEFAULT 'legacy';
UPDATE payouts
SET reason = 'legacy'
WHERE reason IS NULL;
ALTER TABLE payouts
ALTER COLUMN reason SET NOT NULL;
ALTER TABLE payouts
ADD COLUMN IF NOT EXISTS error_message TEXT;
-- Align payout address length with Wownero expectations
DROP VIEW IF EXISTS pending_payouts;
ALTER TABLE payouts
ALTER COLUMN payout_address TYPE VARCHAR(110);
CREATE OR REPLACE VIEW pending_payouts AS
SELECT
p.id,
p.user_id,
u.username,
p.payout_address,
p.amount,
p.status,
p.created_at,
g.outcome AS game_outcome
FROM payouts p
JOIN users u ON p.user_id = u.id
LEFT JOIN games g ON p.game_id = g.id
WHERE p.status = 'pending'
ORDER BY p.created_at ASC;
-- Add helpful indexes for new queries
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
CREATE INDEX IF NOT EXISTS idx_payouts_user_id ON payouts(user_id);
CREATE INDEX IF NOT EXISTS idx_payments_payment_mode ON payments(payment_mode);
+55 -9
View File
@@ -50,6 +50,9 @@ class PaymentHandlers {
paymentId: paymentRequest.id,
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: paymentRequest.amountFormatted,
currency: this.gameModeManager.cryptoType,
package: paymentRequest.package,
gameMode: gameMode
});
if (this.debugManager.CONSOLE_LOGGING) console.log(`💳 Payment request created for ${socket.id}: ${paymentRequest.amount}`);
@@ -81,17 +84,60 @@ class PaymentHandlers {
// caller (SocketHandlers) will handle user existence; keep method generic
const gameMode = this.gameModeManager.gameMode;
const cryptoType = this.gameModeManager.cryptoType;
let paymentType, amount, description;
if (gameMode === 'PAID_SINGLE') { paymentType = 'single_game'; amount = this.gameModeManager.singleGamePrice; description = 'Single game entry'; }
else if (gameMode === 'PAID_CREDITS') { paymentType = 'credits_package'; amount = this.gameModeManager.creditsPackagePrice; description = '10 game credits package'; }
else { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.'); return; }
let paymentType;
let amount;
let description;
if (gameMode === 'PAID_SINGLE') {
paymentType = 'single_game';
amount = this.gameModeManager.singleGamePrice;
description = 'Single game entry';
} else if (gameMode === 'PAID_CREDITS') {
paymentType = 'credits_package';
const primaryPackage = this.gameModeManager.getPrimaryCreditPackage();
amount = Number(primaryPackage?.price ?? this.gameModeManager.creditsPackagePrice);
const credits = primaryPackage?.credits ?? this.gameModeManager.creditsPerGameCost * 10;
description = `${credits} credit package`;
} else {
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Invalid game mode configuration.');
return;
}
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType);
let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); qrDataUrl = await generatePaymentQR(paymentRequest.address, amount, cryptoType, description); } catch(e) {}
const humanAmount = (amount / 1000000000000).toFixed(3);
this.io.to(socket.id).emit('payment_created', { paymentId: paymentRequest.id, address: paymentRequest.address, amount: paymentRequest.amount, paymentType, gameMode, cryptoType, humanAmount, description, expiresAt: paymentRequest.expiresAt, qr: qrDataUrl });
this.broadcastManager.sendStatusUpdate(socket.id, 'payment', `💳 PAYMENT REQUIRED (${description})\n\nAmount: ${humanAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${humanAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`);
this._monitorAddress(socket, paymentRequest, amount, cryptoType, currentUser);
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
let qrDataUrl = null;
try {
const { generatePaymentQR } = require('../payments/qrService');
qrDataUrl = await generatePaymentQR(
paymentRequest.address,
paymentRequest.amount,
cryptoType,
description,
this.gameModeManager.currencyDecimals
);
} catch (e) {}
this.io.to(socket.id).emit('payment_created', {
paymentId: paymentRequest.id,
address: paymentRequest.address,
amount: paymentRequest.amount,
amountFormatted: formattedAmount,
paymentType,
gameMode,
cryptoType,
description,
expiresAt: paymentRequest.expiresAt,
qr: qrDataUrl,
package: paymentRequest.package
});
this.broadcastManager.sendStatusUpdate(
socket.id,
'payment',
`💳 PAYMENT REQUIRED (${description})\n\nAmount: ${formattedAmount} ${cryptoType}\nAddress: ${paymentRequest.address}\n\n⚠️ Send EXACTLY ${formattedAmount} ${cryptoType}.\n🔄 Added to queue once mempool seen.\n⏰ Expires in 30 minutes.`
);
this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser);
} catch (e) {
console.error('Error creating payment request:', e);
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to create payment request.');
+7 -4
View File
@@ -8,15 +8,18 @@ try {
QR = null;
}
function formatAmount(atomic) {
function formatAmount(atomic, decimals = 12) {
if (!atomic || atomic <= 0) return '0';
return (atomic / 1e12).toFixed(12).replace(/0+$/,'').replace(/\.$/,'');
const divisor = Math.pow(10, decimals);
return (Number(atomic) / divisor).toFixed(decimals)
.replace(/0+$/, '')
.replace(/\.$/, '');
}
async function generatePaymentQR(address, amountAtomic, cryptoType, description) {
async function generatePaymentQR(address, amountAtomic, cryptoType, description, decimals = 12) {
if (!QR) return null; // Library not installed
try {
const amountStr = formatAmount(amountAtomic);
const amountStr = formatAmount(amountAtomic, decimals);
const scheme = cryptoType && cryptoType.toUpperCase() === 'WOW' ? 'wownero' : 'monero';
// Basic URI; wallets usually understand amount (tx_amount) & description
const params = new URLSearchParams();