mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
add backup scripts and improve docs and .env.example
This commit is contained in:
@@ -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`
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user