add backup scripts and improve docs and .env.example

This commit is contained in:
jwinterm
2025-12-22 16:29:32 -05:00
parent 7f91d83308
commit 7d701fe01b
14 changed files with 1335 additions and 702 deletions
+98 -65
View File
@@ -1,116 +1,149 @@
# =============================================================================
# Wownerogue Server Configuration
# =============================================================================
# Copy this file to .env and customize for your environment.
# The server validates these settings at startup and warns about misconfigurations.
# -----------------------------------------------------------------------------
# Network Selection (IMPORTANT for Monero users)
# Network Selection
# -----------------------------------------------------------------------------
# For Wownero: Only mainnet exists, this setting is ignored
# For Monero: Choose mainnet, stagenet, or testnet
# For Wownero: Only mainnet exists (this setting is ignored)
# For Monero: mainnet | stagenet | testnet
# ⚠️ STAGENET WARNING: Stagenet XMR has no real value! Only for testing.
MONERO_NETWORK=mainnet
# MONERO_NETWORK=stagenet
# MONERO_NETWORK=testnet
# -----------------------------------------------------------------------------
# Difficulty Settings
# Difficulty Presets
# -----------------------------------------------------------------------------
# Presets: easy, normal, hard, casino
# - easy: Small dungeon, slow monster, high player win rate (~70%)
# - normal: Balanced difficulty (~45% player win rate)
# - hard: Large dungeon, aggressive monster (~35% player win rate)
# - casino: Maximum house edge (~30% player win rate) - DEFAULT for paid modes
# Leave unset to auto-select based on PAYMENTS_ENABLED
# Presets: easy | normal | hard | casino
# easy - Small dungeon, slow monster (~70% player win rate)
# normal - Balanced difficulty (~45% player win rate)
# hard - Large dungeon, aggressive monster (~35% player win rate)
# casino - Maximum house edge (~30% player win rate) - DEFAULT for paid modes
# Leave unset to auto-select based on payment mode.
# DIFFICULTY_PRESET=casino
# DIFFICULTY_PRESET=normal
# -----------------------------------------------------------------------------
# Unified Payment System (recommended configuration)
# Payment System
# -----------------------------------------------------------------------------
# Master switch for payments. Set false for FREE mode (no payments required).
PAYMENTS_ENABLED=true
# Enable one or both monetized modes (comma separated list: direct,credits)
# Payment modes: direct | credits | direct,credits (comma-separated)
# direct - Pay per game, eligible for payouts on win
# credits - Buy credit packages, spend 1 credit per game
# Enabling both allows users to choose; users with credits use them first.
PAYMENT_MODES=direct,credits
# Direct (per-run) mode settings
# -----------------------------------------------------------------------------
# Direct Mode (pay-per-game)
# -----------------------------------------------------------------------------
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units (1e11)
DIRECT_REQUIRES_ADDRESS=true
DIRECT_ALLOW_GUEST_PLAY=false
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
DIRECT_GAME_PRICE=100000000000 # Price in atomic units (1 WOW = 1e11)
DIRECT_REQUIRES_ADDRESS=true # Require payout address before playing
DIRECT_ALLOW_GUEST_PLAY=false # Allow play without payout address
# Credits mode settings
# Direct mode payouts
# ⚠️ Requires PRIMARY_WALLET_ENDPOINT to be configured!
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0 # Multiplier for escaping the dungeon
DIRECT_PAYOUT_TREASURE=3.0 # Multiplier for finding treasure
# -----------------------------------------------------------------------------
# Credits Mode (buy packages, spend per game)
# -----------------------------------------------------------------------------
CREDITS_ENABLED=true
CREDITS_PER_GAME=1
CREDITS_REQUIRES_ADDRESS=true
ALLOW_MIXED_MODE=true
PREFER_CREDITS_FIRST=true
CREDITS_PER_GAME=1 # Credits consumed per game
CREDITS_REQUIRES_ADDRESS=true # Require payout address for credits mode
ALLOW_MIXED_MODE=true # Allow both direct and credits options
PREFER_CREDITS_FIRST=true # Auto-use credits if available
# Credits mode payouts (usually disabled - credits are a bulk discount)
# ⚠️ If enabled, requires PRIMARY_WALLET_ENDPOINT!
CREDITS_PAYOUTS_ENABLED=false
CREDITS_PAYOUT_BASE=50000000000 # 0.5 WOW per credit payout baseline
CREDITS_PAYOUT_BASE=50000000000 # Base payout per credit win
CREDITS_PAYOUT_ESCAPE=1.5
CREDITS_PAYOUT_TREASURE=2.0
# Credit packages (JSON array)
# Each package: { id, credits, price (atomic units string), bonus (extra free credits) }
# ⚠️ Must be valid JSON! Server validates at startup.
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
# -----------------------------------------------------------------------------
# Global Payout Settings
# -----------------------------------------------------------------------------
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
PAYOUT_MIN_AMOUNT=10000000000 # 0.1 WOW minimum payout
PAYOUT_MAX_PER_GAME=10000000000000 # Max payout per single game
PAYOUT_BATCH_INTERVAL=300 # Seconds between payout batches (5 min)
PAYOUT_MAX_RETRIES=3 # Retry failed payouts
MAX_PAYOUT_BATCH_SIZE=50 # Max payouts per batch
# Anti-abuse limits
# -----------------------------------------------------------------------------
# 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.
# Wallet RPC (required for paid modes)
# -----------------------------------------------------------------------------
# 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
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 (required for paid modes)
# Run wownero-wallet-rpc or monero-wallet-rpc with --rpc-bind-port
# ⚠️ Without this, payments and payouts will fail!
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
# WALLET_RPC_USER=user
# WALLET_RPC_PASSWORD=password
# Blockchain RPC Configuration (daemon URLs)
# -----------------------------------------------------------------------------
# Blockchain RPC (daemon)
# -----------------------------------------------------------------------------
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
RPC_POLL_INTERVAL=2000
RPC_POLL_INTERVAL=2000 # Milliseconds between block polls
# Database Configuration
# -----------------------------------------------------------------------------
# Database (PostgreSQL)
# -----------------------------------------------------------------------------
DB_HOST=localhost
DB_PORT=5432
DB_NAME=wownerogue
DB_USER=jw
DB_PASSWORD=jw
DB_USER=your_db_user
DB_PASSWORD=your_db_password
# Server Configuration
# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
PORT=3000
NODE_ENV=production
# Block Height Source & Debug Controls
# BLOCK_SOURCE=daemon -> always use real blockchain height (default for paid modes)
# BLOCK_SOURCE=simulated -> force simulated blocks (development)
# SIMULATED_BLOCKS=true/false overrides automatic detection (if set)
# FORCE_SIMULATED_BLOCKS=true will force simulation even in paid modes (for local testing)
# Examples:
# BLOCK_SOURCE=daemon
# BLOCK_SOURCE=simulated
# SIMULATED_BLOCKS=false
# FORCE_SIMULATED_BLOCKS=true
# -----------------------------------------------------------------------------
# Admin API
# -----------------------------------------------------------------------------
# Required for admin endpoints: /api/admin/refund/payment, /api/admin/credits/adjust, etc.
# Generate a secure key: openssl rand -hex 32
# Pass as X-Admin-Key header when calling admin endpoints.
# ADMIN_API_KEY=your-secure-random-key-here
# -----------------------------------------------------------------------------
# Block Simulation (Development Only)
# -----------------------------------------------------------------------------
# BLOCK_SOURCE: daemon (real blockchain) | simulated (fake blocks for testing)
# In paid modes, defaults to daemon. In FREE mode, defaults to simulated.
BLOCK_SOURCE=daemon
# SIMULATED_BLOCKS=false
# FORCE_SIMULATED_BLOCKS=false
# -----------------------------------------------------------------------------
# Legacy Configuration (deprecated, kept for compatibility)
# -----------------------------------------------------------------------------
# GAME_MODE: FREE | PAID_SINGLE | PAID_CREDITS
# If set, overrides PAYMENT_MODES. Prefer using PAYMENT_MODES instead.
# GAME_MODE=
# Legacy prices (used if PAYMENT_MODES not set)
CRYPTO_TYPE=WOW
# SINGLE_GAME_PRICE=1e11
# CREDITS_PACKAGE_PRICE=5e11
+44 -8
View File
@@ -1,6 +1,6 @@
/**
* Environment Validator
* Emits friendly warnings when payment related environment variables look suspicious.
* Validates payment configuration and emits warnings for suspicious settings.
*/
class EnvironmentValidator {
@@ -12,34 +12,70 @@ class EnvironmentValidator {
const warnings = [];
const errors = [];
// Check for conflicting legacy/new config
if (process.env.GAME_MODE && process.env.PAYMENT_MODES) {
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
}
// Wallet RPC required for payments
if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('paymentsEnabled is true but PRIMARY_WALLET_ENDPOINT is not set. Wallet RPC may fail.');
warnings.push('Payments enabled but PRIMARY_WALLET_ENDPOINT not set. Wallet RPC will fail.');
}
// Blockchain RPC required for payments
if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) {
warnings.push('paymentsEnabled is true but PRIMARY_RPC_ENDPOINT is missing. Blockchain RPC is required.');
warnings.push('Payments enabled but PRIMARY_RPC_ENDPOINT missing. Block sync will fail.');
}
// Direct mode price validation
if (config.modes.direct.enabled && config.modes.direct.price <= 0n) {
errors.push('Direct payment mode is enabled but direct price is not positive.');
errors.push('Direct payment mode enabled but price is not positive.');
}
// Credits mode package validation
if (config.modes.credits.enabled && !config.modes.credits.packages.length) {
errors.push('Credits mode is enabled but no credit packages are defined.');
errors.push('Credits mode enabled but no credit packages 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.');
// Validate CREDITS_PACKAGES JSON format
if (process.env.CREDITS_PACKAGES) {
try {
const parsed = JSON.parse(process.env.CREDITS_PACKAGES);
if (!Array.isArray(parsed)) {
errors.push('CREDITS_PACKAGES must be a JSON array.');
} else if (parsed.length === 0) {
warnings.push('CREDITS_PACKAGES is an empty array. Using defaults.');
} else {
// Validate each package has required fields
parsed.forEach((pkg, i) => {
if (!pkg.id) warnings.push(`CREDITS_PACKAGES[${i}] missing "id" field.`);
if (!pkg.credits && pkg.credits !== 0) warnings.push(`CREDITS_PACKAGES[${i}] missing "credits" field.`);
if (!pkg.price) warnings.push(`CREDITS_PACKAGES[${i}] missing "price" field.`);
});
}
} catch (e) {
errors.push(`CREDITS_PACKAGES is not valid JSON: ${e.message}`);
}
} else if (config.modes.credits.enabled) {
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Set CREDITS_PACKAGES for production.');
}
// Warn if direct payouts enabled but no wallet RPC
if (config.payouts.rules.direct.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('DIRECT_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.');
}
// Warn if credits payouts enabled but no wallet RPC
if (config.payouts.rules.credits.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('CREDITS_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.');
}
// Info about FREE mode
if (!config.paymentsEnabled) {
this.logger.info?.('ℹ️ Payments are currently disabled. Server will run in FREE mode.');
this.logger.info?.('ℹ️ Payments disabled. Server running in FREE mode.');
}
// Emit warnings and errors
if (warnings.length) {
warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`));
}
+391
View File
@@ -36,6 +36,7 @@ const debugManager = new DebugManager(broadcastManager);
// Initialize payment system components (debugManager is now available)
const databaseManager = new DatabaseManager();
const db = databaseManager; // Alias for convenience in API endpoints
const rpcService = new RpcService();
const walletRPCService = new WalletRPCService(debugManager);
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
@@ -276,6 +277,396 @@ app.get('/api/user/:socketId/payment-options', asyncHandler(async (req, res) =>
}
}));
// Get payment history for a user
app.get('/api/user/:socketId/payments', asyncHandler(async (req, res) => {
const { socketId } = req.params;
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
const offset = parseInt(req.query.offset, 10) || 0;
if (!socketId) {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId parameter is required.'
});
}
try {
const user = await gameModeManager.getOrCreateUser(socketId);
const result = await db.query(`
SELECT
id,
payment_type,
expected_amount,
status,
credits_purchased,
created_at,
confirmed_at,
description
FROM payments
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, [user.id, limit, offset]);
const countResult = await db.query(
`SELECT COUNT(*) as total FROM payments WHERE user_id = $1`,
[user.id]
);
res.json({
payments: result.rows.map(row => ({
id: row.id,
type: row.payment_type,
amount: row.expected_amount,
amountFormatted: gameModeManager.formatAtomicHuman(row.expected_amount, 4),
status: row.status,
creditsReceived: row.credits_purchased || 0,
createdAt: row.created_at,
confirmedAt: row.confirmed_at,
description: row.description
})),
total: parseInt(countResult.rows[0].total, 10),
limit,
offset,
currency: gameModeManager.cryptoType
});
} catch (error) {
throw new AppError('Failed to retrieve payment history', {
statusCode: 500,
safeMessage: 'Unable to retrieve payment history.',
cause: error
});
}
}));
// Get payout history for a user
app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
const { socketId } = req.params;
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
const offset = parseInt(req.query.offset, 10) || 0;
if (!socketId) {
throw new ValidationError('Missing socketId', {
safeMessage: 'socketId parameter is required.'
});
}
try {
const user = await gameModeManager.getOrCreateUser(socketId);
const result = await db.query(`
SELECT
p.id,
p.amount,
p.multiplier,
p.reason,
p.status,
p.tx_hash,
p.created_at,
p.processed_at,
g.outcome as game_outcome
FROM payouts p
LEFT JOIN games g ON p.game_id = g.id
WHERE p.user_id = $1
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3
`, [user.id, limit, offset]);
const countResult = await db.query(
`SELECT COUNT(*) as total FROM payouts WHERE user_id = $1`,
[user.id]
);
// Calculate total received (confirmed payouts)
const totalResult = await db.query(
`SELECT COALESCE(SUM(amount), 0) as total_received FROM payouts WHERE user_id = $1 AND status = 'confirmed'`,
[user.id]
);
res.json({
payouts: result.rows.map(row => ({
id: row.id,
amount: row.amount,
amountFormatted: gameModeManager.formatAtomicHuman(row.amount, 4),
multiplier: parseFloat(row.multiplier) || 0,
reason: row.reason,
status: row.status,
txHash: row.tx_hash,
createdAt: row.created_at,
processedAt: row.processed_at,
gameOutcome: row.game_outcome
})),
total: parseInt(countResult.rows[0].total, 10),
totalReceived: parseInt(totalResult.rows[0].total_received, 10),
totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4),
limit,
offset,
currency: gameModeManager.cryptoType
});
} catch (error) {
throw new AppError('Failed to retrieve payout history', {
statusCode: 500,
safeMessage: 'Unable to retrieve payout history.',
cause: error
});
}
}));
// =============================================================================
// Admin API Endpoints (requires ADMIN_API_KEY)
// =============================================================================
/**
* Admin authentication middleware
* Requires X-Admin-Key header to match ADMIN_API_KEY env variable
*/
const adminAuth = (req, res, next) => {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return res.status(503).json({
error: 'Admin API not configured',
message: 'Set ADMIN_API_KEY environment variable to enable admin endpoints.'
});
}
const providedKey = req.headers['x-admin-key'];
if (!providedKey || providedKey !== adminKey) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or missing X-Admin-Key header.'
});
}
next();
};
/**
* POST /api/admin/refund/payment
* Refund a payment - marks it as refunded and optionally sends funds back
* Body: { paymentId: number, reason?: string, sendFunds?: boolean }
*/
app.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) => {
const { paymentId, reason, sendFunds } = req.body || {};
if (!paymentId || typeof paymentId !== 'number') {
throw new ValidationError('Invalid paymentId', {
safeMessage: 'paymentId (number) is required.'
});
}
// Get payment record
const paymentResult = await db.query(`
SELECT p.*, u.payout_address, u.socket_id
FROM payments p
LEFT JOIN users u ON p.user_id = u.id
WHERE p.id = $1
`, [paymentId]);
if (paymentResult.rows.length === 0) {
throw new NotFoundError('Payment not found', {
safeMessage: `Payment ${paymentId} not found.`
});
}
const payment = paymentResult.rows[0];
if (payment.status === 'refunded') {
return res.json({
success: false,
message: 'Payment already refunded.',
payment: { id: payment.id, status: payment.status }
});
}
// Begin transaction
const client = await db.getClient();
try {
await client.query('BEGIN');
// Mark payment as refunded
await client.query(`
UPDATE payments
SET status = 'refunded',
description = COALESCE(description, '') || ' | Refunded: ' || $2
WHERE id = $1
`, [paymentId, reason || 'Admin refund']);
// If credits were purchased, deduct them
let creditsDeducted = 0;
if (payment.credits_purchased > 0 && payment.user_id) {
const userResult = await client.query(`
SELECT credits FROM users WHERE id = $1
`, [payment.user_id]);
const currentCredits = userResult.rows[0]?.credits || 0;
creditsDeducted = Math.min(payment.credits_purchased, currentCredits);
if (creditsDeducted > 0) {
const newBalance = currentCredits - creditsDeducted;
await client.query(`
UPDATE users SET credits = $1 WHERE id = $2
`, [newBalance, payment.user_id]);
// Record credit transaction
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, $3, $4, 'refund')
`, [payment.user_id, -creditsDeducted, `Refund for payment ${paymentId}`, newBalance]);
}
}
await client.query('COMMIT');
// Optionally send funds back via wallet RPC
let fundsSent = false;
let txHash = null;
if (sendFunds && payment.payout_address && payment.expected_amount > 0) {
try {
if (walletRPCService && typeof walletRPCService.sendPayment === 'function') {
const result = await walletRPCService.sendPayment(
payment.payout_address,
payment.expected_amount
);
fundsSent = true;
txHash = result?.tx_hash || null;
}
} catch (err) {
console.error('Failed to send refund funds:', err.message);
// Don't fail the request - refund is recorded, funds can be sent manually
}
}
res.json({
success: true,
message: 'Payment refunded successfully.',
refund: {
paymentId,
originalAmount: payment.expected_amount,
originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4),
creditsDeducted,
fundsSent,
txHash,
reason: reason || 'Admin refund'
}
});
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}));
/**
* POST /api/admin/credits/adjust
* Adjust a user's credit balance (add or subtract)
* Body: { socketId: string, amount: number, reason?: string }
* amount can be positive (add) or negative (subtract)
*/
app.post('/api/admin/credits/adjust', adminAuth, asyncHandler(async (req, res) => {
const { socketId, amount, reason } = req.body || {};
if (!socketId || typeof socketId !== 'string') {
throw new ValidationError('Invalid socketId', {
safeMessage: 'socketId (string) is required.'
});
}
if (typeof amount !== 'number' || amount === 0) {
throw new ValidationError('Invalid amount', {
safeMessage: 'amount (non-zero number) is required. Positive to add, negative to subtract.'
});
}
// Get user
const user = await gameModeManager.getOrCreateUser(socketId);
if (!user) {
throw new NotFoundError('User not found', {
safeMessage: `User with socket ${socketId} not found.`
});
}
const currentCredits = user.credits || 0;
const newBalance = Math.max(0, currentCredits + amount); // Prevent negative balance
const actualAdjustment = newBalance - currentCredits;
if (actualAdjustment === 0) {
return res.json({
success: false,
message: 'No adjustment made (would result in same balance).',
user: { credits: currentCredits }
});
}
// Update credits
await db.query(`
UPDATE users SET credits = $1, updated_at = NOW() WHERE id = $2
`, [newBalance, user.id]);
// Record transaction
const transactionType = actualAdjustment > 0 ? 'admin_credit' : 'admin_debit';
await db.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, $3, $4, $5)
`, [user.id, actualAdjustment, reason || 'Admin adjustment', newBalance, transactionType]);
// Notify user if connected
try {
io.to(socketId).emit('credits_update', {
balance: newBalance,
creditsPerGame: gameModeManager.creditsPerGameCost
});
} catch (_) {}
res.json({
success: true,
message: `Credits ${actualAdjustment > 0 ? 'added' : 'deducted'} successfully.`,
adjustment: {
userId: user.id,
previousBalance: currentCredits,
adjustment: actualAdjustment,
newBalance,
reason: reason || 'Admin adjustment'
}
});
}));
/**
* GET /api/admin/users/search
* Search for users by socket ID prefix or payout address
* Query: ?q=searchterm&limit=20
*/
app.get('/api/admin/users/search', adminAuth, asyncHandler(async (req, res) => {
const { q, limit = 20 } = req.query;
if (!q || q.length < 3) {
throw new ValidationError('Invalid search query', {
safeMessage: 'Search query must be at least 3 characters.'
});
}
const searchLimit = Math.min(parseInt(limit, 10) || 20, 100);
const result = await db.query(`
SELECT id, socket_id, payout_address, credits, total_games_played, total_credits_purchased, created_at
FROM users
WHERE socket_id ILIKE $1 OR payout_address ILIKE $1
ORDER BY created_at DESC
LIMIT $2
`, [`%${q}%`, searchLimit]);
res.json({
users: result.rows.map(u => ({
id: u.id,
socketId: u.socket_id,
payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 10)}...` : null,
credits: u.credits || 0,
gamesPlayed: u.total_games_played || 0,
createdAt: u.created_at
})),
total: result.rows.length
});
}));
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
+3 -2
View File
@@ -222,9 +222,10 @@ class ConnectionHandler {
});
}
// Credits convenience push
// Credits convenience push (include creditsPerGame for games remaining calculation)
this.io.to(socket.id).emit('credits_update', {
balance: sessionInfo.user.credits || 0
balance: sessionInfo.user.credits || 0,
creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1
});
} else {
// fallback legacy welcome