mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
UnifyPayments (megachange)
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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.');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user