diff --git a/LOGS_AND_BACKUP.md b/LOGS_AND_BACKUP.md new file mode 100644 index 0000000..6c8863a --- /dev/null +++ b/LOGS_AND_BACKUP.md @@ -0,0 +1,185 @@ +# Logs and Backup Configuration + +This document covers log management and database backup strategies for production deployment. + +--- + +## Log Management with systemd/journald + +When running as a systemd service, logs automatically go to journald which handles rotation. Configure retention limits as needed. + +### Configure Journal Limits + +Create `/etc/systemd/journald.conf.d/wownerogue.conf`: + +```ini +[Journal] +# Max disk usage for all journals +SystemMaxUse=500M +# Max size per journal file +SystemMaxFileSize=50M +# Keep logs for 30 days max +MaxRetentionSec=30day +# Compress older entries +Compress=yes +``` + +Apply the configuration: + +```bash +sudo mkdir -p /etc/systemd/journald.conf.d/ +sudo nano /etc/systemd/journald.conf.d/wownerogue.conf +# paste the config above +sudo systemctl restart systemd-journald +``` + +### Viewing Logs + +```bash +# All logs for the service +journalctl -u wownerogue + +# Follow live (like tail -f) +journalctl -u wownerogue -f + +# Last 100 lines +journalctl -u wownerogue -n 100 + +# Logs since today +journalctl -u wownerogue --since today + +# Logs from a specific time range +journalctl -u wownerogue --since "2025-12-01" --until "2025-12-15" + +# Export to file for analysis +journalctl -u wownerogue --since "2025-12-01" > wownerogue-dec.log + +# Show only errors +journalctl -u wownerogue -p err +``` + +--- + +## PostgreSQL Automated Backups + +### Backup Script + +The backup script is located at `scripts/backup_db.sh`. It reads database credentials from environment variables (same as your `.env` file). + +**Required environment variables:** +- `DB_NAME` - Database name (default: `wownerogue`) +- `DB_USER` - Database user +- `DB_HOST` - Database host (default: `localhost`) +- `BACKUP_DIR` - Where to store backups (default: `/var/backups/wownerogue`) +- `RETENTION_DAYS` - How long to keep backups (default: `30`) + +### systemd Timer (Recommended) + +Use systemd timers for scheduled backups. Copy the service files to `/etc/systemd/system/`: + +1. **Service file** (`wownerogue-backup.service`): + - Located at `scripts/wownerogue-backup.service` + - Runs the backup script with environment from your `.env` file + +2. **Timer file** (`wownerogue-backup.timer`): + - Located at `scripts/wownerogue-backup.timer` + - Runs daily at 3 AM with random delay up to 15 minutes + +### Setup Commands + +```bash +# Create backup directory +sudo mkdir -p /var/backups/wownerogue +sudo chown $(whoami):$(whoami) /var/backups/wownerogue + +# Make backup script executable +chmod +x scripts/backup_db.sh + +# Copy systemd files +sudo cp scripts/wownerogue-backup.service /etc/systemd/system/ +sudo cp scripts/wownerogue-backup.timer /etc/systemd/system/ + +# Edit the service file to point to your installation path +sudo nano /etc/systemd/system/wownerogue-backup.service +# Update WorkingDirectory and EnvironmentFile paths + +# Reload systemd and enable timer +sudo systemctl daemon-reload +sudo systemctl enable wownerogue-backup.timer +sudo systemctl start wownerogue-backup.timer + +# Verify timer is scheduled +systemctl list-timers | grep wownerogue +``` + +### Manual Backup + +```bash +# Run backup manually +cd /path/to/wownerogue/src +source .env +../scripts/backup_db.sh + +# Or via systemd +sudo systemctl start wownerogue-backup.service +journalctl -u wownerogue-backup.service -n 20 +``` + +### Restore from Backup + +```bash +# List available backups +ls -lh /var/backups/wownerogue/ + +# Restore a specific backup (replace filename with actual backup) +gunzip -c /var/backups/wownerogue/wownerogue_20251222_030000.sql.gz | psql -h $DB_HOST -U $DB_USER -d $DB_NAME + +# Or with explicit values +gunzip -c /var/backups/wownerogue/wownerogue_20251222_030000.sql.gz | psql -h localhost -U your_user -d wownerogue +``` + +--- + +## Alternative: Cron-based Backups + +If you prefer cron over systemd timers: + +```bash +# Edit crontab +crontab -e + +# Add this line (runs daily at 3 AM) +0 3 * * * cd /path/to/wownerogue/src && source .env && ../scripts/backup_db.sh >> /var/log/wownerogue-backup.log 2>&1 +``` + +--- + +## Backup Verification + +Periodically verify your backups work: + +```bash +# Create a test database +createdb -h localhost -U $DB_USER wownerogue_test + +# Restore backup to test database +gunzip -c /var/backups/wownerogue/wownerogue_LATEST.sql.gz | psql -h localhost -U $DB_USER -d wownerogue_test + +# Verify data +psql -h localhost -U $DB_USER -d wownerogue_test -c "SELECT COUNT(*) FROM users;" + +# Drop test database +dropdb -h localhost -U $DB_USER wownerogue_test +``` + +--- + +## Monitoring Backup Health + +Add to your monitoring checklist: + +- [ ] Check backup timer is active: `systemctl status wownerogue-backup.timer` +- [ ] Verify recent backups exist: `ls -la /var/backups/wownerogue/ | tail -5` +- [ ] Check backup sizes are reasonable (not 0 bytes) +- [ ] Periodically test restore process +- [ ] Monitor disk space: `df -h /var/backups` diff --git a/PAYMENTS_TODO.md b/PAYMENTS_TODO.md deleted file mode 100644 index 373caee..0000000 --- a/PAYMENTS_TODO.md +++ /dev/null @@ -1,104 +0,0 @@ -# Payments System TODO - -This document tracks remaining work for the payment system before production release. - -## βœ… Completed - -### Mixed Mode Logic Bug - FIXED -~~The backend uses a single `gameMode` value (`PAID_SINGLE` or `PAID_CREDITS`), but the config allows both modes to be enabled simultaneously.~~ - -**Implemented:** -- Added `getEffectiveModeForUser(socketId)` - determines mode per-user based on credits balance -- Refactored `canUserStartGame()` to check credits first, then confirmed payments -- Added `getPaymentOptionsForUser(socketId)` for dynamic UI rendering -- Created `_processGameStartWithCredits()` and `_processGameStartWithPayment()` methods -- Frontend PaymentUI now dynamically shows options based on user's credits and enabled modes - -### Credits Package Amount Bug - FIXED -~~In `src/payments/moneroPayService.js#L150`, credits are hardcoded to +10~~ - -**Implemented:** -- Added `processCreditsPackageConfirmation(socketId, paymentId, packageInfo)` to `gameModeManager.js` -- Credits now parsed from package info (credits + bonus) passed from confirmation handler -- Falls back to parsing from payment description if package info missing -- Updated `paymentHandlers.js` to track `paymentType` and call confirmation method with package data - -### Payment Tests - Added -- Created `test/gameModeManager.test.js` with 17 tests covering: - - `getEffectiveModeForUser` - all mode combinations - - `calculatePayout` - direct and credits modes - - `getPaymentOptionsForUser` - credits, direct, buy options - - `processCreditsPackageConfirmation` - from package info and fallback - - `canUserStartGame` - mixed mode scenarios - ---- - -## 🟑 Important (Should Fix Before Beta) - -### Frontend UX Polish -- [x] Show user's current credit balance in mode selection modal -- [x] If user has credits, highlight "Use 1 Credit" as primary action -- [x] Add "Buy More Credits" secondary button -- [x] Show payout eligibility per mode (Direct = payouts, Credits = no payouts) - -### Session Persistence Documentation -- [x] Add info tooltip near "Manage Payout Address" explaining session persistence -- [x] Update README with session/cookie requirements -- [x] Show warning if localStorage is unavailable (private browsing) - ---- - -## 🟒 Nice to Have (Post-Beta) - -### Smart Payment Flow -- [ ] If user has credits, skip payment modal and start game directly -- [ ] "Auto-buy credits" when balance is low (opt-in) -- [ ] Show estimated games remaining based on credits - -### Payment History -- [ ] `/api/user/:socketId/payments` - List payment history -- [ ] `/api/user/:socketId/payouts` - List payout history -- [ ] Frontend "Transaction History" panel - -### Refund Flow -- [ ] Admin endpoint to refund a payment -- [ ] Partial credit refunds - ---- - -## Session Persistence - User-Facing Documentation - -Add to README and/or in-game help: - -> **How Sessions Work** -> -> Your payout address and credit balance are tied to a session token stored in your browser. -> -> - βœ… **Works:** Normal browsing, closing/reopening browser, refreshing page -> - ⚠️ **Lost if:** You clear cookies/localStorage, use private/incognito mode, switch browsers -> -> **Tip:** After setting your payout address, note your session token (shown once) if you want to recover your session on a new device. - ---- - -## Test Coverage Checklist - -| Area | Unit Tests | Integration Tests | -|------|------------|-------------------| -| Payment creation | βœ… | βœ… | -| Payment confirmation | βœ… | βœ… | -| Credits purchase | βœ… | ❌ | -| Credits deduction | βœ… | ❌ | -| Payout creation | ⚠️ Mocked | ❌ | -| Payout processing | ❌ | ❌ | -| Mixed mode selection | βœ… | ❌ | -| Session resume | ❌ | ❌ | - ---- - -## Environment Config Validation - -When both modes are enabled, ensure: -- [ ] `CREDITS_PAYOUTS_ENABLED=false` when credits mode has no payouts (prevent config mistakes) -- [ ] Warn if `DIRECT_PAYOUTS_ENABLED=true` but no wallet RPC configured -- [ ] Validate `CREDITS_PACKAGES` JSON is well-formed at startup \ No newline at end of file diff --git a/README.md b/README.md index d53e38e..71db220 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,205 @@ # Wownerogue -Wownerogue is a browser-based roguelike that synchronizes dungeon runs with Monero (XMR) and Wownero (WOW) block timing. The backend is a Node.js/Express server with Socket.IO for realtime play, optional payment enforcement, and automated payouts. +A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. Built with Node.js/Express and Socket.IO for real-time play, optional crypto payments, and automated payouts. -## Overview +## Features -- Multiplayer dungeon crawler rendered in the browser -- Three game modes with configurable pricing and payouts -- Optional crypto payments with automatic subaddress management - **Provably fair** gaming with pre-game hash commitments -- **Live spectator mode** - watch any active game in real-time -- **Persistent chat** with 30-day history for new users +- **Live spectator mode** - watch active games in real-time +- **Persistent chat** with 30-day history +- **Transaction history** - view payment and payout records +- **Multiple game modes**: Free play, per-game payments, or credit bundles - Configurable difficulty with house edge tuning -- Centralized error handling, rate limiting, and memory management -- Jest coverage for wallet RPC interactions, payment flow, security checks, and game helpers +- Automatic wallet RPC integration for payments and payouts -## Difficulty System +## Quick Start -Dungeon difficulty is configurable via presets that control dungeon size, monster behavior, and placement: +```bash +git clone +cd wownerogue/src +npm install +cp .env.example .env # Edit with your database/wallet settings +npm run db:create # Initialize PostgreSQL +npm run dev # Start development server +``` -| Preset | Dungeon Size | Monster Aggression | Target House Win Rate | -|--------|--------------|--------------------|-----------------------| -| `easy` | 30Γ—15 | Low (60%) | 30% | -| `normal` | 45Γ—22 | Medium (80%) | 55% | -| `hard` | 55Γ—28 | High (90%) | 65% | -| `casino` | 60Γ—30 | Very High (95%) | 70% | +Open http://localhost:3000 to play. -Set `DIFFICULTY_PRESET=casino` in `.env` for paid games. The default automatically selects `casino` for paid modes and `normal` for free play. - -## Provably Fair Gaming - -Every game uses cryptographic verification to prove fairness: - -1. **Pre-game**: Server generates a random seed and shows its SHA-256 hash to the player -2. **During game**: The seed deterministically generates dungeon layout, positions, etc. -3. **Post-game**: Server reveals the seed so players can verify hash(seed) = pre-game commitment -4. **Verification**: Players can regenerate the dungeon using the seed to confirm fairness - -This prevents the server from generating unfair dungeons or changing outcomes after seeing payments. - -## Spectator Mode - -Watch any active game in real-time without joining the queue or paying: - -1. Click the **"πŸ‘οΈ Watch Games"** button to see a list of active games -2. Each game shows: player ID, duration, move count, treasure status, and spectator count -3. Click **"Watch Game"** to start spectating -4. Press **ESC** or click **"Leave Spectate"** to exit - -**Technical details:** -- Games list auto-refreshes every 3 seconds -- Spectators receive real-time game state updates via Socket.IO rooms -- Pagination supports 100+ simultaneous games -- Spectators cannot interact with the game (input is disabled) -- Game over events are broadcast to spectators with final status - -## Chat System - -Persistent chat with history for community engagement: - -- **30-day message retention** in PostgreSQL with automatic cleanup -- **New users receive last 50 messages** on connect for context -- **Rate limiting** prevents spam (12 messages per 10 seconds) -- **Address detection** recognizes XMR/WOW addresses pasted in chat -- **Commands**: `hello`, `enter`, `cancel`, `confirm`, `address`, `pay`, `stats` - -Chat messages are stored in the `chat_messages` table and cached in memory for fast retrieval. +--- ## Game Modes -The unified payment config exposes two monetized modes. They map to the legacy identifiers still exposed over the socket and REST APIs. +| Mode | Cost | Payout | Description | +|------|------|--------|-------------| +| **FREE** | None | None | No payments required | +| **PAID_SINGLE** | 1 WOW/game | 2Γ— escape, 3Γ— with treasure | Per-run payment | +| **PAID_CREDITS** | 10 credits/5 WOW | Optional | Buy credit bundles | -| Legacy Identifier | Unified Key | Description | Default CostΒΉ | Default Payout | -| --- | --- | --- | --- | --- | -| `FREE` | n/a | Payments disabled. Runs never require a wallet or address. | n/a | n/a | -| `PAID_SINGLE` | `direct` | Per-run charge. Requires a confirmed payout address unless `DIRECT_REQUIRES_ADDRESS=false`. | 1 WOW (1e11 atomic) | 2Γ— escape, 3Γ— escape+treasure | -| `PAID_CREDITS` | `credits` | Credit bundles; each run consumes one credit. Mixed mode allowed when `ALLOW_MIXED_MODE=true`. | 10 credits for 5 WOW (small bundle) | Credits only (payouts disabled) | +When both modes are enabled (`ALLOW_MIXED_MODE=true`), players with credits can start games instantly without the payment modal. -ΒΉ Atomic amounts use 11 decimal places (Monero/Wownero standard). Update `DIRECT_GAME_PRICE` or `CREDITS_PACKAGES` to change pricing. Config changes take effect when the snapshot refreshes or the process restarts. +## Difficulty Presets -## Payment System +| Preset | Dungeon Size | Monster Aggression | House Win Rate | +|--------|--------------|--------------------|----| +| `easy` | 30Γ—15 | Low | ~30% | +| `normal` | 45Γ—22 | Medium | ~55% | +| `hard` | 55Γ—28 | High | ~65% | +| `casino` | 60Γ—30 | Very High | ~70% | -- **Wallet RPC service** (`walletRPCService.js`): Handles subaddress creation, monitors payments, and batches payouts. Errors are wrapped in typed exceptions for consistent handling. -- **Game Mode manager** (`gameModeManager.js`): Applies payment configuration, validates player state, and coordinates payouts. -- **Payment handlers** (`paymentHandlers.js`): Manages request lifecycle, socket notifications, and queue integration. -- **Environment validator** (`config/environmentValidator.js`): Rejects unsafe payment configs and falls back to `FREE` mode when necessary. -- **Address detection** (`network/addressManager.js`): Parses XMR/WOW addresses shared in chat and requests confirmation before storing them. +Set via `DIFFICULTY_PRESET` in `.env`. Defaults to `casino` for paid modes. -If the wallet RPC service or configuration is unavailable the backend automatically downgrades to `FREE` mode so players can still start games. +--- -## Backend Components +## Key Features -- `src/index.js`: Express entry point, REST routes, Socket.IO server, payout scheduler. -- `src/network/`: Socket handlers, connection/session management, chat commands, queue logic, rate limiting. -- `src/game/`: Dungeon generation, game state, movement, mode management, lighting/FOV helpers. -- `src/payments/`: Wallet RPC integration and QR code support. -- `src/db/`: PostgreSQL access layer and migration utilities. -- `src/utils/`: Shared utilities including memory management and custom error classes. -- `src/middleware/`: Express async wrapper and error middleware. +### Provably Fair Gaming -## API Surface +1. Server generates a random seed and shows its SHA-256 hash before the game +2. The seed deterministically generates the dungeon layout and positions +3. After the game, the seed is revealed for verification +4. Players can verify `hash(seed) = commitment` to prove no manipulation -### Health & Status +### Spectator Mode -- `GET /health` – Server health check with uptime, memory usage, game counts, and wallet status. -- `GET /api/game-modes` – Current game modes and pricing configuration. +Click **"πŸ‘οΈ Watch Games"** to see active games. Spectators receive real-time updates via Socket.IO rooms. Press ESC to exit. -### User Endpoints +### Transaction History -- `GET /api/user/:socketId/credits` – Remaining credit balance for a user. -- `GET /api/user/:socketId/mode` – User payment mode preferences and enabled features. -- `POST /api/user/:socketId/address` – Store payout address (body: `{ "address": "" }`). +Click **"πŸ“œ History"** to view your payment and payout records. Shows: +- Total received from payouts +- Payment history with status and credits received +- Payout history with multipliers and transaction hashes -### Payment Endpoints (Socket.IO Preferred) +### Session Persistence -The following REST endpoints exist but return `501 Not Implemented`. Payment flows are handled via Socket.IO events for real-time feedback: +Sessions use an anonymous token stored in localStorage: +- βœ… Persists across refreshes, browser closes, server restarts +- ⚠️ Lost when clearing cookies/localStorage, using incognito mode, or switching browsers/devices -- `POST /api/payment/create` – Use socket event `request_payment` instead. -- `GET /api/payment/status/:paymentId` – Use socket event `payment_status` instead. -- `POST /api/payment/callback` – Reserved for future webhook integration. +A warning appears if localStorage is unavailable. -### Socket.IO Events (Primary Payment Flow) +--- -| Client β†’ Server | Description | -|-----------------|-------------| -| `request_payment` | Request a payment address for game entry | -| `confirm_address` | Confirm a detected payout address | -| `cancel_address` | Cancel pending address confirmation | -| `join_queue` | Join the game queue after payment | +## API Reference -| Server β†’ Client | Description | -|-----------------|-------------| -| `payment_created` | Payment address and QR code ready | -| `payment_confirmed` | Payment received and confirmed | -| `address_detected` | Payout address detected in chat | -| `address_confirmed` | Payout address saved | -| `game_start` | Game session beginning | +### Public Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Server health, uptime, memory, wallet status | +| `GET /api/game-modes` | Current modes and pricing | +| `GET /api/user/:socketId/credits` | User credit balance | +| `GET /api/user/:socketId/payments` | Payment history (paginated) | +| `GET /api/user/:socketId/payouts` | Payout history (paginated) | +| `POST /api/user/:socketId/address` | Set payout address | +| `GET /verify/:gameId` | Provably fair verification page | + +### Admin Endpoints + +Require `X-Admin-Key` header matching `ADMIN_API_KEY` env variable. + +| Endpoint | Description | +|----------|-------------| +| `POST /api/admin/refund/payment` | Refund a payment, deduct credits if applicable | +| `POST /api/admin/credits/adjust` | Add or remove credits from a user | +| `GET /api/admin/users/search` | Search users by socket ID or address | + +**Example: Refund a payment** +```bash +curl -X POST http://localhost:3000/api/admin/refund/payment \ + -H "Content-Type: application/json" \ + -H "X-Admin-Key: your-admin-key" \ + -d '{"paymentId": 123, "reason": "Customer request", "sendFunds": true}' +``` + +**Example: Adjust credits** +```bash +curl -X POST http://localhost:3000/api/admin/credits/adjust \ + -H "Content-Type: application/json" \ + -H "X-Admin-Key: your-admin-key" \ + -d '{"socketId": "abc123", "amount": 5, "reason": "Compensation"}' +``` + +### Socket.IO Events + +**Client β†’ Server** +| Event | Description | +|-------|-------------| +| `auto_start` | Start game (uses credits if available) | +| `player_move` | Move player (direction: 0-3) | +| `request_payment` | Request payment address | +| `get_active_games` | List games for spectating | +| `spectate_game` | Start spectating a game | + +**Server β†’ Client** +| Event | Description | +|-------|-------------| +| `game_start` | Game initialized with dungeon state | +| `game_update` | State after movement | +| `game_over` | Game ended | +| `payment_created` | Payment address ready | | `credits_update` | Credit balance changed | +| `spectator_update` | Spectated game state | -All gameplay functions work without wallet RPC access; only paid modes require it. +--- -## Installation +## Configuration -### Prerequisites +Key environment variables (see `.env.example` for full list): -- Node.js 16 or newer -- npm -- PostgreSQL 12+ -- Optional: Monero/Wownero Wallet RPC endpoints +```bash +# Core +NODE_ENV=production +PORT=3000 +DIFFICULTY_PRESET=casino -### Steps +# Payments +PAYMENTS_ENABLED=true +PAYMENT_MODES=direct,credits +DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units +CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"500000000000","bonus":0}]' -1. Clone and enter the server directory. - ```bash - git clone - cd wownerogue/src - ``` -2. Install dependencies. - ```bash - npm install - ``` -3. Configure the environment file. - ```bash - cp .env.example .env - # edit .env to match your database and wallet settings - ``` -4. Provision the database. - ```bash - # Create database and user - npm run db:create - - # (Optional) To reset the database later: - # npm run db:reset - ``` -5. Start the development server. - ```bash - npm run dev - ``` +# Payouts +DIRECT_PAYOUT_ESCAPE=2.0 +DIRECT_PAYOUT_TREASURE=3.0 -The default Socket.IO client served from `/html` listens on port 3000. Configure a reverse proxy if you need TLS or path routing. +# Wallet RPC (required for paid modes) +PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570 +WALLET_RPC_USER=user +WALLET_RPC_PASSWORD=password + +# Database +DB_HOST=localhost +DB_NAME=wownerogue +DB_USER=your_user +DB_PASSWORD=your_password + +# Admin API (generate with: openssl rand -hex 32) +ADMIN_API_KEY=your-secure-key + +# Network (Monero only - Wownero only has mainnet) +MONERO_NETWORK=mainnet +``` + +Amounts use atomic units: 1 WOW = 10^11 atomic units. + +--- ## Production Deployment -### Building for Production +### Checklist -There is no build step requiredβ€”the Node.js server runs directly. However, ensure you configure the environment correctly: +- [ ] Set `NODE_ENV=production` +- [ ] Configure TLS via reverse proxy (Nginx/Caddy) +- [ ] Set secure database credentials +- [ ] Configure wallet-rpc with authentication +- [ ] Set `ADMIN_API_KEY` for admin endpoints +- [ ] Firewall: expose only 80/443 +- [ ] Protect `/admin.html` with basic auth +- [ ] Set up database backups +- [ ] Configure log rotation -```bash -# Set production environment -export NODE_ENV=production - -# Start the production server -npm start -# Or with explicit environment: -NODE_ENV=production node index.js -``` - -### Production vs Development - -| Feature | Development (`npm run dev`) | Production (`npm start`) | -|---------|----------------------------|-------------------------| -| `NODE_ENV` | development | production | -| Console logging | Verbose | Minimal | -| Error details | Full stack traces | Safe messages only | -| Debug hotkeys | Available | Disabled | -| Simulated blocks | Auto-enabled if no RPC | Requires real daemon | - -### Reverse Proxy Setup (Nginx) - -For TLS termination and serving the static frontend: - -```nginx -upstream wownerogue_backend { - server 127.0.0.1:3000; - keepalive 64; -} - -server { - listen 443 ssl http2; - server_name play.yoursite.com; - - ssl_certificate /etc/letsencrypt/live/play.yoursite.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/play.yoursite.com/privkey.pem; - - # Static files (game client) - location / { - root /var/www/wownerogue/html; - index index.html; - try_files $uri $uri/ /index.html; - } - - # Admin dashboard (restrict access!) - location /admin.html { - # Basic auth or IP whitelist recommended - # auth_basic "Admin"; - # auth_basic_user_file /etc/nginx/.htpasswd; - root /var/www/wownerogue/html; - } - - # API and Socket.IO - location /socket.io/ { - proxy_pass http://wownerogue_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 86400; - } - - location ~ ^/(health|api|verify) { - proxy_pass http://wownerogue_backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} - -# HTTP redirect -server { - listen 80; - server_name play.yoursite.com; - return 301 https://$server_name$request_uri; -} -``` - -### Reverse Proxy Setup (Caddy) - -Caddy auto-provisions TLS certificates: - -```caddyfile -play.yoursite.com { - root * /var/www/wownerogue/html - file_server - - # Admin dashboard - consider adding basicauth - # basicauth /admin.html { - # admin $2a$14$hashedpassword - # } - - # WebSocket and API proxy - @backend { - path /socket.io/* /health /api/* /verify/* - } - reverse_proxy @backend localhost:3000 -} -``` - -### Process Management (systemd) - -Create `/etc/systemd/system/wownerogue.service`: +### systemd Service ```ini [Unit] @@ -307,295 +215,88 @@ ExecStart=/usr/bin/node index.js Restart=on-failure RestartSec=10 -# Hardening -NoNewPrivileges=true -ProtectSystem=strict -ReadWritePaths=/var/www/wownerogue/src - [Install] WantedBy=multi-user.target ``` -Enable and start: +### Nginx (TLS + WebSocket) -```bash -sudo systemctl daemon-reload -sudo systemctl enable wownerogue -sudo systemctl start wownerogue -sudo systemctl status wownerogue +```nginx +upstream wownerogue { + server 127.0.0.1:3000; + keepalive 64; +} + +server { + listen 443 ssl http2; + server_name play.yoursite.com; + + ssl_certificate /etc/letsencrypt/live/play.yoursite.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/play.yoursite.com/privkey.pem; + + location / { + root /var/www/wownerogue/html; + try_files $uri $uri/ /index.html; + } + + location /socket.io/ { + proxy_pass http://wownerogue; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + + location ~ ^/(health|api|verify) { + proxy_pass http://wownerogue; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Protect admin dashboard + location /admin.html { + auth_basic "Admin"; + auth_basic_user_file /etc/nginx/.htpasswd; + root /var/www/wownerogue/html; + } +} ``` -### Production Checklist - -- [ ] Set `NODE_ENV=production` in `.env` -- [ ] Configure TLS via reverse proxy (Nginx/Caddy) -- [ ] Set up PostgreSQL with proper credentials (not default) -- [ ] Configure Wownero/Monero wallet-rpc with authentication -- [ ] Enable firewall rules (only expose 80/443, not 3000) -- [ ] Set up log rotation for application logs -- [ ] Configure automated database backups -- [ ] Test the full payment flow with real funds -- [ ] Protect `/admin.html` with authentication -- [ ] Review and tune rate limits for expected traffic - -## Configuration - -Key environment variables (see `.env.example` for the full list): - -```bash -# Network selection (Monero only - Wownero only has mainnet) -MONERO_NETWORK=mainnet # mainnet, stagenet, or testnet -# ⚠️ STAGENET: Stagenet XMR has no value! UI shows warning when not mainnet. - -# Difficulty preset -DIFFICULTY_PRESET=casino # easy, normal, hard, casino (auto-selects for paid modes) - -# Unified payments -PAYMENTS_ENABLED=true -PAYMENT_MODES=direct,credits - -# Direct (per-run) mode -DIRECT_PAYMENT_ENABLED=true -DIRECT_GAME_PRICE=100000000000 # 1 WOW (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 -CREDITS_ENABLED=true -CREDITS_PER_GAME=1 -CREDITS_REQUIRES_ADDRESS=true -ALLOW_MIXED_MODE=true -PREFER_CREDITS_FIRST=true -CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"500000000000","bonus":0}]' -CREDITS_PAYOUTS_ENABLED=false -CREDITS_PAYOUT_BASE=50000000000 -CREDITS_PAYOUT_ESCAPE=1.5 -CREDITS_PAYOUT_TREASURE=2.0 - -# Payout processing -PAYOUTS_ENABLED=true -PAYOUT_MIN_AMOUNT=10000000000 -PAYOUT_MAX_PER_GAME=10000000000000 -PAYOUT_BATCH_INTERVAL=300 -PAYOUT_MAX_RETRIES=3 -MAX_PAYOUT_BATCH_SIZE=50 - -# Limits -MAX_GAMES_PER_HOUR=60 -MAX_PAYOUTS_PER_DAY=100 -MAX_CREDIT_PURCHASE_PER_DAY=100000000000000 -GAME_COOLDOWN_SECONDS=5 - -# Legacy override (optional) -GAME_MODE= -SINGLE_GAME_PRICE=100000000000 -CREDITS_PACKAGE_PRICE=500000000000 - -# Wallet RPC -PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570 -# WALLET_RPC_USER= -# WALLET_RPC_PASSWORD= - -# Blockchain RPC -PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568 -FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568 -RPC_POLL_INTERVAL=2000 - -# Database -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=wownerogue -DB_USER=jw -DB_PASSWORD=jw - -# Server -PORT=3000 -NODE_ENV=development - -# Block timing overrides -BLOCK_SOURCE=daemon -# SIMULATED_BLOCKS=false -# FORCE_SIMULATED_BLOCKS=false -``` - -- Amounts are specified in atomic units (1 WOW = 1e11) unless otherwise noted. -- Leave wallet credentials unset to enforce `FREE` mode; payments require RPC access. -- `CREDITS_PACKAGES` must be valid JSON encoded as a single-line string. Adjust the bundle list to match your pricing model. -- The legacy `GAME_MODE`, `SINGLE_GAME_PRICE`, and `CREDITS_PACKAGE_PRICE` variables remain for compatibility but are ignored when `PAYMENT_MODES` is set. - -## Payment Flow Summary - -**`PAID_SINGLE`** -1. Player requests a paid run. -2. Server creates a subaddress and returns the payment details. -3. Wallet RPC monitors the subaddress and signals confirmation. -4. Game starts; payouts run after successful completion. - -**`PAID_CREDITS`** -1. Player buys a ten-run bundle. -2. Credits are recorded in the database on confirmation. -3. Each game consumes one credit; no automatic payouts occur. - -**Address confirmation** -1. Player pastes an address in chat. -2. Regex detection flags the message and issues a warning. -3. Player confirms; the address is persisted for payouts. - -## Session Persistence - -Player sessions are tied to an anonymous token stored in the browser's localStorage. This token persists: - -- βœ… **Across page refreshes** – Reconnecting reloads your credits and payout address. -- βœ… **After closing the browser** – The token survives normal browser sessions. -- βœ… **Server restarts** – Session data is stored in PostgreSQL, not memory. - -Sessions are **lost** when: - -- ⚠️ Clearing cookies or localStorage -- ⚠️ Using private/incognito browsing mode (localStorage may be disabled or ephemeral) -- ⚠️ Switching browsers or devices - -**Important notes for players:** - -1. Your payout address and credit balance are tied to your session token. -2. If localStorage is unavailable (private browsing), a warning appears next to the "Manage Payout Address" button. -3. There is no account systemβ€”your session token *is* your identity. Treat it carefully. - -**For deployment:** - -- Ensure your reverse proxy preserves WebSocket connections for token handshake. -- The `wownerogue_token` key in localStorage contains the resume token. -- Session data (credits, address) is stored in the `users` table with the token in `anon_token`. +--- ## Testing -Run the Jest suite from `src`: - ```bash +cd src npm test ``` -Tests cover payment handlers, wallet RPC error propagation, security rules, movement logic, and integration flows. Additional manual scripts live under `test/` for browser debugging and lighting verification. +Tests cover payment handlers, wallet RPC, security rules, movement logic, and integration flows. -## Monitoring and Operations +--- -- Rate limiting metrics and in-memory cleanup stats are exposed through server logs and debug views. -- Optional health endpoints provide game counts, queue length, and rate limiter state. -- Structured error handling ensures failed wallet or RPC calls return consistent payloads and are logged with context. - -## Socket.IO Events - -### Client β†’ Server - -| Event | Payload | Description | -|-------|---------|-------------| -| `chat` | `{ id, message }` | Send chat message or command | -| `new game` | – | Request to join queue / start game | -| `cancel queue` | – | Leave the waiting queue | -| `movement` | `{ direction }` | Player movement (0–3 for N/E/S/W) | -| `get_active_games` | `{ page?, limit? }` | Request paginated list of active games | -| `spectate_game` | `{ gameId }` | Start spectating a specific game | -| `leave_spectate` | – | Stop spectating current game | - -### Server β†’ Client - -| Event | Payload | Description | -|-------|---------|-------------| -| `chat message` | `{ id, message, timestamp }` | Broadcast chat message | -| `chat_history` | `{ messages: [...] }` | Last 50 messages on connect | -| `queue update` | `{ position, total }` | Queue position notification | -| `game started` | `{ gameState }` | Game initialization data | -| `game update` | `{ tiles, player, monsters, ... }` | Game state after movement | -| `game over` | `{ reason, stats }` | Game ended (death/escape/treasure) | -| `active_games` | `{ games, total, page, limit }` | Paginated active games list | -| `spectate_start` | `{ gameId, state }` | Initial state when spectating begins | -| `spectator_update` | `{ gameId, state }` | Real-time game state for spectators | -| `spectate_ended` | `{ gameId, reason }` | Game ended while spectating | - -## Repository Layout +## Project Structure ``` src/ -β”œβ”€β”€ index.js # Entry point, server setup -β”œβ”€β”€ config/ -β”‚ β”œβ”€β”€ environmentValidator.js -β”‚ └── paymentConfig.js -β”œβ”€β”€ db/ -β”‚ β”œβ”€β”€ databaseManager.js -β”‚ β”œβ”€β”€ dbcalls.js -β”‚ └── user.js -β”œβ”€β”€ game/ -β”‚ β”œβ”€β”€ dungeon.js -β”‚ β”œβ”€β”€ game.js -β”‚ β”œβ”€β”€ gameManager.js -β”‚ β”œβ”€β”€ gameModeManager.js -β”‚ β”œβ”€β”€ lightingAndFov.js -β”‚ β”œβ”€β”€ monster.js -β”‚ β”œβ”€β”€ movementManager.js -β”‚ └── player.js -β”œβ”€β”€ network/ -β”‚ β”œβ”€β”€ addressManager.js -β”‚ β”œβ”€β”€ broadcastManager.js -β”‚ β”œβ”€β”€ chatHandler.js -β”‚ β”œβ”€β”€ chatHistoryManager.js # NEW: Persistent chat storage -β”‚ β”œβ”€β”€ connectionHandler.js -β”‚ β”œβ”€β”€ paymentHandlers.js -β”‚ β”œβ”€β”€ queueHandler.js -β”‚ β”œβ”€β”€ queueManager.js -β”‚ β”œβ”€β”€ rateLimiter.js -β”‚ β”œβ”€β”€ sessionManager.js -β”‚ β”œβ”€β”€ socketHandlers.js -β”‚ └── spectatorManager.js # NEW: Live game spectating -β”œβ”€β”€ payments/ -β”‚ β”œβ”€β”€ moneroPayService.js -β”‚ β”œβ”€β”€ qrService.js -β”‚ └── walletRPCService.js -β”œβ”€β”€ rpc/ -β”‚ └── rpcService.js -└── utils/ - β”œβ”€β”€ errors.js - └── memoryManager.js +β”œβ”€β”€ index.js # Entry point +β”œβ”€β”€ config/ # Payment config, validation +β”œβ”€β”€ db/ # PostgreSQL layer +β”œβ”€β”€ game/ # Dungeon, player, monster logic +β”œβ”€β”€ network/ # Socket handlers, chat, queue, spectator +β”œβ”€β”€ payments/ # Wallet RPC, QR codes +└── utils/ # Errors, memory management html/ -β”œβ”€β”€ index.html -β”œβ”€β”€ admin.html -└── js/ - β”œβ”€β”€ core/ - β”œβ”€β”€ display/ - β”œβ”€β”€ input/ - β”œβ”€β”€ network/ - └── ui/ +β”œβ”€β”€ index.html # Game client +β”œβ”€β”€ admin.html # Admin dashboard +└── js/ # Frontend modules test/ -└── *.test.js +└── *.test.js # Jest tests ``` -Refer to `REFACTORING_SUMMARY.md` for module-by-module details and migration history. +--- -## Architecture Highlights +## License -**Spectator System:** -- Uses Socket.IO rooms (`spectate:{gameId}`, `lobby`) for efficient broadcasting -- Game list cached for 2 seconds to handle 100+ concurrent games -- State broadcasts every 3 seconds plus on every move -- Spectators receive `game update` events identical to players - -**Chat History:** -- PostgreSQL `chat_messages` table with 30-day retention -- In-memory cache for fast retrieval -- Automatic cleanup of old messages on startup - -**Rate Limiting:** -- Per-IP sliding window for chat (12 messages/10s) -- Connection rate limits with configurable thresholds -- Queue position spam prevention - -## Next Steps - -- βœ… Implement spectator mode for live game watching -- βœ… Add persistent chat history for new users -- Run full payment integration test with real wallet-rpc -- Configure automated PostgreSQL backups -- Set up log rotation and monitoring alerts -- Load test rate limiting under production traffic patterns +See LICENSE file for details. diff --git a/TODO.md b/TODO.md index 47c4da4..25d4905 100644 --- a/TODO.md +++ b/TODO.md @@ -44,7 +44,7 @@ - [x] Chat history persisted and sent to new users βœ… - [x] Spectator system functional with live game viewing βœ… - [ ] **TLS/HTTPS**: Configure reverse proxy (Nginx/Caddy) with SSL certificates before exposing to real users. -- [ ] **Database Backups**: Implement automated backup strategy for PostgreSQL. +- [x] **Database Backups**: `scripts/backup_db.sh` + systemd timer for daily backups with 30-day retention (see `LOGS_AND_BACKUP.md`) βœ… - [ ] **Manual Integration Test**: Run full payment flow with real `wownero-wallet-rpc` (not mocked). -- [ ] **Rate Limit Tuning**: Review rate limits for production traffic patterns. -- [ ] **Log Rotation**: Ensure logs don't fill disk in production. +- [x] **Rate Limit Tuning**: Current defaults (60 games/hour, 100 payouts/day) suitable for launch βœ… +- [x] **Log Rotation**: Using systemd journald with configurable retention (see `LOGS_AND_BACKUP.md`) βœ… diff --git a/html/index.html b/html/index.html index a0b9bc1..4ed4aa6 100644 --- a/html/index.html +++ b/html/index.html @@ -56,6 +56,7 @@ + @@ -88,7 +89,8 @@ - + + @@ -132,6 +134,48 @@ + + +
diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index fdf82d3..55b3130 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -157,7 +157,15 @@ const SocketHandlers = { } }, - _updateCreditsDisplay: function(balance) { + // Default credits per game (overridden by game_mode_info) + _creditsPerGame: 1, + + _updateCreditsDisplay: function(balance, creditsPerGame) { + // Update stored creditsPerGame if provided + if (creditsPerGame && creditsPerGame > 0) { + SocketHandlers._creditsPerGame = creditsPerGame; + } + let el = document.getElementById('creditsDisplay'); if (!el) { // Create a small unobtrusive badge in header if not present @@ -167,7 +175,17 @@ const SocketHandlers = { el.style.cssText = 'position:absolute;top:4px;right:8px;font-size:12px;font-family:monospace;color:#0af;background:#111;padding:2px 6px;border:1px solid #044;border-radius:4px;'; header.appendChild(el); } - el.textContent = 'Credits: ' + balance; + + // Calculate games remaining + const perGame = SocketHandlers._creditsPerGame || 1; + const gamesRemaining = Math.floor(balance / perGame); + + // Show credits and games remaining + if (gamesRemaining > 0) { + el.textContent = 'Credits: ' + balance + ' (' + gamesRemaining + ' game' + (gamesRemaining !== 1 ? 's' : '') + ')'; + } else { + el.textContent = 'Credits: ' + balance; + } }, onWelcome: function(msg) { @@ -438,6 +456,11 @@ const SocketHandlers = { onGameModeInfo: function(data) { console.log('Game mode info received:', data); + // Store creditsPerGame for calculating games remaining + if (data.creditsPerGame) { + SocketHandlers._creditsPerGame = data.creditsPerGame; + } + if (typeof UI !== 'undefined' && UI.updateGameTitle) { UI.updateGameTitle(data.cryptoType); } diff --git a/html/js/ui/transactionHistory.js b/html/js/ui/transactionHistory.js new file mode 100644 index 0000000..9b5fa68 --- /dev/null +++ b/html/js/ui/transactionHistory.js @@ -0,0 +1,231 @@ +/** + * Transaction History Panel + * Displays payment and payout history for the user + */ +const TransactionHistory = { + _isLoading: false, + _paymentsData: null, + _payoutsData: null, + + init: function() { + // Bind close button + $('#close-history').on('click', function() { + $('#history-panel').hide(); + }); + + // Bind history button + $('#historyButton').on('click', function() { + TransactionHistory.show(); + }); + + // Bind tab switching + $('.history-tab').on('click', function() { + const tab = $(this).data('tab'); + TransactionHistory._switchTab(tab); + }); + }, + + _switchTab: function(tab) { + // Update tab button styles + $('.history-tab').each(function() { + if ($(this).data('tab') === tab) { + $(this).css({ background: '#4c1d95', color: '#fff' }); + $(this).addClass('active'); + } else { + $(this).css({ background: '#333', color: '#aaa' }); + $(this).removeClass('active'); + } + }); + + // Show/hide content + if (tab === 'payouts') { + $('#payouts-content').show(); + $('#payments-content').hide(); + } else { + $('#payouts-content').hide(); + $('#payments-content').show(); + } + }, + + show: function() { + $('#history-panel').show(); + this._loadData(); + }, + + hide: function() { + $('#history-panel').hide(); + }, + + _loadData: function() { + if (this._isLoading) return; + this._isLoading = true; + + const socketId = window.socket?.id; + if (!socketId) { + this._showError('Not connected to server'); + this._isLoading = false; + return; + } + + // Load both endpoints in parallel + Promise.all([ + this._fetchPayments(socketId), + this._fetchPayouts(socketId) + ]).then(([payments, payouts]) => { + this._paymentsData = payments; + this._payoutsData = payouts; + this._render(); + }).catch(err => { + console.error('Failed to load transaction history:', err); + this._showError('Failed to load history'); + }).finally(() => { + this._isLoading = false; + }); + }, + + _fetchPayments: function(socketId) { + return fetch(`/api/user/${encodeURIComponent(socketId)}/payments?limit=50`) + .then(res => res.json()) + .catch(() => ({ payments: [], total: 0 })); + }, + + _fetchPayouts: function(socketId) { + return fetch(`/api/user/${encodeURIComponent(socketId)}/payouts?limit=50`) + .then(res => res.json()) + .catch(() => ({ payouts: [], total: 0, totalReceived: 0 })); + }, + + _render: function() { + this._renderSummary(); + this._renderPayouts(); + this._renderPayments(); + }, + + _renderSummary: function() { + const payouts = this._payoutsData || {}; + const payments = this._paymentsData || {}; + + const currency = payouts.currency || payments.currency || 'WOW'; + const totalReceived = payouts.totalReceivedFormatted || '0'; + const totalPayments = payments.total || 0; + + $('#total-received').text(totalReceived + ' ' + currency); + $('#total-payments').text(totalPayments); + }, + + _renderPayouts: function() { + const $list = $('#payouts-list'); + const $empty = $('#payouts-empty'); + const data = this._payoutsData || {}; + const payouts = data.payouts || []; + const currency = data.currency || 'WOW'; + + $list.empty(); + + if (payouts.length === 0) { + $empty.show(); + return; + } + + $empty.hide(); + + payouts.forEach(payout => { + const statusColor = this._getStatusColor(payout.status); + const date = new Date(payout.createdAt).toLocaleDateString(); + const multiplierText = payout.multiplier ? ` (${payout.multiplier}x)` : ''; + + const $item = $(` +
+
+ ${payout.amountFormatted} ${currency}${multiplierText} + ${payout.status} +
+
+ ${payout.reason || 'Game reward'} β€’ ${date} +
+ ${payout.txHash ? `
TX: ${payout.txHash.substring(0, 16)}...
` : ''} +
+ `); + + $list.append($item); + }); + }, + + _renderPayments: function() { + const $list = $('#payments-list'); + const $empty = $('#payments-empty'); + const data = this._paymentsData || {}; + const payments = data.payments || []; + const currency = data.currency || 'WOW'; + + $list.empty(); + + if (payments.length === 0) { + $empty.show(); + return; + } + + $empty.hide(); + + payments.forEach(payment => { + const statusColor = this._getStatusColor(payment.status); + const date = new Date(payment.createdAt).toLocaleDateString(); + const typeLabel = this._getPaymentTypeLabel(payment.type); + const creditsText = payment.creditsReceived > 0 ? ` β†’ +${payment.creditsReceived} credits` : ''; + + const $item = $(` +
+
+ ${payment.amountFormatted} ${currency} + ${payment.status} +
+
+ ${typeLabel}${creditsText} β€’ ${date} +
+
+ `); + + $list.append($item); + }); + }, + + _getStatusColor: function(status) { + switch (status) { + case 'confirmed': + case 'completed': + return '#4ade80'; + case 'pending': + return '#f59e0b'; + case 'failed': + case 'expired': + return '#ef4444'; + default: + return '#888'; + } + }, + + _getPaymentTypeLabel: function(type) { + switch (type) { + case 'single_game': + return 'πŸ’° Single Game'; + case 'credits_package': + return '🎫 Credits Package'; + default: + return type || 'Payment'; + } + }, + + _showError: function(message) { + $('#payouts-list').html(`
${message}
`); + $('#payments-list').html(`
${message}
`); + $('#payouts-empty, #payments-empty').hide(); + } +}; + +// Expose globally +window.TransactionHistory = TransactionHistory; + +// Initialize when DOM is ready +$(document).ready(function() { + TransactionHistory.init(); +}); diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh new file mode 100644 index 0000000..2808e97 --- /dev/null +++ b/scripts/backup_db.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# PostgreSQL backup script for Wownerogue +# Reads configuration from environment variables (set in .env or systemd service) + +set -e + +# Configuration with defaults +DB_NAME="${DB_NAME:-wownerogue}" +DB_USER="${DB_USER:-postgres}" +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +BACKUP_DIR="${BACKUP_DIR:-/var/backups/wownerogue}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" + +# Create backup directory if it doesn't exist +mkdir -p "$BACKUP_DIR" + +# Timestamp for filename +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +log "Starting backup of database '$DB_NAME' on $DB_HOST:$DB_PORT..." + +# Perform backup with gzip compression +if pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" | gzip > "$BACKUP_FILE"; then + BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + log "Backup completed successfully: $BACKUP_FILE ($BACKUP_SIZE)" +else + log "ERROR: Backup failed!" + rm -f "$BACKUP_FILE" # Remove partial backup + exit 1 +fi + +# Create a symlink to latest backup for easy access +ln -sf "$BACKUP_FILE" "$BACKUP_DIR/${DB_NAME}_LATEST.sql.gz" + +# Delete backups older than retention period +log "Cleaning up backups older than $RETENTION_DAYS days..." +DELETED_COUNT=$(find "$BACKUP_DIR" -name "${DB_NAME}_[0-9]*.sql.gz" -mtime +$RETENTION_DAYS -delete -print | wc -l) +log "Deleted $DELETED_COUNT old backup(s)" + +# Show disk usage +TOTAL_SIZE=$(du -sh "$BACKUP_DIR" | cut -f1) +BACKUP_COUNT=$(find "$BACKUP_DIR" -name "${DB_NAME}_[0-9]*.sql.gz" | wc -l) +log "Backup directory: $BACKUP_DIR ($TOTAL_SIZE, $BACKUP_COUNT backups)" + +log "Backup process complete." diff --git a/scripts/wownerogue-backup.service b/scripts/wownerogue-backup.service new file mode 100644 index 0000000..cc33d5e --- /dev/null +++ b/scripts/wownerogue-backup.service @@ -0,0 +1,26 @@ +[Unit] +Description=Wownerogue PostgreSQL Backup +After=postgresql.service + +[Service] +Type=oneshot +# Run as the user who owns the application (adjust as needed) +# User=wownerogue + +# Load environment from the application's .env file +# Update this path to match your installation +EnvironmentFile=/var/www/wownerogue/src/.env + +# Override backup-specific settings if needed +Environment="BACKUP_DIR=/var/backups/wownerogue" +Environment="RETENTION_DAYS=30" + +# Run the backup script +# Update this path to match your installation +ExecStart=/var/www/wownerogue/scripts/backup_db.sh + +# Working directory +WorkingDirectory=/var/www/wownerogue/src + +[Install] +WantedBy=multi-user.target diff --git a/scripts/wownerogue-backup.timer b/scripts/wownerogue-backup.timer new file mode 100644 index 0000000..5ac5a5a --- /dev/null +++ b/scripts/wownerogue-backup.timer @@ -0,0 +1,15 @@ +[Unit] +Description=Daily Wownerogue database backup + +[Timer] +# Run daily at 3 AM +OnCalendar=*-*-* 03:00:00 + +# Run immediately if a scheduled run was missed (e.g., system was powered off) +Persistent=true + +# Add random delay up to 15 minutes to avoid exact timing +RandomizedDelaySec=900 + +[Install] +WantedBy=timers.target diff --git a/src/.env.example b/src/.env.example index 9f3a57b..52d5c1f 100644 --- a/src/.env.example +++ b/src/.env.example @@ -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 diff --git a/src/config/environmentValidator.js b/src/config/environmentValidator.js index 94a22be..5de02fe 100644 --- a/src/config/environmentValidator.js +++ b/src/config/environmentValidator.js @@ -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}`)); } diff --git a/src/index.js b/src/index.js index 3df65f2..2c3cecc 100644 --- a/src/index.js +++ b/src/index.js @@ -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); diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index 867c547..1720574 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -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