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
+185
View File
@@ -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`
-104
View File
@@ -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
+218 -517
View File
@@ -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 <repository-url>
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": "<XMR/WOW 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 <repository-url>
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=<rpc-user>
# WALLET_RPC_PASSWORD=<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.
+3 -3
View File
@@ -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`) ✅
+45 -1
View File
@@ -56,6 +56,7 @@
<script src="js/ui/paymentUI.js"></script>
<script src="js/ui/audioAlerts.js"></script>
<script src="js/ui/addressModal.js"></script>
<script src="js/ui/transactionHistory.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/input/inputHandler.js"></script>
</head>
@@ -88,7 +89,8 @@
<label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
<input type="checkbox" id="audioAlertsToggle" style="cursor:pointer;"> Audio Alerts
</label>
<button id="manageAddressButton" style="margin-left:auto; background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
<button id="historyButton" style="margin-left:auto; background:#1a1a4d; color:#c4b5fd; border:1px solid #8b5cf6; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View your payment and payout history">📜 History</button>
<button id="manageAddressButton" style="background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
<span id="sessionWarning" style="display:none; color:#f59e0b; font-size:10px; margin-left:6px;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
</div>
</div>
@@ -132,6 +134,48 @@
</div>
</div>
<!-- Transaction History Panel -->
<div id="history-panel" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2000; background: rgba(10,10,30,0.98); border: 2px solid #8b5cf6; padding: 20px; color: #e0e0e0; min-width: 500px; max-width: 600px; max-height: 80vh; overflow-y: auto; box-shadow: 0 0 20px rgba(139,92,246,0.3); border-radius: 8px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; border-bottom:1px solid #444; padding-bottom:10px;">
<strong style="font-size:1.2em; color:#c4b5fd;">📜 Transaction History</strong>
<button id="close-history" style="background: #500; color: #f00; border: 1px solid #f00; padding: 4px 8px; cursor: pointer; border-radius:3px;">✕</button>
</div>
<!-- Summary Section -->
<div id="history-summary" style="display:grid; grid-template-columns:1fr 1fr; gap:15px; margin-bottom:20px;">
<div style="background:#1a1a3d; padding:12px; border-radius:6px; border:1px solid #4c1d95;">
<div style="color:#888; font-size:0.85em;">Total Received</div>
<div id="total-received" style="color:#4ade80; font-size:1.3em; font-weight:bold;">0 WOW</div>
</div>
<div style="background:#1a1a3d; padding:12px; border-radius:6px; border:1px solid #4c1d95;">
<div style="color:#888; font-size:0.85em;">Total Payments</div>
<div id="total-payments" style="color:#60a5fa; font-size:1.3em; font-weight:bold;">0</div>
</div>
</div>
<!-- Tabs -->
<div style="display:flex; gap:10px; margin-bottom:15px;">
<button class="history-tab active" data-tab="payouts" style="flex:1; padding:8px; background:#4c1d95; color:#fff; border:none; cursor:pointer; border-radius:4px;">💰 Payouts</button>
<button class="history-tab" data-tab="payments" style="flex:1; padding:8px; background:#333; color:#aaa; border:none; cursor:pointer; border-radius:4px;">💳 Payments</button>
</div>
<!-- Payouts Tab Content -->
<div id="payouts-content" class="history-content">
<div id="payouts-list" style="max-height:300px; overflow-y:auto;"></div>
<div id="payouts-empty" style="text-align:center; color:#666; padding:20px;">No payouts yet</div>
</div>
<!-- Payments Tab Content -->
<div id="payments-content" class="history-content" style="display:none;">
<div id="payments-list" style="max-height:300px; overflow-y:auto;"></div>
<div id="payments-empty" style="text-align:center; color:#666; padding:20px;">No payments yet</div>
</div>
<div style="margin-top:15px; font-size:0.8em; color:#666; text-align:center;">
History is tied to your browser session
</div>
</div>
<!-- Game display -->
<div id="game-display" class="rotdis"></div>
+25 -2
View File
@@ -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);
}
+231
View File
@@ -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 = $(`
<div style="background:#1a1a2e; padding:12px; margin-bottom:8px; border-radius:6px; border-left:3px solid ${statusColor};">
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="color:#4ade80; font-weight:bold;">${payout.amountFormatted} ${currency}${multiplierText}</span>
<span style="color:${statusColor}; font-size:0.85em; text-transform:uppercase;">${payout.status}</span>
</div>
<div style="color:#888; font-size:0.85em; margin-top:4px;">
${payout.reason || 'Game reward'} • ${date}
</div>
${payout.txHash ? `<div style="color:#60a5fa; font-size:0.75em; margin-top:4px; word-break:break-all;">TX: ${payout.txHash.substring(0, 16)}...</div>` : ''}
</div>
`);
$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 = $(`
<div style="background:#1a1a2e; padding:12px; margin-bottom:8px; border-radius:6px; border-left:3px solid ${statusColor};">
<div style="display:flex; justify-content:space-between; align-items:center;">
<span style="color:#60a5fa; font-weight:bold;">${payment.amountFormatted} ${currency}</span>
<span style="color:${statusColor}; font-size:0.85em; text-transform:uppercase;">${payment.status}</span>
</div>
<div style="color:#888; font-size:0.85em; margin-top:4px;">
${typeLabel}${creditsText} • ${date}
</div>
</div>
`);
$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(`<div style="color:#ef4444; text-align:center; padding:20px;">${message}</div>`);
$('#payments-list').html(`<div style="color:#ef4444; text-align:center; padding:20px;">${message}</div>`);
$('#payouts-empty, #payments-empty').hide();
}
};
// Expose globally
window.TransactionHistory = TransactionHistory;
// Initialize when DOM is ready
$(document).ready(function() {
TransactionHistory.init();
});
+51
View File
@@ -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."
+26
View File
@@ -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
+15
View File
@@ -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
+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